diff --git a/.dockerignore b/.dockerignore index 00362da..94ac69e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,6 +7,11 @@ web/node_modules web/dist web/test-results vault +.generated +data +dashboard +private +attachments .env .secrets .backups diff --git a/.env.example b/.env.example index 8752195..441dab9 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,9 @@ PM_APP_TOKEN=replace-with-a-long-random-value # Defaults to ./vault. An absolute directory is recommended for real data. PM_VAULT_ROOT= +# Defaults to /.generated. +PM_OUTPUT_ROOT= + # Comma-separated browser origins allowed to call the API. PM_CORS_ORIGINS= @@ -12,5 +15,5 @@ PM_GITHUB_SYNC_ENABLED=false PM_GITHUB_REPO=your-account/your-private-vault PM_GITHUB_BRANCH=main PM_GITHUB_TOKEN= -PM_GITHUB_AUTHOR_NAME=Markdown Task Manager -PM_GITHUB_AUTHOR_EMAIL=task-manager@example.invalid +PM_GITHUB_AUTHOR_NAME=Research Workbench Automation +PM_GITHUB_AUTHOR_EMAIL=workbench-automation@example.invalid diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..757c625 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,52 @@ +name: Bug report +description: Report a reproducible problem using synthetic data. +title: "[Bug] " +labels: [bug] +body: + - type: markdown + attributes: + value: | + Do not paste private vault content, credentials, local paths, or screenshots containing real data. + - type: input + id: version + attributes: + label: Version + description: Release tag or commit SHA. + validations: + required: true + - type: dropdown + id: install + attributes: + label: Installation + options: + - Local Python and Node + - Docker Compose + - Other private deployment + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Synthetic reproduction + description: Provide the smallest reproduction using example-vault or fabricated content. + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior + validations: + required: true + - type: checkboxes + id: privacy + attributes: + label: Privacy check + options: + - label: I removed private content, credentials, identifying paths, and real personal data. + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..792d547 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Private vulnerability report + url: https://github.com/sfuchs-de/markdown-task-manager/security/advisories/new + about: Report security problems privately; never post secrets or vault excerpts. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..6edb197 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,30 @@ +name: Feature request +description: Propose a generic, privacy-safe improvement. +title: "[Feature] " +labels: [enhancement] +body: + - type: textarea + id: problem + attributes: + label: Problem + description: What generic workflow is difficult today? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed behavior + validations: + required: true + - type: textarea + id: vault + attributes: + label: Vault or schema impact + description: Describe new fields, migrations, or compatibility concerns. + - type: textarea + id: privacy + attributes: + label: Privacy and security impact + description: Explain whether this changes data exposure, authentication, or exports. + validations: + required: true diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..a846a87 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,18 @@ +## Summary + +Describe the user-visible change and its motivation. + +## Verification + +- [ ] `make test` +- [ ] `make build` +- [ ] `make privacy` +- [ ] `make links` +- [ ] Relevant local or Docker smoke test + +## Privacy and compatibility + +- [ ] Tests, screenshots, and examples contain only fictional data. +- [ ] No credentials, private URLs, identifying paths, binary documents, or vault output were added. +- [ ] Vault/frontmatter changes are backward compatible or have an upgrade note. +- [ ] `private: true` is treated as a display filter, not encryption. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b7e90e..5d3e974 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,16 +16,27 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - uses: actions/setup-python@v6 with: python-version: "3.11" cache: pip - run: python -m pip install -r requirements-dev.txt - run: python -m pytest - - run: python scripts/privacy_scan.py + - name: Validate a pinned synthetic vault + run: | + vault="$(mktemp -d)" + python scripts/pm.py init --vault "$vault" --today 2026-07-30 + PM_VAULT_ROOT="$vault" PM_OUTPUT_ROOT="$vault/.generated" python scripts/pm.py doctor + python scripts/validate_vault_schema.py --root "$vault" + PM_VAULT_ROOT="$vault" PM_OUTPUT_ROOT="$vault/.generated" python scripts/sync_markdown.py + PM_VAULT_ROOT="$vault" PM_OUTPUT_ROOT="$vault/.generated" PM_RENDER_TIMESTAMP=2026-07-30T12:00:00Z python scripts/render_static.py + - run: python scripts/check_links.py + - run: python scripts/privacy_scan.py --all-files --history - run: python -m pip install pip-audit && python -m pip_audit -r requirements-dev.txt - - uses: actions/setup-node@v7 + - uses: actions/setup-node@v6 with: node-version: "22" cache: npm @@ -34,11 +45,13 @@ jobs: - run: npm --prefix web audit --audit-level=moderate - run: npm --prefix web test - run: npm --prefix web run build - - run: npm --prefix web exec -- playwright install --with-deps chromium webkit + - run: cd web && npx playwright install --with-deps chromium - run: npm --prefix web run test:e2e + - run: git diff --check container: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 - - run: docker build --tag markdown-task-manager:test . + - uses: actions/checkout@v6 + - run: docker build --tag research-workbench:test . + - run: python scripts/docker_smoke.py --image research-workbench:test diff --git a/.gitignore b/.gitignore index c1c1238..f1ab037 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,9 @@ web/vite.config.js web/vite.config.d.ts web/vitest.config.js web/vitest.config.d.ts +.generated/ +data/ +dashboard/ # User vaults are intentionally outside the public source history. vault/* @@ -22,6 +25,19 @@ vault/* # Local exports and credentials. .secrets/ .backups/ +private/ +attachments/ exports/ *.sqlite *.db +*.pdf +*.doc +*.docx +*.xls +*.xlsx +*.ppt +*.pptx +*.dta +*.parquet +*.feather +*.rds diff --git a/CHANGELOG.md b/CHANGELOG.md index 63010bd..8993e38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,17 @@ # Changelog -## 1.0.0 +## 1.1.0 -- Initial public, privacy-reviewed edition. -- Markdown projects, tasks, notes, and calendar metadata. +- Initial Research Workbench release. +- Configurable projects, tasks, notes, calendar, boards, graph, and time planning. +- Metadata-driven admin, travel, collaborator, wellness, performance, and + scholar-metrics modules. - Search, reading, editing, task-status controls, wiki links, and backlinks. - Responsive light/dark web interface. -- Local, Docker, and Render setup. +- Local and Docker/private self-hosting setup. - Optional private GitHub vault synchronization. - Synthetic example vault and automated privacy scanning. + +The repository already contained a `v1.0.0` public release before the +Research Workbench generalization, so the first generalized release is +numbered `v1.1.0`. diff --git a/Dockerfile b/Dockerfile index 03e9328..fbb030c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,12 +8,15 @@ RUN npm --prefix web run build FROM python:3.11-slim ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ - PM_VAULT_ROOT=/vault + PM_VAULT_ROOT=/vault \ + PM_OUTPUT_ROOT=/vault/.generated WORKDIR /app COPY requirements.txt . RUN python -m pip install --no-cache-dir -r requirements.txt COPY server ./server COPY scripts ./scripts +COPY templates ./templates +COPY example-vault ./example-vault COPY --from=web /app/web/dist ./web/dist RUN mkdir -p /vault && useradd --create-home --uid 10001 app && chown -R app:app /app /vault USER app diff --git a/LICENSE b/LICENSE index 05e513e..2dd0417 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 Markdown Task Manager contributors +Copyright (c) 2026 Simon Fuchs Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Makefile b/Makefile index bd0f512..954256f 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: setup demo dev api web test build privacy docker +.PHONY: setup init dev api web test test-empty test-e2e build doctor privacy links qa docker PYTHON ?= python3 @@ -7,8 +7,13 @@ setup: .venv/bin/python -m pip install -r requirements-dev.txt npm --prefix web ci -demo: - .venv/bin/python scripts/bootstrap_demo.py +init: + .venv/bin/python scripts/pm.py init --vault vault + +demo: init + +dev: + @echo "Run 'make api' and 'make web' in separate terminals." api: PM_VAULT_ROOT="$${PM_VAULT_ROOT:-$$(pwd)/vault}" .venv/bin/python -m uvicorn server.app:app --host 127.0.0.1 --port 8765 --reload @@ -20,14 +25,25 @@ test: .venv/bin/python -m pytest npm --prefix web run test +test-empty: + @tmp=$$(mktemp -d); PM_VAULT_ROOT="$$tmp" .venv/bin/python -m pytest; rm -rf "$$tmp" + test-e2e: npm --prefix web run test:e2e build: npm --prefix web run build +doctor: + .venv/bin/python scripts/pm.py doctor + privacy: .venv/bin/python scripts/privacy_scan.py --all-files +links: + .venv/bin/python scripts/check_links.py + +qa: test build privacy links + docker: docker compose up --build diff --git a/README.md b/README.md index 21950df..75fd2dd 100644 --- a/README.md +++ b/README.md @@ -1,142 +1,137 @@ -# Markdown Task Manager +# Research Workbench -A private-by-default task and project workbench built on ordinary Markdown -files. +Research Workbench is a configurable, Markdown-first project manager for +research. This repository is named `markdown-task-manager` for continuity; the +application and interface are called **Research Workbench**. -The application gives you a searchable dashboard, project and task views, -Markdown editing and preview, wiki links, lightweight metadata controls, -calendar-oriented filtering, and optional synchronization to a **separate -private GitHub vault**. Your files remain readable in any editor and are never -locked into a database. +Projects, tasks, notes, dates, plans, and module records stay as ordinary +Markdown with YAML frontmatter. The application adds a searchable dashboard, +editing, planning, visual summaries, and optional synchronization without +turning the vault into a proprietary database. -## What is public—and what is not - -This repository contains only application code, documentation, and a fictional -example vault. It contains no user vault, credentials, messages, calendar -events, health records, travel plans, collaborator records, or private Git -history. - -When you use the app, keep your real vault outside this repository or under the -ignored `vault/` directory. If you synchronize it with GitHub, create a separate -**private** repository for the vault. +![Research Workbench overview built from the fictional starter vault](docs/images/overview.png) ## Features -- Markdown and YAML frontmatter remain canonical. -- Overview counts for projects, open tasks, waiting work, and notes. -- Searchable Tasks, Projects, Calendar, and full Library views. -- Read and edit modes with conflict-aware, atomic saves. -- Quick task-status changes and lifecycle moves. -- `[[wiki links]]`, `[[document-id|labels]]`, aliases, and backlinks. -- Responsive light and dark interfaces. -- Token-protected remote access. -- Optional, explicit GitHub push and restore workflow. -- Privacy and credential scans in CI. +- Tasks, projects, notes, calendar, boards, graph, and time planning +- Admin, travel, collaborator/RA, wellness, performance-review, and scholar + metrics modules +- Markdown editing, KaTeX math, wiki links, aliases, and backlinks +- Vault-local application name, owner aliases, module labels, and domain rules +- Deterministic static views under `/.generated` +- Optional GitHub synchronization, disabled by default and dry-run-first +- Local Python/Node operation and Docker-based private self-hosting -## Try it in five minutes +All examples are synthetic. No personal vault, private Git history, generated +dashboard, correspondence, calendar, health record, travel plan, collaborator +record, or binary document belongs in this repository. -Requirements: Python 3.11+, Node.js 22+, and npm. +## Five-minute start + +Requirements: Python 3.11+, Node.js 22+, npm, and Git. ```bash -git clone https://github.com//markdown-task-manager.git +git clone https://github.com/sfuchs-de/markdown-task-manager.git cd markdown-task-manager make setup -make demo +make init ``` -Start the API in one terminal: +Run the API and web application in separate terminals: ```bash make api +make web ``` -Start the web application in a second terminal: +Open [http://127.0.0.1:5173](http://127.0.0.1:5173). To use an existing vault: ```bash -make web +PM_VAULT_ROOT="/absolute/path/to/vault" make api ``` -Open [http://127.0.0.1:5173](http://127.0.0.1:5173). Localhost access does not -require a token by default. - -To use an existing vault instead: +Useful checks: ```bash -PM_VAULT_ROOT="/absolute/path/to/your/vault" make api +make doctor +make qa +make test-e2e ``` -The app never modifies a file until you explicitly save or change task -metadata. +`python scripts/pm.py init --vault ` refuses to overwrite a nonempty +directory. `PM_OUTPUT_ROOT` defaults to `/.generated`. + +## Configuration -## Markdown format +The starter vault includes `settings/workbench.yml`. It controls the +application name, owner aliases, enabled modules, custom module labels, domain +labels, metadata-based classification rules, and an optional scholar-statistics +source. -A task is a Markdown file with a small YAML header: +Frontmatter is the primary classifier: ```markdown --- kind: task -id: revise-introduction -title: Revise the introduction +id: estimate-baseline +title: Estimate the baseline status: active +domain: research +area: modeling +project: harbor-flows priority: 1 -project: paper-example due: 2026-09-30 -assignee: Example User +deadline_type: soft +assignee: Owner --- -# Revise the introduction - -## Next action - -- Incorporate the argument from [[identification-note]]. ``` -Projects live at `projects//README.md`; tasks commonly live under -`tasks/active`, `tasks/waiting`, and `tasks/done`. The parser also accepts less -structured Markdown, so you can adopt the conventions incrementally. - -See [Vault format](docs/VAULT_FORMAT.md) for the complete recommended layout. +Unmatched entries are assigned to `other`; project names are not hidden +classification rules. See [Vault format](docs/VAULT_FORMAT.md), +[Configuration](docs/CONFIGURATION.md), and [Modules](docs/MODULES.md). ## Docker -Copy the example vault or add your own files under `vault/`, choose a strong -token, and start the container: - ```bash -cp -R example-vault/. vault/ -export PM_APP_TOKEN="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')" +make init docker compose up --build ``` -Open [http://127.0.0.1:8765](http://127.0.0.1:8765). The vault is mounted into -the container and is not built into the image. +Compose binds only to `127.0.0.1:8765`, mounts the writable vault at `/vault`, +and runs the container as a non-root user. For access from another machine, +set a strong `PM_APP_TOKEN`, use TLS, and put the service behind an +access-controlled network boundary. There is no public hosted demo. -## Remote access +See [Docker and private hosting](docs/DOCKER.md). -Do not expose the server directly without `PM_APP_TOKEN` and TLS. The API -requires a bearer token for non-local requests and deliberately returns no -credentials or vault contents from its health endpoint. +## Privacy boundary -For deployment, backup, and optional GitHub synchronization, read -[Self-hosting](docs/SELF_HOSTING.md) and [Privacy model](docs/PRIVACY.md). +`private: true` is a display filter, **not encryption or access control**. +The bearer token is not a multi-user identity system. Keep the vault on +encrypted storage, use an access-controlled network for remote instances, and +review any static export before sharing it. -## Development +GitHub synchronization is off unless explicitly enabled. If used, point it at +a separate private vault repository. Read [Privacy and security](docs/PRIVACY.md) +and [GitHub synchronization](docs/GITHUB_SYNC.md) first. -```bash -make test -make build -make privacy -``` +## Documentation -Pull requests run Python tests, TypeScript checks, frontend tests, a production -build, the privacy scan, and a container build. +- [CLI](docs/CLI.md) +- [Architecture](docs/ARCHITECTURE.md) +- [Backup and restore](docs/BACKUP_RESTORE.md) +- [Upgrading](docs/UPGRADING.md) +- [Contributing](CONTRIBUTING.md) and [security policy](SECURITY.md) -## Scope +## Status and support -This public edition focuses on the reusable core: projects, tasks, notes, -calendar metadata, Markdown editing, links, and private synchronization. -Personalized modules and any user-specific data from the original private -installation are intentionally not distributed. +Version `1.x` is self-hosted software. Interfaces may change between minor +releases; tagged releases and the changelog document migrations. This is +a community-supported project with no uptime or response-time guarantee. +Use GitHub Issues for reproducible bugs and feature proposals. Do not include +private vault excerpts, credentials, screenshots of real data, or sensitive +paths in an issue. ## License -[MIT](LICENSE). Use it, adapt it, and keep your own vault private. +[MIT](LICENSE). diff --git a/compose.yaml b/compose.yaml index 07c5d05..9c13ffd 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,11 +1,12 @@ services: - task-manager: + workbench: build: . ports: - - "8765:8765" + - "127.0.0.1:8765:8765" environment: - PM_APP_TOKEN: "${PM_APP_TOKEN:-change-me-before-network-use}" + PM_APP_TOKEN: "${PM_APP_TOKEN:-}" PM_VAULT_ROOT: /vault + PM_OUTPUT_ROOT: /vault/.generated volumes: - ./vault:/vault read_only: true diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..9b75bbc --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,21 @@ +# Architecture + +Research Workbench has four layers: + +1. The user-selected vault contains canonical Markdown and safe settings. +2. Python readers parse YAML frontmatter, enforce path boundaries, and expose a + FastAPI JSON/file API. +3. The React application derives views from entry metadata and edits Markdown + through conflict-aware API calls. +4. Optional JSON and static HTML are generated under `/.generated`. + +No database is required. The server preserves unknown frontmatter fields and +uses atomic file replacement for writes. The configuration endpoint exposes a +small allowlist rather than arbitrary settings. + +GitHub sync operates through a temporary checkout and an explicit vault-file +allowlist. It starts disabled and reports a dry-run diff before mutation. + +The public application repository never reads from the private customized +repository. A private installation may import a reviewed public tag through a +one-way, allowlisted utility; there is no reverse synchronization path. diff --git a/docs/BACKUP_RESTORE.md b/docs/BACKUP_RESTORE.md new file mode 100644 index 0000000..5ca05b5 --- /dev/null +++ b/docs/BACKUP_RESTORE.md @@ -0,0 +1,16 @@ +# Backup and restore + +The vault is the durable state. Back up the whole vault except disposable +`.generated` output. + +Before an upgrade or GitHub restore: + +1. Stop writes. +2. Copy the vault to encrypted storage. +3. Record the application tag. +4. Run `pm.py validate-schema`. +5. Test the copy by starting Research Workbench against it. + +GitHub restore is optional and can create a timestamped `.backups` snapshot. +It does not replace an offline backup. Keep credentials and attachments outside +the application repository and test restoration periodically. diff --git a/docs/CLI.md b/docs/CLI.md new file mode 100644 index 0000000..8400498 --- /dev/null +++ b/docs/CLI.md @@ -0,0 +1,20 @@ +# CLI + +Run commands with `.venv/bin/python scripts/pm.py`. + +```text +init --vault PATH create a starter vault; never overwrite nonempty PATH +doctor read-only dependency and configuration checks +status list project state +today show the near-term task queue +focus show a compact focus view +sync rebuild generated JSON from Markdown +render rebuild deterministic static views +validate-schema check task and structured-frontmatter conventions +privacy-scan scan the active vault for obvious private residue +plan allocate a local time plan +ai-plan-context emit a copyable planning context without changing tasks +``` + +The CLI honors `PM_VAULT_ROOT` and `PM_OUTPUT_ROOT`. Markdown remains canonical; +JSON and HTML under `.generated` are disposable caches. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md new file mode 100644 index 0000000..71c2229 --- /dev/null +++ b/docs/CONFIGURATION.md @@ -0,0 +1,46 @@ +# Configuration + +Create `/settings/workbench.yml`. Missing fields inherit safe defaults. + +```yaml +application: + name: Research Workbench + owner_label: Owner + owner_aliases: [Owner, me, self] + +modules: + travel: {enabled: true, label: Travel Center} + wellness: {enabled: false, label: Wellness} + +domains: + labels: + research: Research + admin: Administration + other: Other + classification_rules: + - {domain: research, field: domain, values: [research]} + +profile: + scholar_statistics_source: settings/scholar-stats.json +``` + +`GET /api/config` returns only display-safe values: the application label, +owner aliases, module state/labels, and domain labels. It never returns source +paths, synchronization credentials, or arbitrary configuration. + +Classification is metadata-first. Set `domain`, `area`, `kind`, `project`, and +module-specific fields in frontmatter. A task can inherit a domain from its +project. Unmatched material becomes `other`. + +Environment variables retain the `PM_*` prefix: + +| Variable | Purpose | +| --- | --- | +| `PM_VAULT_ROOT` | Active vault; defaults to `./vault` | +| `PM_OUTPUT_ROOT` | Generated cache/output; defaults to `/.generated` | +| `PM_APP_TOKEN` | Bearer token required for non-local requests | +| `PM_REQUIRE_LOCAL_AUTH` | Require the token on localhost | +| `PM_CORS_ORIGINS` | Additional comma-separated browser origins | +| `PM_GITHUB_SYNC_ENABLED` | Enables optional vault synchronization | + +See `.env.example` for the full synchronization set. diff --git a/docs/DOCKER.md b/docs/DOCKER.md new file mode 100644 index 0000000..89eb4ed --- /dev/null +++ b/docs/DOCKER.md @@ -0,0 +1,34 @@ +# Docker and private hosting + +Initialize a vault, then start Compose: + +```bash +make init +docker compose up --build +``` + +The default host binding is `127.0.0.1:8765`. The container runs as an +unprivileged user with a read-only root filesystem and a writable `/vault` +mount. Edits and `.generated` output persist across restarts. + +On Linux, a bind-mounted vault must be writable by container UID/GID `10001`. +Either adjust the directory ownership deliberately or set compatible bind +permissions before starting Compose. Do not make a sensitive vault +world-writable. Docker Desktop normally translates bind-mount permissions. + +For remote access: + +1. Set a long random `PM_APP_TOKEN`. +2. Terminate TLS at a trusted reverse proxy. +3. Restrict access with a VPN, identity-aware proxy, or private network. +4. Back up the mounted vault. +5. Verify an unauthenticated content request returns `401`. + +To require the token even on localhost, add +`PM_REQUIRE_LOCAL_AUTH=true`. The CI container smoke test verifies a +localhost-only port, non-root execution, token rejection, a persisted Markdown +edit after restart, and disabled-by-default GitHub synchronization. + +The application has no public demo and should not be exposed directly to the +internet. The bearer token is a single-instance gate, not multi-user +authorization. diff --git a/docs/GITHUB_SYNC.md b/docs/GITHUB_SYNC.md new file mode 100644 index 0000000..1ff59a1 --- /dev/null +++ b/docs/GITHUB_SYNC.md @@ -0,0 +1,20 @@ +# GitHub synchronization + +Synchronization is optional, disabled by default, and intended only for a +separate private vault repository. + +```text +PM_GITHUB_SYNC_ENABLED=true +PM_GITHUB_REPO=account/private-vault +PM_GITHUB_BRANCH=main +PM_GITHUB_TOKEN= +``` + +Use a token with Contents access only to that repository. Status and dry-run +operations inspect changes first. Push and restore require explicit actions; +remote deletions and local extra-file deletion are opt-in. Restore can make a +local backup before changing Markdown. + +Never configure synchronization to the public application repository. The +public release-import path for a private customized installation is a separate, +one-way code utility; it never exports private files. diff --git a/docs/MODULES.md b/docs/MODULES.md new file mode 100644 index 0000000..836de36 --- /dev/null +++ b/docs/MODULES.md @@ -0,0 +1,25 @@ +# Modules + +Modules are enabled and labeled in `settings/workbench.yml`. Empty modules show +an empty state; they do not require placeholder files. + +| Module | Primary metadata | +| --- | --- | +| Tasks | `kind: task`, `status`, `priority`, `due`, `deadline_type` | +| Projects | `kind: project`, `id`, `domain`, `status` | +| Notes/editor | Any Markdown kind, plus `project`, `area`, and aliases | +| Calendar | `date`, `start_date`, `end_date` | +| Time planning | Task duration and scheduling fields; vault settings | +| Admin | `domain: admin`, optional `admin_category` | +| Travel | `module: travel`, `trip_key`, travel kinds, optional `ledger` | +| Collaborators | collaborator kinds, `module: collaborators`, `assignee` | +| Wellness | `area` or `module: wellness`, optional `wellness_category` | +| Performance | `performance-category`, `performance-achievement`, and verification kinds | +| Scholar metrics | JSON source configured in `workbench.yml` | +| GitHub sync | Environment configuration; disabled by default | + +The internal `ra-management` view identifier remains for API/UI compatibility, +but its default public label is “Collaborators.” + +The complete fictional examples under `example-vault/` are the reference +fixtures for supported frontmatter. diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 26454f3..4c57029 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -1,6 +1,6 @@ # Privacy model -Markdown Task Manager is designed for private data, but privacy depends on how +Research Workbench is designed for private data, but privacy depends on how you deploy and store the vault. ## Boundaries @@ -12,13 +12,14 @@ you deploy and store the vault. - Common private, import, database, dependency, and build directories are excluded from indexing. - Non-local API requests require `PM_APP_TOKEN`. -- The health endpoint reports availability, not paths or file contents. +- The unauthenticated health endpoint reports availability, not paths or file + contents. - The browser stores the app token locally and sends it only to the configured origin. ## What the app does not provide -- `private: true` is not encryption. +- `private: true` is a display filter, not encryption or access control. - The bearer token is not multi-user authentication. - The app does not encrypt the vault at rest. - The app cannot prevent a vault repository from being made public. @@ -30,7 +31,8 @@ you deploy and store the vault. 1. Keep the public application checkout separate from the vault. 2. Store the vault on an encrypted device or private persistent disk. 3. Use a long random `PM_APP_TOKEN`. -4. Put remote access behind TLS and, preferably, an identity-aware proxy. +4. Put remote access behind TLS and an access-controlled network boundary, + preferably an identity-aware proxy. 5. Use a separate private GitHub repository if synchronization is enabled. 6. Grant a GitHub token access only to that vault repository. 7. Never commit `.env`, private keys, exports, or browser data. diff --git a/docs/SELF_HOSTING.md b/docs/SELF_HOSTING.md index 7d5c0a9..850be47 100644 --- a/docs/SELF_HOSTING.md +++ b/docs/SELF_HOSTING.md @@ -1,72 +1,8 @@ -# Self-hosting +# Private self-hosting -## Local network +See [Docker and private hosting](DOCKER.md) for the supported v1.x deployment. +There is no public hosted demo. -Create a vault and token: - -```bash -mkdir -p vault -export PM_APP_TOKEN="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')" -docker compose up --build -``` - -The Compose file binds the app to port 8765 and mounts `./vault` at `/vault`. -Add TLS through a reverse proxy before exposing it beyond a trusted machine. - -## Render - -The included Blueprint provisions a small persistent disk and generates the app -token. After creating the service: - -1. Copy the generated `PM_APP_TOKEN` from Render into the browser unlock form. -2. Upload or synchronize a vault. -3. Confirm `/api/health` returns `ok: true`. -4. Confirm unauthenticated `/api/vault/entries` returns `401`. -5. Back up the persistent disk. - -The Render service is not a public demo. It contains the deployer’s private -vault and should be protected accordingly. - -## Optional private GitHub vault - -Create a separate private repository containing only vault files. Then set: - -```text -PM_GITHUB_SYNC_ENABLED=true -PM_GITHUB_REPO=account/private-vault -PM_GITHUB_BRANCH=main -PM_GITHUB_TOKEN= -``` - -The token should have Contents read/write access only to that repository. - -Synchronization is deliberately explicit: - -- Status and dry-run endpoints are safe inspection operations. -- Push copies allowed vault files into a temporary sparse checkout and commits - them. -- Restore can create a local backup before changing the vault. -- Deletion of extra files is opt-in. - -Do not configure synchronization to this public application repository. - -## Environment variables - -| Variable | Purpose | -| --- | --- | -| `PM_VAULT_ROOT` | Absolute vault directory | -| `PM_APP_TOKEN` | Bearer token for remote requests | -| `PM_REQUIRE_LOCAL_AUTH` | Require the token on localhost when true | -| `PM_CORS_ORIGINS` | Comma-separated additional browser origins | -| `PM_GITHUB_SYNC_ENABLED` | Enable private GitHub vault sync | -| `PM_GITHUB_REPO` | `owner/repository` for the private vault | -| `PM_GITHUB_BRANCH` | Vault branch, normally `main` | -| `PM_GITHUB_TOKEN` | Fine-grained vault token | - -## Upgrade procedure - -1. Back up the vault. -2. Pull a tagged application release. -3. Rebuild the image or reinstall dependencies. -4. Run tests and the privacy scan. -5. Start the app and verify entry counts before editing. +Remote instances require `PM_APP_TOKEN`, TLS, and an access-controlled network +boundary. Optional GitHub synchronization must target a separate private vault +repository; see [GitHub synchronization](GITHUB_SYNC.md). diff --git a/docs/UPGRADING.md b/docs/UPGRADING.md new file mode 100644 index 0000000..e6b6c12 --- /dev/null +++ b/docs/UPGRADING.md @@ -0,0 +1,15 @@ +# Upgrading + +1. Back up the vault. +2. Read `CHANGELOG.md` and fetch the desired tag. +3. Reinstall Python and Node dependencies. +4. Run `make doctor`, `make qa`, and `make test-e2e`. +5. Run the new version against a copy of the vault. +6. Validate entry counts, module empty states, and one reversible edit. +7. Rebuild the Docker image if applicable. + +Generated JSON and HTML may be deleted and rebuilt. Never overwrite the vault +with `example-vault`; `pm.py init` deliberately refuses nonempty targets. + +The first Research Workbench release in this existing repository is `v1.1.0`; +the earlier `v1.0.0` tag belongs to the original public Markdown Task Manager. diff --git a/docs/VAULT_FORMAT.md b/docs/VAULT_FORMAT.md index cb68eb1..11f4578 100644 --- a/docs/VAULT_FORMAT.md +++ b/docs/VAULT_FORMAT.md @@ -19,6 +19,8 @@ vault/ │ └── done/ ├── dates/ ├── notes/ +├── settings/ +│ └── workbench.yml ├── sources/ └── templates/ ``` @@ -35,10 +37,13 @@ private/import folders, databases, and generated build folders are excluded. | `title` | Display title; an H1 is the fallback | | `status` | Workflow state | | `project` | Owning project ID | +| `domain` | Primary top-level classifier; unmatched entries become `other` | +| `area` | Optional module or work-area label | +| `module` | Optional dedicated module, such as `travel` or `wellness` | | `priority` | Lower numbers sort first | | `due` / `date` | ISO date, `YYYY-MM-DD` | | `assignee` | Optional person or team label | -| `private` | Display hint; not encryption | +| `private` | Display filter; not encryption or access control | | `aliases` | Alternative stable link targets | Task states recognized by the quick editor are `open`, `active`, `waiting`, diff --git a/docs/images/overview.png b/docs/images/overview.png new file mode 100644 index 0000000..2157c86 Binary files /dev/null and b/docs/images/overview.png differ diff --git a/example-vault/START_HERE.md b/example-vault/START_HERE.md index 23593f4..57e67b6 100644 --- a/example-vault/START_HERE.md +++ b/example-vault/START_HERE.md @@ -1,12 +1,15 @@ --- kind: documentation title: Start here +domain: system --- # Start here -This fictional vault demonstrates the file layout without containing real -people, correspondence, appointments, travel, health information, or research. +This entirely fictional vault demonstrates every Research Workbench module. +Names, places, projects, metrics, and costs are invented. -- Open [[research-example]] to see a project. +- Open [[harbor-flows]] to see a project. - Open [[t-draft-method-note]] to see an active task. - Open [[t-review-data-checks]] to see a waiting task. +- Use the Travel, Collaborators, Wellness, Performance, and Scholar views to + inspect their metadata-driven examples. diff --git a/example-vault/dates/2026-12-22-example-milestone.md b/example-vault/dates/2026-12-22-example-milestone.md deleted file mode 100644 index ffefc84..0000000 --- a/example-vault/dates/2026-12-22-example-milestone.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -kind: event -id: example-milestone -title: Example project milestone -date: 2026-12-22 -project: research-example ---- -# Example project milestone - -Review the methods note and validation checklist. diff --git a/example-vault/dates/harbor-flows-milestone.md b/example-vault/dates/harbor-flows-milestone.md new file mode 100644 index 0000000..105a443 --- /dev/null +++ b/example-vault/dates/harbor-flows-milestone.md @@ -0,0 +1,11 @@ +--- +kind: workshop +id: harbor-flows-milestone +title: Harbor Flows methods workshop +date: "{{TODAY_PLUS_30}}" +project: harbor-flows +domain: research +--- +# Harbor Flows methods workshop + +Review the methods note and validation checklist. diff --git a/example-vault/dates/methods-summit.md b/example-vault/dates/methods-summit.md new file mode 100644 index 0000000..7861b77 --- /dev/null +++ b/example-vault/dates/methods-summit.md @@ -0,0 +1,15 @@ +--- +kind: conference +id: methods-summit +title: Methods Summit +date: "{{TODAY_PLUS_30}}" +end_date: "{{TODAY_PLUS_30}}" +domain: admin +area: travel +module: travel +trip_key: methods-summit +project: operations +--- +# Methods Summit + +Fictional one-day meeting in Northport. diff --git a/example-vault/notes/collaborators/avery-example-assignment.md b/example-vault/notes/collaborators/avery-example-assignment.md new file mode 100644 index 0000000..2b0d3bf --- /dev/null +++ b/example-vault/notes/collaborators/avery-example-assignment.md @@ -0,0 +1,15 @@ +--- +kind: collaborator-assignment +id: avery-example-assignment +title: Avery Example assignment +domain: research +area: collaborators +module: collaborators +project: harbor-flows +assignee: Avery Example +status: active +date: "{{TODAY}}" +--- +# Avery Example assignment + +Produce a small synthetic table and a reproducibility note. diff --git a/example-vault/notes/collaborators/avery-example-checkin.md b/example-vault/notes/collaborators/avery-example-checkin.md new file mode 100644 index 0000000..fdc4c84 --- /dev/null +++ b/example-vault/notes/collaborators/avery-example-checkin.md @@ -0,0 +1,15 @@ +--- +kind: collaborator-checkin +id: avery-example-checkin +title: Avery Example check-in +domain: research +area: collaborators +module: collaborators +project: harbor-flows +assignee: Avery Example +status: active +date: "{{TODAY}}" +--- +# Avery Example check-in + +The invented table structure is ready; values remain placeholders. diff --git a/example-vault/notes/collaborators/avery-example.md b/example-vault/notes/collaborators/avery-example.md new file mode 100644 index 0000000..491b2b2 --- /dev/null +++ b/example-vault/notes/collaborators/avery-example.md @@ -0,0 +1,14 @@ +--- +kind: collaborator-status +id: avery-example +title: Avery Example +domain: research +area: collaborators +module: collaborators +assignee: Avery Example +status: active +date: "{{TODAY}}" +--- +# Avery Example + +Fictional collaborator working on the Harbor Flows validation table. diff --git a/example-vault/notes/performance/methods-note-achievement.md b/example-vault/notes/performance/methods-note-achievement.md new file mode 100644 index 0000000..363c29e --- /dev/null +++ b/example-vault/notes/performance/methods-note-achievement.md @@ -0,0 +1,20 @@ +--- +kind: performance-achievement +id: methods-note-achievement +title: Harbor Flows methods note +domain: admin +area: performance +module: performance +performance_group: academic +category_id: research-output +status: ready +entry_date: "{{TODAY}}" +broad_category: Academic +narrow_category: Research output +copy_ready_description: Drafted a fictional methods note and validation checklist. +evidence: + - projects/harbor-flows/notes/design-note.md +--- +# Harbor Flows methods note + +This achievement and its evidence are synthetic. diff --git a/example-vault/notes/performance/research-category.md b/example-vault/notes/performance/research-category.md new file mode 100644 index 0000000..abaf2d9 --- /dev/null +++ b/example-vault/notes/performance/research-category.md @@ -0,0 +1,17 @@ +--- +kind: performance-category +id: research-output +title: Research output +domain: admin +area: performance +module: performance +performance_group: academic +status: supported +summary: Demonstrate a fictional evidence-backed category. +bullets: + - Complete a methods note. + - Document a reproducibility check. +--- +# Research output + +Fictional category for the starter vault. diff --git a/example-vault/notes/performance/review.md b/example-vault/notes/performance/review.md new file mode 100644 index 0000000..9f5570f --- /dev/null +++ b/example-vault/notes/performance/review.md @@ -0,0 +1,14 @@ +--- +kind: performance-review +id: example-performance-review +title: Example performance review +domain: admin +area: performance +module: performance +status: draft +date: "{{TODAY}}" +--- +# Example performance review + +This note assembles the fictional categories, achievements, and verification +items shown in the Performance Review module. diff --git a/example-vault/notes/performance/source-check.md b/example-vault/notes/performance/source-check.md new file mode 100644 index 0000000..0c8c8f8 --- /dev/null +++ b/example-vault/notes/performance/source-check.md @@ -0,0 +1,13 @@ +--- +kind: performance-verification +id: performance-source-check +title: Verify example evidence links +domain: admin +area: performance +module: performance +status: supported +detail: All evidence points to fictional files in the starter vault. +--- +# Verify example evidence links + +Supported by local synthetic notes only. diff --git a/example-vault/notes/travel/methods-summit-itinerary.md b/example-vault/notes/travel/methods-summit-itinerary.md new file mode 100644 index 0000000..f11fbcc --- /dev/null +++ b/example-vault/notes/travel/methods-summit-itinerary.md @@ -0,0 +1,17 @@ +--- +kind: travel-brief +id: methods-summit-itinerary +title: Methods Summit itinerary +domain: admin +area: travel +module: travel +trip_key: methods-summit +project: operations +status: draft +date: "{{TODAY}}" +--- +# Methods Summit itinerary + +- Morning: fictional rail trip to Northport. +- Afternoon: methods sessions. +- Evening: return or one-night stay. diff --git a/example-vault/notes/travel/travel-ledger.md b/example-vault/notes/travel/travel-ledger.md new file mode 100644 index 0000000..ebc9ebd --- /dev/null +++ b/example-vault/notes/travel/travel-ledger.md @@ -0,0 +1,35 @@ +--- +kind: travel-ledger +id: travel-ledger +title: Example travel ledger +domain: admin +area: travel +module: travel +research_account_limit_usd: 5000 +ledger: + - id: methods-summit-rail + trip_key: methods-summit + trip_title: Methods Summit + item: Fictional round-trip rail estimate + category: transport + estimate: 120 + currency: USD + status: approved + reimbursable: true + counts_against_research_account: true + source: synthetic example + - id: methods-summit-room + trip_key: methods-summit + trip_title: Methods Summit + item: Fictional one-night lodging estimate + category: lodging + estimate: 180 + currency: USD + status: approval_needed + reimbursable: true + counts_against_research_account: false + source: synthetic example +--- +# Example travel ledger + +All amounts and coverage rules are invented. diff --git a/example-vault/notes/wellness/weekly-plan.md b/example-vault/notes/wellness/weekly-plan.md new file mode 100644 index 0000000..7702de2 --- /dev/null +++ b/example-vault/notes/wellness/weekly-plan.md @@ -0,0 +1,33 @@ +--- +kind: weekly-health-review +id: weekly-wellness-plan +title: Example weekly wellness plan +domain: personal +area: wellness +module: wellness +wellness_category: fitness +date: "{{TODAY}}" +private: true +fitness_plan: + - week: 1 + offset: 0 + day: Monday + kind: walk + label: Morning walk + duration: 30 + - week: 1 + offset: 2 + day: Wednesday + kind: recovery + label: Mobility session + duration: 20 + - week: 1 + offset: 4 + day: Friday + kind: strength + label: Strength session + duration: 30 +--- +# Example weekly wellness plan + +Synthetic aggregate entries demonstrate the private display filter. diff --git a/example-vault/projects/harbor-flows/README.md b/example-vault/projects/harbor-flows/README.md new file mode 100644 index 0000000..4b7ade8 --- /dev/null +++ b/example-vault/projects/harbor-flows/README.md @@ -0,0 +1,28 @@ +--- +kind: project +id: harbor-flows +title: Harbor Flows +domain: research +status: active +priority: 2 +deadline: "{{TODAY_PLUS_30}}" +deadline_type: soft +lead: Owner +next_action: Draft the baseline methods note +--- +# Harbor Flows + +## Goal + +Compare two fictional shipping-network estimators and document the validation +sequence. + +## Current state + +The outline is complete. The methods note and reproducibility checklist remain +open. No underlying dataset or external organization is real. + +## Next + +- Complete [[t-draft-method-note]]. +- Review [[design-note]]. diff --git a/example-vault/projects/harbor-flows/notes/design-note.md b/example-vault/projects/harbor-flows/notes/design-note.md new file mode 100644 index 0000000..53a8a2f --- /dev/null +++ b/example-vault/projects/harbor-flows/notes/design-note.md @@ -0,0 +1,17 @@ +--- +kind: research-note +id: design-note +title: Baseline design note +project: harbor-flows +domain: research +status: draft +date: "{{TODAY}}" +--- +# Baseline design note + +The fictional study compares direct routing with a network-aware alternative. +The example contains no real model result. + +## Open question + +Which validation check should run before the final draft? diff --git a/example-vault/projects/research-example/README.md b/example-vault/projects/research-example/README.md deleted file mode 100644 index f9f1e73..0000000 --- a/example-vault/projects/research-example/README.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -kind: project -id: research-example -title: Example research project -status: active -priority: 2 -next_action: Draft the methods note ---- -# Example research project - -## Goal - -Demonstrate a Markdown-first project with a small, fictional work queue. - -## Current state - -The outline is complete. The methods note and reproducibility checklist remain -open. - -## Next - -- Complete [[t-draft-method-note]]. -- Review [[design-note]]. diff --git a/example-vault/projects/research-example/notes/design-note.md b/example-vault/projects/research-example/notes/design-note.md deleted file mode 100644 index 6b55b95..0000000 --- a/example-vault/projects/research-example/notes/design-note.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -kind: research-note -id: design-note -title: Example design note -project: research-example -status: draft -date: 2026-01-15 ---- -# Example design note - -The fictional study compares two transparent workflows. This note exists only -to demonstrate links, metadata, search, and editing. - -## Open question - -Which validation check should run before the final draft? diff --git a/example-vault/settings/scholar-stats.json b/example-vault/settings/scholar-stats.json new file mode 100644 index 0000000..63897fe --- /dev/null +++ b/example-vault/settings/scholar-stats.json @@ -0,0 +1,17 @@ +{ + "name": "Example Researcher", + "affiliation": "Fictional Research Studio", + "metrics": { + "citations": {"all": 128, "recent": 94}, + "h_index": {"all": 6, "recent": 5}, + "i10_index": {"all": 4, "recent": 3} + }, + "citations_by_year": [ + {"year": 2024, "citations": 22}, + {"year": 2025, "citations": 31}, + {"year": 2026, "citations": 41} + ], + "top_publications": [ + {"title": "A Fictional Study of Harbor Flows", "year": 2024, "citations": 37} + ] +} diff --git a/example-vault/settings/time_planning.md b/example-vault/settings/time_planning.md new file mode 100644 index 0000000..636bd5e --- /dev/null +++ b/example-vault/settings/time_planning.md @@ -0,0 +1,11 @@ +--- +kind: settings +title: Time planning +domain: system +workday_start: "09:00" +daily_capacity_minutes: 420 +weekdays: 5 +--- +# Time planning + +The scheduler reads these settings and task-level duration metadata. diff --git a/example-vault/settings/workbench.yml b/example-vault/settings/workbench.yml new file mode 100644 index 0000000..00bf71e --- /dev/null +++ b/example-vault/settings/workbench.yml @@ -0,0 +1,43 @@ +application: + name: Research Workbench + owner_label: Owner + owner_aliases: [Owner, me, self] + +modules: + overview: {enabled: true, label: Overview} + tasks: {enabled: true, label: Tasks} + projects: {enabled: true, label: Projects} + notes: {enabled: true, label: Library} + calendar: {enabled: true, label: Calendar} + boards: {enabled: true, label: Boards} + graph: {enabled: true, label: Graph} + time_planning: {enabled: true, label: Time Plan} + admin: {enabled: true, label: Admin Center} + travel: {enabled: true, label: Travel Center} + collaborators: {enabled: true, label: Collaborators} + wellness: {enabled: true, label: Wellness} + performance: {enabled: true, label: Performance Review} + scholar_metrics: {enabled: true, label: Scholar Metrics} + github_sync: {enabled: false, label: GitHub Sync} + markdown_editor: {enabled: true, label: Editor} + codex_backlog: {enabled: true, label: Agent Backlog} + +domains: + labels: + research: Research + admin: Administration + teaching: Teaching + personal: Personal + system: System + archive: Archive + other: Other + classification_rules: + - {domain: research, field: domain, values: [research]} + - {domain: admin, field: domain, values: [admin]} + - {domain: teaching, field: domain, values: [teaching]} + - {domain: personal, field: domain, values: [personal, wellness]} + - {domain: system, field: domain, values: [system]} + - {domain: archive, field: domain, values: [archive]} + +profile: + scholar_statistics_source: settings/scholar-stats.json diff --git a/example-vault/tasks/active/t-book-methods-summit.md b/example-vault/tasks/active/t-book-methods-summit.md new file mode 100644 index 0000000..2a75308 --- /dev/null +++ b/example-vault/tasks/active/t-book-methods-summit.md @@ -0,0 +1,19 @@ +--- +kind: travel-task +id: t-book-methods-summit +title: Confirm Methods Summit lodging +status: open +priority: 2 +domain: admin +area: travel +module: travel +trip_key: methods-summit +project: operations +due: "{{TODAY_PLUS_7}}" +deadline_type: hard +assignee: Owner +estimate_minutes: 25 +--- +# Confirm Methods Summit lodging + +Select one fictional option after approval. diff --git a/example-vault/tasks/active/t-draft-method-note.md b/example-vault/tasks/active/t-draft-method-note.md index a2501d2..b10e9f2 100644 --- a/example-vault/tasks/active/t-draft-method-note.md +++ b/example-vault/tasks/active/t-draft-method-note.md @@ -4,9 +4,11 @@ id: t-draft-method-note title: Draft the example methods note status: active priority: 1 -project: research-example -due: 2026-12-15 -assignee: Example User +project: harbor-flows +domain: research +due: "{{TODAY_PLUS_7}}" +deadline_type: soft +assignee: Owner estimate_minutes: 90 --- # Draft the example methods note diff --git a/example-vault/tasks/active/t-submit-example-form.md b/example-vault/tasks/active/t-submit-example-form.md new file mode 100644 index 0000000..d37d76d --- /dev/null +++ b/example-vault/tasks/active/t-submit-example-form.md @@ -0,0 +1,17 @@ +--- +kind: task +id: t-submit-example-form +title: Submit the fictional annual form +status: open +priority: 2 +domain: admin +area: admin +admin_category: forms-approvals +due: "{{TODAY_PLUS_14}}" +deadline_type: hard +assignee: Owner +estimate_minutes: 30 +--- +# Submit the fictional annual form + +Review the invented checklist and record completion. diff --git a/example-vault/tasks/active/t-update-performance-review.md b/example-vault/tasks/active/t-update-performance-review.md new file mode 100644 index 0000000..18393ac --- /dev/null +++ b/example-vault/tasks/active/t-update-performance-review.md @@ -0,0 +1,17 @@ +--- +kind: task +id: t-update-performance-review +title: Update the example performance review +status: active +priority: 3 +domain: admin +area: performance +module: performance +due: "{{TODAY_PLUS_30}}" +deadline_type: soft +assignee: Owner +estimate_minutes: 45 +--- +# Update the example performance review + +Verify that the sample evidence links remain fictional. diff --git a/example-vault/tasks/active/t-wellness-review.md b/example-vault/tasks/active/t-wellness-review.md new file mode 100644 index 0000000..e0a11bd --- /dev/null +++ b/example-vault/tasks/active/t-wellness-review.md @@ -0,0 +1,19 @@ +--- +kind: task +id: t-wellness-review +title: Complete the weekly wellness review +status: open +priority: 4 +domain: personal +area: wellness +module: wellness +wellness_category: baseline +due: "{{TODAY_PLUS_7}}" +deadline_type: soft +assignee: Owner +estimate_minutes: 20 +private: true +--- +# Complete the weekly wellness review + +Use only aggregate fictional values in this example. diff --git a/example-vault/tasks/waiting/t-avery-model-table.md b/example-vault/tasks/waiting/t-avery-model-table.md new file mode 100644 index 0000000..1344c5d --- /dev/null +++ b/example-vault/tasks/waiting/t-avery-model-table.md @@ -0,0 +1,18 @@ +--- +kind: task +id: t-avery-model-table +title: Review Avery Example's model table +status: waiting +priority: 2 +domain: research +area: collaborators +module: collaborators +project: harbor-flows +due: "{{TODAY_PLUS_14}}" +deadline_type: soft +assignee: Avery Example +estimate_minutes: 45 +--- +# Review Avery Example's model table + +Waiting for a fictional output path. diff --git a/example-vault/tasks/waiting/t-review-data-checks.md b/example-vault/tasks/waiting/t-review-data-checks.md index d890887..01a003c 100644 --- a/example-vault/tasks/waiting/t-review-data-checks.md +++ b/example-vault/tasks/waiting/t-review-data-checks.md @@ -4,9 +4,13 @@ id: t-review-data-checks title: Review the example data checks status: waiting priority: 2 -project: research-example -due: 2026-12-20 -assignee: Example Collaborator +project: harbor-flows +domain: research +area: collaborators +module: collaborators +due: "{{TODAY_PLUS_14}}" +deadline_type: soft +assignee: Avery Example --- # Review the example data checks diff --git a/pyproject.toml b/pyproject.toml index 8e66689..9771d5f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,21 +1,27 @@ [project] -name = "markdown-task-manager" -version = "1.0.0" -description = "A private-by-default Markdown project and task workbench" +name = "research-workbench" +version = "1.1.0" +description = "A configurable, Markdown-first research project workbench" readme = "README.md" license = "MIT" requires-python = ">=3.11" +authors = [ + {name = "Simon Fuchs"}, +] dependencies = [ - "PyYAML>=6.0,<7", - "fastapi>=0.115,<1", - "uvicorn[standard]>=0.32,<1", + "PyYAML>=6.0", + "beautifulsoup4>=4.12", + "fastapi>=0.115", + "icalendar>=5.0", + "recurring-ical-events>=2.3", + "uvicorn[standard]>=0.32", ] [dependency-groups] dev = [ "httpx>=0.27,<1", "httpx2>=2.0,<3", - "pytest>=9.0.3,<10", + "pytest>=8.3", ] [tool.pytest.ini_options] diff --git a/render.yaml b/render.yaml deleted file mode 100644 index 3d514fc..0000000 --- a/render.yaml +++ /dev/null @@ -1,21 +0,0 @@ -services: - - type: web - name: markdown-task-manager - runtime: docker - plan: starter - healthCheckPath: /api/health - envVars: - - key: PM_APP_TOKEN - generateValue: true - - key: PM_VAULT_ROOT - value: /vault - - key: PM_GITHUB_SYNC_ENABLED - value: "false" - - key: PM_GITHUB_REPO - sync: false - - key: PM_GITHUB_TOKEN - sync: false - disk: - name: markdown-vault - mountPath: /vault - sizeGB: 1 diff --git a/requirements.txt b/requirements.txt index 6193bf1..c72b118 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,9 @@ -PyYAML>=6.0,<7 -fastapi>=0.115,<1 -uvicorn[standard]>=0.32,<1 +PyYAML>=6.0 +beautifulsoup4>=4.12 +fastapi>=0.115 +icalendar>=5.0 +recurring-ical-events>=2.3 +uvicorn[standard]>=0.32 +pytest>=8.3 +httpx>=0.27 +httpx2>=2.0 diff --git a/scripts/bootstrap_demo.py b/scripts/bootstrap_demo.py index 5dcc63b..d7eaf20 100644 --- a/scripts/bootstrap_demo.py +++ b/scripts/bootstrap_demo.py @@ -4,13 +4,14 @@ import argparse import shutil +from datetime import date, timedelta from pathlib import Path ROOT = Path(__file__).resolve().parents[1] -def bootstrap(source: Path, target: Path) -> int: +def bootstrap(source: Path, target: Path, today: date | None = None) -> int: source = source.resolve() target = target.resolve() if not source.is_dir(): @@ -27,6 +28,20 @@ def bootstrap(source: Path, target: Path) -> int: else: destination.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(path, destination) + today_value = today or date.today() + replacements = { + "{{TODAY}}": today_value.isoformat(), + "{{TODAY_PLUS_7}}": (today_value + timedelta(days=7)).isoformat(), + "{{TODAY_PLUS_14}}": (today_value + timedelta(days=14)).isoformat(), + "{{TODAY_PLUS_30}}": (today_value + timedelta(days=30)).isoformat(), + } + for path in target.rglob("*"): + if not path.is_file() or path.suffix.lower() not in {".md", ".json", ".yml", ".yaml"}: + continue + text = path.read_text(encoding="utf-8") + for marker, value in replacements.items(): + text = text.replace(marker, value) + path.write_text(text, encoding="utf-8") return sum(1 for path in target.rglob("*") if path.is_file()) @@ -34,8 +49,9 @@ def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--source", type=Path, default=ROOT / "example-vault") parser.add_argument("--target", type=Path, default=ROOT / "vault") + parser.add_argument("--today", type=date.fromisoformat, help=argparse.SUPPRESS) args = parser.parse_args() - count = bootstrap(args.source, args.target) + count = bootstrap(args.source, args.target, args.today) print(f"Created {args.target.resolve()} with {count} example files.") return 0 diff --git a/scripts/calendar_feed.py b/scripts/calendar_feed.py new file mode 100644 index 0000000..d25a8b7 --- /dev/null +++ b/scripts/calendar_feed.py @@ -0,0 +1,563 @@ +from __future__ import annotations + +import json +import hashlib +import os +import re +import time +from dataclasses import dataclass +from datetime import date, datetime, time as DateTime, timedelta +from pathlib import Path +from typing import Any +from urllib.request import urlopen +from zoneinfo import ZoneInfo + +import yaml + +try: + import recurring_ical_events + from icalendar import Calendar +except ImportError as exc: + recurring_ical_events = None + Calendar = None + CALENDAR_DEPENDENCY_ERROR = exc +else: + CALENDAR_DEPENDENCY_ERROR = None + + +DEFAULT_WORK_KEYWORDS = [ + "board", + "conference", + "meeting", + "office hours", + "presentation", + "research", + "seminar", + "talk", + "workshop", + "zoom", +] + +DEFAULT_EXCLUDE_KEYWORDS = [ + "birthday", + "dinner", + "doctor", + "dentist", + "medical", + "personal", + "reservation", + "sushi", +] + +CACHE_TTL_SECONDS = 600 +LOCAL_CACHE_PATH = Path("local_data/calendar/timeplan_events.json") +_CACHE: dict[str, tuple[float, dict[str, Any]]] = {} + + +@dataclass(frozen=True) +class CalendarSource: + label: str + url: str + scope: str = "mixed" + + +def _split_frontmatter(text: str) -> tuple[dict[str, Any], str]: + if not (text.startswith("---\n") or text.startswith("---\r\n")): + return {}, text + lines = text.splitlines(keepends=True) + for index in range(1, len(lines)): + if lines[index].strip() == "---": + raw = "".join(lines[1:index]) + try: + parsed = yaml.safe_load(raw) or {} + except Exception: + parsed = {} + return (parsed if isinstance(parsed, dict) else {}), "".join(lines[index + 1 :]) + return {}, text + + +def read_calendar_import_settings(root: Path) -> dict[str, Any]: + path = root / "settings" / "calendar_import.md" + if not path.exists(): + return { + "timezone": "America/New_York", + "work_keywords": DEFAULT_WORK_KEYWORDS, + "exclude_keywords": DEFAULT_EXCLUDE_KEYWORDS, + } + frontmatter, _body = _split_frontmatter(path.read_text(encoding="utf-8", errors="replace")) + return { + "timezone": str(frontmatter.get("timezone") or "America/New_York"), + "work_keywords": _string_list(frontmatter.get("work_keywords")) or DEFAULT_WORK_KEYWORDS, + "exclude_keywords": _string_list(frontmatter.get("exclude_keywords")) or DEFAULT_EXCLUDE_KEYWORDS, + } + + +def _string_list(value: Any) -> list[str]: + if isinstance(value, list): + return [str(item).strip() for item in value if str(item).strip()] + if isinstance(value, str): + return [item.strip() for item in value.split(",") if item.strip()] + return [] + + +def _calendar_sources_from_env() -> tuple[list[CalendarSource], str]: + raw = os.environ.get("TIMEPLAN_CALENDAR_ICS_URLS", "").strip() + if not raw: + return [], "" + try: + parsed = json.loads(raw) + except json.JSONDecodeError: + if raw.startswith(("http://", "https://", "file://")): + return [CalendarSource(label="Calendar", url=raw, scope="mixed")], "" + return [], "Invalid TIMEPLAN_CALENDAR_ICS_URLS; expected JSON array of calendar sources." + if isinstance(parsed, dict): + parsed = [parsed] + if isinstance(parsed, str): + parsed = [{"label": "Calendar", "url": parsed, "scope": "mixed"}] + if not isinstance(parsed, list): + return [], "Invalid TIMEPLAN_CALENDAR_ICS_URLS; expected a list." + sources: list[CalendarSource] = [] + for index, item in enumerate(parsed, start=1): + if not isinstance(item, dict): + continue + url = str(item.get("url") or "").strip() + if not url: + continue + label = _sanitize_label(item.get("label") or f"Calendar {index}") + scope = str(item.get("scope") or "mixed").strip().lower() + if scope not in {"work", "mixed"}: + scope = "mixed" + sources.append(CalendarSource(label=label, url=url, scope=scope)) + return sources, "" if sources else "No usable calendar sources found in TIMEPLAN_CALENDAR_ICS_URLS." + + +def _read_local_cache(root: Path) -> tuple[dict[str, Any] | None, str]: + path = root / LOCAL_CACHE_PATH + if not path.exists(): + return None, "" + try: + parsed = json.loads(path.read_text(encoding="utf-8")) + except Exception: + return None, "Local calendar cache is not valid JSON." + if not isinstance(parsed, dict): + return None, "Local calendar cache must be a JSON object." + return parsed, "" + + +def _sanitize_label(value: Any) -> str: + label = re.sub(r"\s+", " ", str(value or "Calendar")).strip() + return label[:80] or "Calendar" + + +def _sanitize_title(value: Any) -> str: + title = re.sub(r"https?://\S+", "", str(value or "")).strip() + title = re.sub(r"\s+", " ", title) + return title[:160] or "Work meeting" + + +def _parse_cache_date(value: Any) -> date | None: + if not value: + return None + try: + return date.fromisoformat(str(value)[:10]) + except ValueError: + return None + + +def _valid_clock(value: Any) -> str | None: + text = str(value or "").strip() + if not re.fullmatch(r"\d{2}:\d{2}", text): + return None + hour, minute = [int(part) for part in text.split(":", 1)] + if 0 <= hour <= 23 and 0 <= minute <= 59: + return text + return None + + +def _cache_source_label(payload: dict[str, Any], entry: dict[str, Any]) -> str: + return _sanitize_label(entry.get("source_label") or payload.get("source_label") or "Google Calendar snapshot") + + +def _cache_event_id(entry: dict[str, Any], event_day: date, index: int, source_label: str) -> str: + key = "|".join( + [ + source_label, + _sanitize_title(entry.get("title")), + event_day.isoformat(), + str(entry.get("start") or ""), + str(entry.get("end") or ""), + str(index), + ] + ) + return hashlib.sha256(key.encode("utf-8")).hexdigest()[:16] + + +def _cache_entry_dates(entry: dict[str, Any], start_day: date, end_day: date) -> list[date]: + if isinstance(entry.get("dates"), list): + raw_days = [_parse_cache_date(item) for item in entry["dates"]] + return sorted(day for day in raw_days if day is not None and start_day <= day < end_day) + first_day = _parse_cache_date(entry.get("date") or entry.get("start_date")) + if first_day is None: + return [] + last_day = _parse_cache_date(entry.get("end_date")) or first_day + if last_day < first_day: + last_day = first_day + days: list[date] = [] + current = max(first_day, start_day) + final = min(last_day, end_day - timedelta(days=1)) + while current <= final: + days.append(current) + current += timedelta(days=1) + return days + + +def _cache_entry_is_work_like(entry: dict[str, Any], title: str, settings: dict[str, Any]) -> bool: + if bool(entry.get("work")) or bool(entry.get("include")): + return True + scope = str(entry.get("scope") or "").lower() + if scope == "work": + return True + return _matches_any(title, list(settings["work_keywords"])) + + +def _load_local_cache_events_by_day(root: Path, start_day: date, end_day: date, settings: dict[str, Any]) -> dict[str, Any] | None: + payload, cache_error = _read_local_cache(root) + if payload is None: + if cache_error: + return { + "status": { + "enabled": False, + "last_fetch": "", + "event_count": 0, + "source_labels": [], + "errors": [cache_error], + }, + "events_by_day": {}, + } + return None + raw_events = payload.get("events") + if not isinstance(raw_events, list): + return { + "status": { + "enabled": False, + "last_fetch": str(payload.get("generated_at") or ""), + "event_count": 0, + "source_labels": [], + "errors": ["Local calendar cache must contain an events list."], + }, + "events_by_day": {}, + } + + events_by_day: dict[date, list[dict[str, Any]]] = {} + labels: set[str] = set() + errors: list[str] = [] + for index, raw_entry in enumerate(raw_events): + if not isinstance(raw_entry, dict): + continue + title = _sanitize_title(raw_entry.get("title")) + if _matches_any(title, list(settings["exclude_keywords"])): + continue + if not _cache_entry_is_work_like(raw_entry, title, settings): + continue + all_day = bool(raw_entry.get("all_day")) + start_clock = _valid_clock(raw_entry.get("start")) + end_clock = _valid_clock(raw_entry.get("end")) + if not all_day and (start_clock is None or end_clock is None or end_clock <= start_clock): + errors.append(f"Skipped cached event with invalid time: {title}") + continue + source_label = _cache_source_label(payload, raw_entry) + labels.add(source_label) + for event_day in _cache_entry_dates(raw_entry, start_day, end_day): + event_id = _cache_event_id(raw_entry, event_day, index, source_label) + event: dict[str, Any] = { + "id": f"local-calendar-{event_id}", + "path": f"calendar:{source_label}:local-calendar-{event_id}", + "title": title, + "date": event_day.isoformat(), + "source_label": source_label, + "all_day": all_day, + "reason": str(raw_entry.get("reason") or ("all-day work event" if all_day else "cached work meeting")), + "private": bool(raw_entry.get("private")), + } + if not all_day: + event.update({"start": start_clock, "end": end_clock}) + events_by_day.setdefault(event_day, []).append(event) + + for day_events in events_by_day.values(): + day_events.sort(key=lambda event: (event.get("start") or "00:00", event.get("title") or "")) + return { + "status": { + "enabled": True, + "last_fetch": str(payload.get("generated_at") or ""), + "event_count": sum(len(events) for events in events_by_day.values()), + "source_labels": sorted(labels) or [_sanitize_label(payload.get("source_label") or "Google Calendar snapshot")], + "errors": errors, + }, + "events_by_day": events_by_day, + } + + +def _safe_error(label: str, exc: Exception) -> str: + return f"{label}: {type(exc).__name__}" + + +def _cache_key(sources: list[CalendarSource], start_day: date, end_day: date, settings: dict[str, Any]) -> str: + source_key = [(source.label, source.url, source.scope) for source in sources] + return json.dumps( + { + "sources": source_key, + "start": start_day.isoformat(), + "end": end_day.isoformat(), + "timezone": settings["timezone"], + "work": settings["work_keywords"], + "exclude": settings["exclude_keywords"], + "user_emails": os.environ.get("TIMEPLAN_CALENDAR_USER_EMAILS", ""), + }, + sort_keys=True, + ) + + +def _read_ics(source: CalendarSource) -> bytes: + with urlopen(source.url, timeout=12) as response: + return response.read() + + +def _decoded(component: Any, name: str) -> Any: + try: + return component.decoded(name) + except Exception: + return None + + +def _as_local_datetime(value: Any, tz: ZoneInfo) -> tuple[datetime | None, bool]: + if isinstance(value, datetime): + if value.tzinfo is None: + value = value.replace(tzinfo=tz) + return value.astimezone(tz), False + if isinstance(value, date): + return datetime.combine(value, DateTime.min, tzinfo=tz), True + return None, False + + +def _normalized(value: str) -> str: + return re.sub(r"\s+", " ", value.lower()).strip() + + +def _matches_any(text: str, keywords: list[str]) -> bool: + normalized = _normalized(text) + return any(keyword.lower() in normalized for keyword in keywords if keyword) + + +def _attendee_values(component: Any) -> list[Any]: + attendees = component.get("attendee") + if attendees is None: + return [] + return attendees if isinstance(attendees, list) else [attendees] + + +def _declined_by_user(component: Any) -> bool: + user_emails = {item.strip().lower() for item in os.environ.get("TIMEPLAN_CALENDAR_USER_EMAILS", "").split(",") if item.strip()} + if not user_emails: + return False + for attendee in _attendee_values(component): + value = str(attendee).lower().replace("mailto:", "") + partstat = str(getattr(attendee, "params", {}).get("PARTSTAT", "")).upper() + if value in user_emails and partstat == "DECLINED": + return True + return False + + +def _is_included_event( + component: Any, + *, + title: str, + all_day: bool, + source: CalendarSource, + work_keywords: list[str], + exclude_keywords: list[str], +) -> bool: + status = str(component.get("status") or "").upper() + if status == "CANCELLED": + return False + if _declined_by_user(component): + return False + if _matches_any(title, exclude_keywords): + return False + transparency = str(component.get("transp") or "OPAQUE").upper() + work_like = source.scope == "work" or _matches_any(title, work_keywords) + if not work_like: + return False + if transparency == "TRANSPARENT" and not all_day: + return False + return True + + +def _source_event_id(component: Any, day: date, source: CalendarSource) -> str: + uid = str(component.get("uid") or "event") + recurrence_id = component.get("recurrence-id") + suffix = str(recurrence_id) if recurrence_id else day.isoformat() + return f"{source.label}:{uid}:{suffix}" + + +def _event_reason(all_day: bool) -> str: + return "all-day work event" if all_day else "work meeting" + + +def _component_to_day_events( + component: Any, + *, + source: CalendarSource, + start_day: date, + end_day: date, + tz: ZoneInfo, + work_keywords: list[str], + exclude_keywords: list[str], +) -> list[dict[str, Any]]: + start_raw = _decoded(component, "dtstart") + end_raw = _decoded(component, "dtend") + start_dt, start_all_day = _as_local_datetime(start_raw, tz) + if start_dt is None: + return [] + end_dt, end_all_day = _as_local_datetime(end_raw, tz) + all_day = start_all_day or end_all_day + if end_dt is None: + end_dt = start_dt + (timedelta(days=1) if all_day else timedelta(hours=1)) + if end_dt <= start_dt: + end_dt = start_dt + (timedelta(days=1) if all_day else timedelta(hours=1)) + title = _sanitize_title(component.get("summary")) + if not _is_included_event( + component, + title=title, + all_day=all_day, + source=source, + work_keywords=work_keywords, + exclude_keywords=exclude_keywords, + ): + return [] + + events: list[dict[str, Any]] = [] + current = max(start_day, start_dt.date()) + while current < end_day: + day_start = datetime.combine(current, DateTime.min, tzinfo=tz) + day_end = day_start + timedelta(days=1) + if end_dt <= day_start: + break + if start_dt < day_end and end_dt > day_start: + segment_start = max(start_dt, day_start) + segment_end = min(end_dt, day_end) + event: dict[str, Any] = { + "id": _source_event_id(component, current, source), + "path": f"calendar:{source.label}:{_source_event_id(component, current, source)}", + "title": title, + "date": current.isoformat(), + "source_label": source.label, + "source_event_id": str(component.get("uid") or ""), + "all_day": all_day, + "reason": _event_reason(all_day), + "private": False, + } + if not all_day: + event.update( + { + "start": segment_start.strftime("%H:%M"), + "end": segment_end.strftime("%H:%M"), + } + ) + events.append(event) + current += timedelta(days=1) + return events + + +def _load_source_events( + source: CalendarSource, + *, + start_day: date, + end_day: date, + tz: ZoneInfo, + work_keywords: list[str], + exclude_keywords: list[str], +) -> list[dict[str, Any]]: + calendar = Calendar.from_ical(_read_ics(source)) + start_dt = datetime.combine(start_day, DateTime.min, tzinfo=tz) + end_dt = datetime.combine(end_day, DateTime.min, tzinfo=tz) + components = recurring_ical_events.of(calendar).between(start_dt, end_dt) + events: list[dict[str, Any]] = [] + for component in components: + events.extend( + _component_to_day_events( + component, + source=source, + start_day=start_day, + end_day=end_day, + tz=tz, + work_keywords=work_keywords, + exclude_keywords=exclude_keywords, + ) + ) + return events + + +def load_calendar_events_by_day(root: Path, start_day: date, end_day: date) -> dict[str, Any]: + settings = read_calendar_import_settings(root) + sources, config_error = _calendar_sources_from_env() + if not sources and not config_error: + local_result = _load_local_cache_events_by_day(root, start_day, end_day, settings) + if local_result is not None: + return local_result + status: dict[str, Any] = { + "enabled": bool(sources), + "last_fetch": "", + "event_count": 0, + "source_labels": [source.label for source in sources], + "errors": [config_error] if config_error else [], + } + if not sources: + return {"status": status, "events_by_day": {}} + if CALENDAR_DEPENDENCY_ERROR is not None: + status.update({"errors": ["Calendar feed dependencies are not installed."]}) + return {"status": status, "events_by_day": {}} + + key = _cache_key(sources, start_day, end_day, settings) + cached = _CACHE.get(key) + now = time.time() + if cached and now - cached[0] < CACHE_TTL_SECONDS: + return cached[1] + + try: + tz = ZoneInfo(str(settings["timezone"])) + except Exception: + status.update({"errors": ["Invalid calendar timezone setting."]}) + result = {"status": status, "events_by_day": {}} + _CACHE[key] = (now, result) + return result + events_by_day: dict[date, list[dict[str, Any]]] = {} + errors: list[str] = [] + for source in sources: + try: + source_events = _load_source_events( + source, + start_day=start_day, + end_day=end_day, + tz=tz, + work_keywords=list(settings["work_keywords"]), + exclude_keywords=list(settings["exclude_keywords"]), + ) + except Exception as exc: + errors.append(_safe_error(source.label, exc)) + continue + for event in source_events: + event_day = date.fromisoformat(str(event["date"])) + events_by_day.setdefault(event_day, []).append(event) + + for day_events in events_by_day.values(): + day_events.sort(key=lambda event: (event.get("start") or "00:00", event.get("title") or "")) + status.update( + { + "last_fetch": datetime.now(tz).isoformat(timespec="seconds"), + "event_count": sum(len(events) for events in events_by_day.values()), + "errors": errors, + } + ) + result = {"status": status, "events_by_day": events_by_day} + _CACHE[key] = (now, result) + return result diff --git a/scripts/check_links.py b/scripts/check_links.py new file mode 100644 index 0000000..fd68e30 --- /dev/null +++ b/scripts/check_links.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Check local Markdown links without making network requests.""" +from __future__ import annotations + +import argparse +import re +from dataclasses import dataclass +from pathlib import Path +from urllib.parse import unquote, urlsplit + + +ROOT = Path(__file__).resolve().parents[1] +MARKDOWN_LINK = re.compile(r"!?\[[^\]]*]\(([^)]+)\)") +SKIP_PARTS = { + ".git", + ".venv", + "node_modules", + "dist", + "vault", + ".generated", + "test-results", + "playwright-report", +} + + +@dataclass(frozen=True) +class BrokenLink: + source: str + line: int + target: str + + +def local_target(raw: str) -> str | None: + value = raw.strip().split(maxsplit=1)[0].strip("<>") + parsed = urlsplit(value) + if parsed.scheme or parsed.netloc or value.startswith(("#", "mailto:", "tel:")): + return None + return unquote(parsed.path) + + +def markdown_files(root: Path) -> list[Path]: + return sorted( + path + for path in root.rglob("*.md") + if path.is_file() and not any(part in SKIP_PARTS for part in path.relative_to(root).parts) + ) + + +def check(root: Path = ROOT) -> list[BrokenLink]: + root = root.resolve() + broken: list[BrokenLink] = [] + for source in markdown_files(root): + for line_number, line in enumerate(source.read_text(encoding="utf-8", errors="replace").splitlines(), 1): + for match in MARKDOWN_LINK.finditer(line): + target = local_target(match.group(1)) + if not target: + continue + destination = (source.parent / target).resolve() + try: + destination.relative_to(root) + except ValueError: + broken.append(BrokenLink(source.relative_to(root).as_posix(), line_number, target)) + continue + if not destination.exists(): + broken.append(BrokenLink(source.relative_to(root).as_posix(), line_number, target)) + return broken + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=ROOT) + args = parser.parse_args() + broken = check(args.root) + if broken: + print("Local link check failed:") + for item in broken: + print(f"- {item.source}:{item.line} -> {item.target}") + return 1 + print("Local Markdown links passed.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/docker_smoke.py b/scripts/docker_smoke.py new file mode 100644 index 0000000..41440f4 --- /dev/null +++ b/scripts/docker_smoke.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""Smoke-test the container's auth, writable vault, and restart behavior.""" +from __future__ import annotations + +import argparse +import json +import subprocess +import time +import urllib.error +import urllib.request +import uuid +from dataclasses import dataclass + + +TOKEN = "synthetic-smoke-token" +TASK_PATH = "tasks/active/t-draft-method-note.md" + + +@dataclass(frozen=True) +class Response: + status: int + body: str + + +def docker(*args: str, capture: bool = False) -> str: + result = subprocess.run( + ["docker", *args], + check=True, + text=True, + capture_output=capture, + ) + return result.stdout.strip() if capture else "" + + +def request(url: str, path: str, *, token: str = "", method: str = "GET", payload: dict | None = None) -> Response: + headers = {"Accept": "application/json"} + if token: + headers["Authorization"] = f"Bearer {token}" + data = None + if payload is not None: + headers["Content-Type"] = "application/json" + data = json.dumps(payload).encode("utf-8") + req = urllib.request.Request(f"{url}{path}", data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=3) as response: + return Response(response.status, response.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + return Response(exc.code, exc.read().decode("utf-8")) + + +def wait_for_health(url: str) -> None: + deadline = time.monotonic() + 30 + while time.monotonic() < deadline: + try: + if request(url, "/api/health").status == 200: + return + except (OSError, urllib.error.URLError): + pass + time.sleep(0.25) + raise RuntimeError("container did not become healthy within 30 seconds") + + +def start_container(image: str, name: str, volume: str) -> str: + docker( + "run", + "--detach", + "--name", + name, + "--read-only", + "--tmpfs", + "/tmp", + "--security-opt", + "no-new-privileges:true", + "--publish", + "127.0.0.1::8765", + "--env", + f"PM_APP_TOKEN={TOKEN}", + "--env", + "PM_REQUIRE_LOCAL_AUTH=true", + "--env", + "PM_GITHUB_SYNC_ENABLED=false", + "--volume", + f"{volume}:/vault", + image, + ) + mapping = docker("port", name, "8765/tcp", capture=True) + host, port = mapping.rsplit(":", 1) + if host != "127.0.0.1": + raise AssertionError(f"container published on unexpected host {host!r}") + return f"http://127.0.0.1:{port}" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--image", default="research-workbench:test") + args = parser.parse_args() + suffix = uuid.uuid4().hex[:10] + volume = f"research-workbench-smoke-{suffix}" + first = f"research-workbench-smoke-a-{suffix}" + second = f"research-workbench-smoke-b-{suffix}" + active: set[str] = set() + try: + image_user = docker("image", "inspect", args.image, "--format", "{{.Config.User}}", capture=True) + if image_user not in {"app", "10001", "10001:10001"}: + raise AssertionError(f"runtime image must declare a non-root user, found {image_user!r}") + + docker("volume", "create", volume) + docker( + "run", + "--rm", + "--user", + "0", + "--volume", + f"{volume}:/vault", + args.image, + "sh", + "-c", + "cp -R /app/example-vault/. /vault/ && chown -R 10001:10001 /vault", + ) + + url = start_container(args.image, first, volume) + active.add(first) + wait_for_health(url) + if request(url, "/api/vault/entries").status != 401: + raise AssertionError("protected API accepted an unauthenticated request") + entries = request(url, "/api/vault/entries", token=TOKEN) + if entries.status != 200 or "Harbor Flows" not in entries.body: + raise AssertionError("authenticated synthetic-vault request failed") + sync_status = request(url, "/api/github-sync/status", token=TOKEN) + if sync_status.status != 200 or json.loads(sync_status.body).get("enabled") is not False: + raise AssertionError("GitHub synchronization must be disabled by default") + patch = request( + url, + "/api/vault/task-metadata", + token=TOKEN, + method="PATCH", + payload={"path": TASK_PATH, "updates": {"priority": "2"}}, + ) + if patch.status != 200: + raise AssertionError(f"runtime could not write the mounted vault: {patch.status} {patch.body}") + + docker("rm", "--force", first) + active.remove(first) + url = start_container(args.image, second, volume) + active.add(second) + wait_for_health(url) + saved = request(url, f"/api/vault/file?path={TASK_PATH}", token=TOKEN) + if saved.status != 200 or "priority: '2'" not in saved.body and "priority: 2" not in saved.body: + raise AssertionError("vault edit did not persist across a container restart") + + print("Docker smoke test passed.") + return 0 + finally: + for name in active: + subprocess.run(["docker", "rm", "--force", name], check=False, capture_output=True) + subprocess.run(["docker", "volume", "rm", "--force", volume], check=False, capture_output=True) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/generate_weekly_review.py b/scripts/generate_weekly_review.py new file mode 100755 index 0000000..9a4d95f --- /dev/null +++ b/scripts/generate_weekly_review.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +from __future__ import annotations +import json +from pathlib import Path +from datetime import date, datetime, timedelta +from render_date import render_today + +try: + from scripts.workbench_paths import APP_ROOT, DATA_ROOT, VAULT_ROOT +except ImportError: # pragma: no cover + from workbench_paths import APP_ROOT, DATA_ROOT, VAULT_ROOT + +ROOT = VAULT_ROOT +DATA = DATA_ROOT +CLOSED_TASK_STATUSES = {"done", "complete", "completed", "cancelled", "canceled", "dropped", "archive", "archived"} + + +def load(name: str): + p = DATA / name + return json.loads(p.read_text(encoding="utf-8")) if p.exists() else [] + + +def parse_date(x): + try: + return date.fromisoformat(str(x)) + except Exception: + return None + + +def days_until(x): + d = parse_date(x) + return None if d is None else (d - render_today()).days + + +def rel(p: Path) -> str: + return str(p.relative_to(ROOT)) + + +def md_link(label: str, path: str) -> str: + if not path: + return label + # Weekly file lives in journal/weekly, so links go two levels up. + return f"[{label}](../../{path})" + + +def week_id(today: date | None = None) -> str: + today = today or render_today() + y, w, _ = today.isocalendar() + return f"{y}-W{w:02d}" + + +def build_review(week: str | None = None, force: bool = False) -> Path: + if not (DATA / "tasks.json").exists(): + import subprocess, sys + subprocess.check_call([sys.executable, str(APP_ROOT / "scripts/sync_markdown.py")]) + week = week or week_id() + out_dir = ROOT / "journal/weekly" + out_dir.mkdir(parents=True, exist_ok=True) + out = out_dir / f"{week}.md" + if out.exists() and not force: + return out + + tasks = [t for t in load("tasks.json") if str(t.get("status", "")).lower() not in CLOSED_TASK_STATUSES] + projects = load("projects.json") + events = load("events.json") + notes = load("notes.json") + + task_by_project = {} + for t in tasks: + task_by_project.setdefault(t.get("project", ""), []).append(t) + note_counts = {} + for n in notes: + note_counts[n.get("project", "")] = note_counts.get(n.get("project", ""), 0) + 1 + + due_3 = sorted([t for t in tasks if (days_until(t.get("due")) is not None and days_until(t.get("due")) <= 3)], key=lambda t: (days_until(t.get("due")), t.get("priority", 9))) + due_14 = sorted([t for t in tasks if (days_until(t.get("due")) is not None and days_until(t.get("due")) <= 14)], key=lambda t: (days_until(t.get("due")), t.get("priority", 9))) + event_14 = sorted([e for e in events if (days_until(e.get("date")) is not None and days_until(e.get("date")) <= 14)], key=lambda e: days_until(e.get("date"))) + waiting = [p for p in projects if any(k in str(p.get("status", "")).lower() for k in ["waiting", "needs", "blocked", "source", "audit"])] + active_research = [ + p + for p in projects + if "research" in { + str(p.get("domain", "")).lower(), + str(p.get("area", "")).lower(), + } + and str(p.get("status", "")).lower().startswith("active") + ] + active_research = sorted(active_research, key=lambda p: (p.get("priority", 9), p.get("deadline") or "9999-12-31"))[:6] + admin = [ + t + for t in tasks + if str(t.get("domain", "")).lower() in {"admin", "personal"} + or str(t.get("area", "")).lower() in {"admin", "travel", "wellness"} + ] + admin = sorted(admin, key=lambda t: (t.get("priority", 9), t.get("due") or "9999-12-31"))[:10] + + flags = [] + for p in projects: + fs = [] + pid = p.get("id", "") + if not p.get("next_action"): + fs.append("missing next action") + if not p.get("deadline") and p.get("priority", 9) <= 2: + fs.append("priority project without deadline") + if note_counts.get(pid, 0) == 0 and str(p.get("domain", "")).lower() == "research": + fs.append("no notes yet") + d = days_until(p.get("deadline")) + if d is not None and d <= 14: + fs.append(f"deadline in {d} days") + if any(k in str(p.get("status", "")).lower() for k in ["needs", "waiting", "audit"]): + fs.append(f"status: {p.get('status')}") + if fs: + flags.append((p, fs)) + + def task_line(t): + return f"- [ ] **{t.get('due') or 'no date'}** — {md_link(t.get('title','task'), t.get('path',''))} (`{t.get('project','')}`): {t.get('next','')}" + + def project_line(p): + return f"- **{md_link(p.get('name','project'), p.get('path',''))}** — {p.get('next_action','')}" + + lines = [ + "---", + "kind: weekly-review", + f"week: {week}", + "status: draft", + f"generated: {datetime.now().isoformat(timespec='seconds')}", + "---", + f"# Weekly review — {week}", + "", + "> Markdown is canonical. Use this page as the review surface, then update project/task Markdown files.", + "", + "## 1. Executive summary", + "", + f"- Open tasks: **{len(tasks)}**", + f"- Tasks due in ≤3 days: **{len(due_3)}**", + f"- Tasks due in ≤14 days: **{len(due_14)}**", + f"- Active research projects surfaced: **{len(active_research)}**", + f"- Waiting/source-gap projects: **{len(waiting)}**", + "", + "## 2. Must not slip", + "", + ] + lines += [task_line(t) for t in due_3[:12]] or ["- None detected."] + lines += ["", "## 3. This-week deadlines and events", ""] + lines += [task_line(t) for t in due_14[:20]] or ["- No dated tasks within 14 days."] + lines += ["", "### Calendar/date markers", ""] + lines += [f"- **{e.get('date')}** — {md_link(e.get('title','event'), e.get('path',''))} (`{e.get('project','')}`)" for e in event_14[:20]] or ["- No dated events within 14 days."] + lines += ["", "## 4. Recommended deep-work blocks", ""] + lines += [project_line(p) for p in active_research] or ["- No active research projects detected."] + lines += ["", "## 5. Administration / personal sprint", ""] + lines += [task_line(t) for t in admin[:12]] or ["- No administration or personal tasks detected."] + lines += ["", "## 6. Waiting on / source gaps", ""] + lines += [f"- **{md_link(p.get('name','project'), p.get('path',''))}** — status `{p.get('status')}` — next: {p.get('next_action','')}" for p in waiting] or ["- No waiting/source-gap projects detected."] + lines += ["", "## 7. Project hygiene flags", ""] + if flags: + for p, fs in flags[:20]: + lines.append(f"- **{md_link(p.get('name','project'), p.get('path',''))}**: {', '.join(fs)}") + else: + lines.append("- No project hygiene flags detected.") + lines += [ + "", + "## 8. Wins / shipped this week", + "", + "- ", + "", + "## 9. Decisions made", + "", + "- ", + "", + "## 10. Notes to create or clean up", + "", + "- ", + "", + "## 11. Wellness check-in", + "", + "- Sleep:", + "- Exercise:", + "- Diet:", + "- Recovery / appointments:", + "- Next experiment:", + "", + "## 12. Next week plan", + "", + "### Top 3 outcomes", + "", + "1. ", + "2. ", + "3. ", + "", + "### First three actions on Monday", + "", + "1. ", + "2. ", + "3. ", + "", + "## 13. Agent prompt", + "", + "```text", + "Read this weekly review plus data/tasks.json, data/projects.json, and data/notes.json. Update project Markdown files only. Do not invent completions. Produce a 5-day plan with one deep-work block per day and a short admin sprint.", + "```", + "", + ] + out.write_text("\n".join(lines), encoding="utf-8") + return out + + +if __name__ == "__main__": + p = build_review(force=True) + print(p) diff --git a/scripts/import_strava_archive.py b/scripts/import_strava_archive.py new file mode 100644 index 0000000..3a637d5 --- /dev/null +++ b/scripts/import_strava_archive.py @@ -0,0 +1,298 @@ +#!/usr/bin/env python3 +"""Import a Strava archive into private raw storage plus a safe Markdown summary.""" + +from __future__ import annotations + +import argparse +import csv +import hashlib +import json +import re +import shutil +import zipfile +from collections import Counter, defaultdict +from dataclasses import dataclass +from datetime import date, datetime, timedelta +from pathlib import Path +from typing import Any + +import yaml + +try: + from scripts.workbench_paths import VAULT_ROOT +except ImportError: # pragma: no cover + from workbench_paths import VAULT_ROOT + +ROOT = VAULT_ROOT +DEFAULT_IMPORT_ROOT = ROOT / "areas" / "wellness" / "imports" / "strava" +DEFAULT_NOTE_DIR = ROOT / "areas" / "wellness" / "notes" + + +@dataclass(frozen=True) +class Activity: + activity_id: str + started_at: datetime + activity_type: str + distance_km: float + moving_hours: float + elevation_gain_m: float + calories: float + relative_effort: float + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("archive", type=Path, help="Path to a Strava export zip") + parser.add_argument("--import-date", default=date.today().isoformat(), help="Import date for folder and note names") + parser.add_argument("--import-root", type=Path, default=DEFAULT_IMPORT_ROOT, help="Ignored private import root") + parser.add_argument("--note-dir", type=Path, default=DEFAULT_NOTE_DIR, help="Tracked private summary note directory") + parser.add_argument("--project", default="", help="Optional project id to attach to the aggregate note") + parser.add_argument("--copy-archive", action="store_true", help="Copy the full zip into the private import folder") + return parser.parse_args() + + +def slugify(value: str) -> str: + slug = re.sub(r"[^a-zA-Z0-9]+", "-", value).strip("-").lower() + return slug or "strava-export" + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def dedupe_headers(headers: list[str]) -> list[str]: + counts: Counter[str] = Counter() + result: list[str] = [] + for header in headers: + counts[header] += 1 + result.append(header if counts[header] == 1 else f"{header}__{counts[header]}") + return result + + +def number(value: Any) -> float: + try: + return float(str(value or "0").replace(",", "")) + except ValueError: + return 0.0 + + +def parse_activity_date(value: str) -> datetime: + return datetime.strptime(value, "%b %d, %Y, %I:%M:%S %p") + + +def read_activities(archive: Path) -> tuple[list[Activity], str]: + with zipfile.ZipFile(archive) as zf: + raw = zf.read("activities.csv").decode("utf-8-sig") + rows = list(csv.reader(raw.splitlines())) + headers = dedupe_headers(rows[0]) + activities: list[Activity] = [] + for row in rows[1:]: + if len(row) < len(headers): + row += [""] * (len(headers) - len(row)) + record = dict(zip(headers, row)) + try: + started_at = parse_activity_date(record.get("Activity Date", "")) + except ValueError: + continue + distance_m = number(record.get("Distance__2")) + activities.append( + Activity( + activity_id=str(record.get("Activity ID") or ""), + started_at=started_at, + activity_type=str(record.get("Activity Type") or "Other"), + distance_km=distance_m / 1000, + moving_hours=number(record.get("Moving Time")) / 3600, + elevation_gain_m=number(record.get("Elevation Gain")), + calories=number(record.get("Calories")), + relative_effort=number(record.get("Relative Effort")), + ) + ) + return activities, raw + + +def empty_bucket() -> dict[str, float | int]: + return { + "activities": 0, + "distance_km": 0.0, + "moving_hours": 0.0, + "elevation_gain_m": 0.0, + "calories": 0.0, + "relative_effort": 0.0, + } + + +def add_activity(bucket: dict[str, float | int], activity: Activity) -> None: + bucket["activities"] = int(bucket["activities"]) + 1 + bucket["distance_km"] = float(bucket["distance_km"]) + activity.distance_km + bucket["moving_hours"] = float(bucket["moving_hours"]) + activity.moving_hours + bucket["elevation_gain_m"] = float(bucket["elevation_gain_m"]) + activity.elevation_gain_m + bucket["calories"] = float(bucket["calories"]) + activity.calories + bucket["relative_effort"] = float(bucket["relative_effort"]) + activity.relative_effort + + +def rounded(bucket: dict[str, float | int]) -> dict[str, float | int]: + return { + "activities": int(bucket["activities"]), + "distance_km": round(float(bucket["distance_km"]), 1), + "moving_hours": round(float(bucket["moving_hours"]), 1), + "elevation_gain_m": round(float(bucket["elevation_gain_m"])), + "calories": round(float(bucket["calories"])), + "relative_effort": round(float(bucket["relative_effort"])), + } + + +def month_add(start: date, offset: int) -> date: + month_index = start.year * 12 + start.month - 1 + offset + return date(month_index // 12, month_index % 12 + 1, 1) + + +def build_summary(activities: list[Activity], import_date: str, source_pointer: str) -> dict[str, Any]: + if not activities: + raise ValueError("No activities found in activities.csv") + + sorted_activities = sorted(activities, key=lambda item: item.started_at) + period_start = sorted_activities[0].started_at.date() + period_end = sorted_activities[-1].started_at.date() + total = empty_bucket() + for activity in sorted_activities: + add_activity(total, activity) + + by_type: dict[str, dict[str, float | int]] = defaultdict(empty_bucket) + by_month: dict[str, dict[str, float | int]] = defaultdict(empty_bucket) + by_week: dict[str, dict[str, float | int]] = defaultdict(empty_bucket) + for activity in sorted_activities: + add_activity(by_type[activity.activity_type], activity) + add_activity(by_month[activity.started_at.strftime("%Y-%m")], activity) + week_start = activity.started_at.date() - timedelta(days=activity.started_at.weekday()) + add_activity(by_week[week_start.isoformat()], activity) + + last_month = date(period_end.year, period_end.month, 1) + recent_months = [month_add(last_month, -offset).strftime("%Y-%m") for offset in range(17, -1, -1)] + end_week = period_end - timedelta(days=period_end.weekday()) + recent_week_starts = [(end_week - timedelta(days=7 * offset)).isoformat() for offset in range(11, -1, -1)] + recent_cutoff = period_end - timedelta(days=89) + recent_90d = empty_bucket() + for activity in sorted_activities: + if activity.started_at.date() >= recent_cutoff: + add_activity(recent_90d, activity) + + return { + "source": "Strava export", + "import_date": import_date, + "source_private_path": source_pointer, + "period_start": period_start.isoformat(), + "period_end": period_end.isoformat(), + "totals": rounded(total), + "recent_90d": rounded(recent_90d), + "activity_types": [ + {"type": activity_type, **rounded(bucket)} + for activity_type, bucket in sorted(by_type.items(), key=lambda item: (-int(item[1]["activities"]), item[0])) + ], + "recent_months": [ + {"month": month, **rounded(by_month.get(month, empty_bucket()))} + for month in recent_months + ], + "recent_weeks": [ + {"week_start": week, **rounded(by_week.get(week, empty_bucket()))} + for week in recent_week_starts + ], + } + + +def build_note(summary: dict[str, Any], project: str = "") -> str: + frontmatter = { + "kind": "strava-summary", + "domain": "personal", + "area": "wellness", + "module": "wellness", + "title": f"Strava activity summary through {summary['period_end']}", + "date": summary["import_date"], + "private": True, + "source": summary["source"], + "source_private_path": summary["source_private_path"], + "strava_activity_count": summary["totals"]["activities"], + "strava_period_start": summary["period_start"], + "strava_period_end": summary["period_end"], + "strava_moving_hours": summary["totals"]["moving_hours"], + "strava_distance_km": summary["totals"]["distance_km"], + "strava_recent_90d_activities": summary["recent_90d"]["activities"], + "strava_recent_90d_moving_hours": summary["recent_90d"]["moving_hours"], + "strava_recent_90d_distance_km": summary["recent_90d"]["distance_km"], + "strava_summary_json": json.dumps(summary, separators=(",", ":")), + } + if project: + frontmatter["project"] = project + top_types = summary["activity_types"][:5] + recent = summary["recent_90d"] + totals = summary["totals"] + top_type_lines = "\n".join( + f"- {item['type']}: {item['activities']} activities, {item['moving_hours']} hours, {item['distance_km']} km." + for item in top_types + ) + body = f"""# Strava Activity Summary Through {summary['period_end']} + +Source: private Strava archive import. Raw GPS/workout files stay under the ignored private import folder. + +## Safe aggregate snapshot + +- Period covered: {summary['period_start']} to {summary['period_end']}. +- Activities: {totals['activities']}. +- Moving time: {totals['moving_hours']} hours. +- Distance: {totals['distance_km']} km. +- Recent 90 days: {recent['activities']} activities, {recent['moving_hours']} moving hours, {recent['distance_km']} km. + +## Main activity mix + +{top_type_lines} + +## Architecture + +- Put future Strava exports in the ignored private import area via `python3 scripts/import_strava_archive.py /path/to/export.zip`. +- The script extracts only `activities.csv` and writes sanitized aggregate frontmatter into this tracked private note. +- Do not commit raw GPX, FIT, TCX, media, profile, contact, route, or social files. +- Health Review reads the aggregate `strava_summary` frontmatter for charts and keeps details hidden in private mode. +""" + return f"---\n{yaml.safe_dump(frontmatter, sort_keys=False, allow_unicode=False).strip()}\n---\n{body}" + + +def main() -> None: + args = parse_args() + archive = args.archive.expanduser().resolve() + if not archive.exists(): + raise SystemExit(f"Archive not found: {archive}") + + activities, activities_csv = read_activities(archive) + import_slug = f"{args.import_date}-{slugify(archive.stem)}" + import_dir = (args.import_root if args.import_root.is_absolute() else ROOT / args.import_root) / import_slug + import_dir.mkdir(parents=True, exist_ok=True) + (import_dir / "activities.csv").write_text(activities_csv, encoding="utf-8") + if args.copy_archive: + shutil.copy2(archive, import_dir / archive.name) + + manifest = { + "source_archive": str(archive), + "source_sha256": sha256_file(archive), + "import_date": args.import_date, + "activity_rows": len(activities), + "extracted_files": ["activities.csv"], + "not_extracted": ["activities/*.gpx", "activities/*.fit.gz", "activities/*.tcx.gz", "media/*", "routes/*", "profile/contact/social files"], + } + (import_dir / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8") + + source_pointer = str(import_dir.relative_to(ROOT)) + summary = build_summary(activities, args.import_date, source_pointer) + (import_dir / "summary.json").write_text(json.dumps(summary, indent=2), encoding="utf-8") + + note_dir = args.note_dir if args.note_dir.is_absolute() else ROOT / args.note_dir + note_dir.mkdir(parents=True, exist_ok=True) + note_path = note_dir / f"{args.import_date}-strava-activity-summary.md" + note_path.write_text(build_note(summary, args.project), encoding="utf-8") + print(json.dumps({"note": str(note_path.relative_to(ROOT)), "private_import": source_pointer, "activities": len(activities)}, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/scripts/markdown_reader.py b/scripts/markdown_reader.py new file mode 100755 index 0000000..dcfb414 --- /dev/null +++ b/scripts/markdown_reader.py @@ -0,0 +1,287 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Markdown vault reader for Research Workbench. + +This is deliberately plain and local: Markdown + YAML frontmatter in, JSON cache out. +It mirrors the useful Tolaria conventions without depending on Tolaria itself. +""" +from __future__ import annotations + +import json +import re +import sys +from dataclasses import dataclass, asdict +from pathlib import Path +from typing import Any, Dict, Iterable, List, Tuple + +try: + import yaml # type: ignore +except Exception as exc: # pragma: no cover + raise SystemExit("PyYAML is required. Install with: python3 -m pip install pyyaml") from exc + +try: + from scripts.workbench_paths import DATA_ROOT, VAULT_ROOT +except ImportError: # pragma: no cover + from workbench_paths import DATA_ROOT, VAULT_ROOT + +ROOT = VAULT_ROOT + +# Shared frontmatter/body primitives. Imported as a package (server/tests) or by +# bare name when pm.py runs this file with scripts/ on sys.path. +try: + from scripts.vault_parsing import ( + CODEX_INSTRUCTION_ITEM_RE, + CODEX_INSTRUCTIONS_HEADING_RE, + H1_RE, + STRUCTURED_PROPERTY_FIELDS, + WIKILINK_RE, + extract_codex_instructions, + extract_title, + json_safe, + listify, + relpath, + scalar, + snippet, + split_frontmatter, + wikilinks_from_value, + word_count, + ) +except ImportError: # pragma: no cover - bare-script invocation path + from vault_parsing import ( # type: ignore + CODEX_INSTRUCTION_ITEM_RE, + CODEX_INSTRUCTIONS_HEADING_RE, + H1_RE, + STRUCTURED_PROPERTY_FIELDS, + WIKILINK_RE, + extract_codex_instructions, + extract_title, + json_safe, + listify, + relpath, + scalar, + snippet, + split_frontmatter, + wikilinks_from_value, + word_count, + ) + +# Reader-specific: assignee/domain are NOT core here, so they surface via +# `properties` (RA rendering reads them there). See scripts/vault_parsing.py. +CORE_FIELDS = { + "title", "type", "kind", "status", "icon", "url", "date", "start_date", "end_date", + "goal", "result", "workspace", "Workspace", "belongs_to", "related_to", "has", + "aliases", "project", "id", "priority", "due", "next", "area", "dashboard", + "private", "source", "energy", "deadline", "deadline_type", "last_touched", "lead", "owner", + "urgent", +} + +LOCAL_ONLY_SENSITIVE_DIRS = { + # These files are readable by the local hosted app, but must not be copied + # into generated tracked JSON/dashboard artifacts. + "booking_details_sensitive", +} + + +def parse_markdown_file(path: Path) -> Dict[str, Any]: + raw = path.read_text(encoding="utf-8", errors="replace") + fm, body = split_frontmatter(raw) + links = sorted(set(m.group(1).strip() for m in WIKILINK_RE.finditer(body))) + relationships: Dict[str, List[str]] = {} + for key, value in fm.items(): + rels = wikilinks_from_value(value) + if rels: + relationships[str(key)] = rels + type_value = scalar(fm.get("type")) or scalar(fm.get("Is A")) or scalar(fm.get("kind")) + properties = { + str(k): json_safe(v) for k, v in fm.items() + if str(k) not in CORE_FIELDS + and not str(k).startswith("_") + and (str(k) in STRUCTURED_PROPERTY_FIELDS or not isinstance(v, (dict, list))) + } + system_properties = {str(k): json_safe(v) for k, v in fm.items() if str(k).startswith("_")} + stat = path.stat() + codex_instructions = extract_codex_instructions(body) + entry = { + "path": relpath(path), + "filename": path.name, + "title": extract_title(fm, body, path), + "type": type_value, + "kind": scalar(fm.get("kind")) or type_value, + "status": scalar(fm.get("status")), + "project": scalar(fm.get("project")), + "id": scalar(fm.get("id")), + "priority": scalar(fm.get("priority")), + **({"urgent": True} if bool(fm.get("urgent", False)) else {}), + "deadline_type": scalar(fm.get("deadline_type")), + "due": scalar(fm.get("due")) or scalar(fm.get("date")), + "date": scalar(fm.get("date")), + "start_date": scalar(fm.get("start_date")), + "end_date": scalar(fm.get("end_date")), + "next": scalar(fm.get("next")) or scalar(fm.get("next_action")), + "icon": scalar(fm.get("icon")), + "url": scalar(fm.get("url")), + "aliases": listify(fm.get("aliases")), + "belongs_to": wikilinks_from_value(fm.get("belongs_to")), + "related_to": wikilinks_from_value(fm.get("related_to")), + "has": wikilinks_from_value(fm.get("has")), + "relationships": relationships, + "outgoing_links": links, + "properties": properties, + "system_properties": system_properties, + "private": bool(fm.get("private", False)) or "private" in relpath(path).lower(), + "word_count": word_count(body), + "snippet": snippet(body), + "modified_at": stat.st_mtime, + "created_at": stat.st_ctime, + "file_kind": "markdown", + } + if codex_instructions: + entry["codex_instructions"] = codex_instructions + return entry + + +def should_skip(path: Path, include_private: bool = False) -> bool: + rel = relpath(path) + parts = set(path.parts) + rel_parts = path.relative_to(ROOT).parts + generated_or_dependency_dirs = { + ".git", + ".github", + ".venv", + "__pycache__", + ".pytest_cache", + "node_modules", + "dist", + "data", + "dashboard", + "test-results", + "playwright-report", + } + if any(part in generated_or_dependency_dirs for part in rel_parts): + return True + if any(part in LOCAL_ONLY_SENSITIVE_DIRS for part in rel_parts): + return True + if rel.startswith("web/fixtures/") or rel.startswith("web/.tmp/"): + return True + if any(p.startswith(".") for p in path.relative_to(ROOT).parts if p != path.name): + return True + if "archive" in parts and not include_private: + # archive is scanned by default for notes? Keep it out of dashboard-level reading. + return True + if not include_private and ("private" in parts or "/private/" in rel.lower()): + return True + if path.name.startswith("."): + return True + if path.suffix.lower() not in {".md", ".markdown"}: + return True + return False + + +def scan_vault(include_private: bool = False) -> List[Dict[str, Any]]: + entries: List[Dict[str, Any]] = [] + for path in ROOT.rglob("*.md"): + if should_skip(path, include_private=include_private): + continue + try: + entry = parse_markdown_file(path) + if entry.get("kind") == "archived-task-copy" or entry.get("type") == "archived-task-copy": + continue + if entry.get("properties", {}).get("archived_from_task"): + continue + entries.append(entry) + except Exception as exc: + entries.append({"path": relpath(path), "title": path.name, "error": str(exc)}) + # Sort by path (stable across machines) rather than filesystem mtime, so the + # cache and the relationships graph derived from it are reproducible in CI. + entries.sort(key=lambda e: e.get("path") or "") + return entries + + +def slug_from_title(title: str) -> str: + slug = re.sub(r"[^A-Za-z0-9]+", "-", title.strip().lower()).strip("-") + return slug or "untitled" + + +def node_key(entry: Dict[str, Any]) -> str: + return (entry.get("id") or entry.get("path") or entry.get("title") or "").strip() + + +def build_relationships(entries: List[Dict[str, Any]]) -> Dict[str, Any]: + nodes = [] + edges = [] + title_index: Dict[str, str] = {} + for e in entries: + key = node_key(e) + title = e.get("title") or key + title_index[slug_from_title(title)] = key + title_index[title.lower()] = key + if e.get("id"): + title_index[str(e["id"]).lower()] = key + nodes.append({ + "id": key, + "title": title, + "path": e.get("path"), + "type": e.get("type") or e.get("kind"), + "project": e.get("project"), + "status": e.get("status"), + "private": e.get("private", False), + }) + def resolve(target: str) -> str: + t = target.strip().lower() + return title_index.get(t) or title_index.get(slug_from_title(target)) or target + for e in entries: + src = node_key(e) + if e.get("project"): + edges.append({"source": src, "target": resolve(str(e.get("project"))), "label": "project"}) + if e.get("type") or e.get("kind"): + edges.append({"source": src, "target": str(e.get("type") or e.get("kind")), "label": "type"}) + for rel, targets in (e.get("relationships") or {}).items(): + for target in targets: + edges.append({"source": src, "target": resolve(target), "label": rel}) + for target in e.get("outgoing_links") or []: + edges.append({"source": src, "target": resolve(target), "label": "mentions"}) + # De-duplicate while preserving order. + seen = set() + deduped = [] + for e in edges: + key = (e.get("source"), e.get("target"), e.get("label")) + if key not in seen: + seen.add(key) + deduped.append(e) + # Deterministic order so relationships.json (and the dashboard graph built + # from it) is reproducible regardless of filesystem iteration order. + nodes.sort(key=lambda n: n.get("id") or "") + deduped.sort(key=lambda x: (x.get("source") or "", x.get("target") or "", x.get("label") or "")) + return {"nodes": nodes, "edges": deduped} + + +def write_cache(include_private: bool = False) -> None: + data_dir = DATA_ROOT + data_dir.mkdir(exist_ok=True) + entries = scan_vault(include_private=include_private) + relationships = build_relationships(entries) + (data_dir / "vault_entries.json").write_text(json.dumps(entries, indent=2), encoding="utf-8") + (data_dir / "relationships.json").write_text(json.dumps(relationships, indent=2), encoding="utf-8") + + +def markdown_preview(path: str | Path, limit: int = 420) -> str: + p = Path(path) + if not p.is_absolute(): + p = ROOT / p + raw = p.read_text(encoding="utf-8", errors="replace") + _, body = split_frontmatter(raw) + body = body.strip() + body = re.sub(r"\n{3,}", "\n\n", body) + return body[:limit] + ("…" if len(body) > limit else "") + + +def main() -> None: + include_private = "--include-private" in sys.argv + entries = scan_vault(include_private=include_private) + write_cache(include_private=include_private) + print(f"Scanned {len(entries)} markdown entries") + print("Wrote data/vault_entries.json and data/relationships.json") + + +if __name__ == "__main__": + main() diff --git a/scripts/pm.py b/scripts/pm.py new file mode 100755 index 0000000..f4c5161 --- /dev/null +++ b/scripts/pm.py @@ -0,0 +1,570 @@ +#!/usr/bin/env python3 +from __future__ import annotations +import argparse, json, os, shutil, subprocess, sys, re, time +from pathlib import Path +from datetime import date, datetime, timedelta + +try: + from scripts.workbench_config import load_workbench_config + from scripts.workbench_paths import APP_ROOT, DATA_ROOT, EXAMPLE_VAULT_ROOT, TEMPLATE_ROOT, VAULT_ROOT +except ImportError: # pragma: no cover - direct script invocation + from workbench_config import load_workbench_config + from workbench_paths import APP_ROOT, DATA_ROOT, EXAMPLE_VAULT_ROOT, TEMPLATE_ROOT, VAULT_ROOT + +ROOT=VAULT_ROOT +DATA=DATA_ROOT +PERF_LOGS_ENABLED=os.environ.get("PM_PERF_LOGS","").lower() in {"1","true","yes","on"} +CLOSED_TASK_STATUSES={"done","complete","completed","cancelled","canceled","dropped","archive","archived"} +NOTE_TYPES={ + "meeting":("academic/meeting_note.md","meetings"), + "feedback":("academic/feedback_note.md","feedback"), + "model":("academic/model_note.md","modeling"), + "derivation":("academic/derivation_note.md","modeling"), + "data":("academic/data_note.md","data"), + "code":("academic/code_run_log.md","code-runs"), + "result":("academic/result_note.md","empirics"), + "literature":("academic/literature_note.md","notes/literature"), + "presentation":("academic/presentation_plan.md","presentations"), + "revision":("academic/revision_matrix.md","revisions"), + "section":("academic/section_draft_note.md","writing"), + "idea":("academic/idea_note.md","notes/ideas"), + "coauthor":("academic/coauthor_update.md","notes/procedural"), + "procedural":("academic/coauthor_update.md","notes/procedural"), + "technical-issue":("academic/technical_issue.md","notes/technical"), +} + +def perf_log(event, **fields): + if PERF_LOGS_ENABLED: + print("[perf] "+event+" "+" ".join(f"{k}={v}" for k,v in fields.items()), file=sys.stderr) + +def timed_check_call(label, argv): + started=time.perf_counter() + subprocess.check_call(argv) + perf_log(label, elapsed_ms=round((time.perf_counter()-started)*1000,2)) + +def sync_markdown(): timed_check_call("pm.sync_markdown", [sys.executable, str(APP_ROOT/"scripts/sync_markdown.py")]) +def load(n): + p=DATA/n + if not p.exists(): sync_markdown() + return json.loads(p.read_text(encoding="utf-8")) +def days_until(d): + try: return (date.fromisoformat(str(d))-date.today()).days + except Exception: return None +def is_open_task(t): + return str(t.get("status","")).lower() not in CLOSED_TASK_STATUSES +def slugify(s): return re.sub(r"[^a-z0-9]+","-",s.lower()).strip("-")[:80] or "note" +def project_dir(project): + for base in [ROOT/"projects",ROOT/"areas"]: + d=base/project + if d.exists(): return d + print(f"No such project/area: {project}", file=sys.stderr); sys.exit(1) +def tmpl(src,dst,mapping,force=False): + if dst.exists() and not force: print(f"Exists: {dst}"); return dst + s=src.read_text(encoding="utf-8") + for k,v in mapping.items(): s=s.replace("{{"+k+"}}",str(v)) + dst.parent.mkdir(parents=True,exist_ok=True); dst.write_text(s,encoding="utf-8"); print(f"Created {dst}"); return dst + +def status(args): + if not args.no_sync: sync_markdown() + print("\nPROJECT STATUS\n"+"="*14) + for p in sorted(load("projects.json"), key=lambda p:(p.get("priority",9),p.get("deadline") or "9999",p.get("name",""))): + due=p.get("deadline",""); delta=days_until(due) if due else None + print(f"[{p.get('priority')}] {p['name']} — {p['status']}"+(f" due {due}"+(f" ({delta:+d}d)" if delta is not None else "") if due else "")) + print(f" next: {p.get('next_action','')}") + print(f" file: {p.get('path','')}") + +def today(args): + if not args.no_sync: sync_markdown() + open_tasks=[t for t in load("tasks.json") if is_open_task(t)] + print("\nTODAY / NEAR-TERM\n"+"="*18) + for t in sorted(open_tasks,key=lambda t:(t.get("priority",9),t.get("due") or "9999-12-31"))[:15]: + due=t.get("due",""); delta=days_until(due) if due else None; warn="" + if delta is not None: warn=" OVERDUE" if delta<0 else (" URGENT" if delta<=3 else "") + print(f"- [{t.get('priority')}] {t['title']} ({t['project']}) due {due}{warn}") + print(f" next: {t.get('next','')}") + print(f" file: {t.get('path','')}") + +def focus(args): + if not args.no_sync: sync_markdown() + tasks=[t for t in load("tasks.json") if is_open_task(t)] + projects=load("projects.json") + urgent=[t for t in tasks if (days_until(t.get("due")) is not None and days_until(t.get("due"))<=3)] + top=sorted(urgent,key=lambda t:(t.get("priority",9), days_until(t.get("due"))))[:5] + if len(top)<5: + extra=[t for t in tasks if t not in top] + top += sorted(extra,key=lambda t:(t.get("priority",9),t.get("due") or "9999"))[:5-len(top)] + research=[p for p in projects if str(p.get("domain") or p.get("area") or "").lower() == "research" and str(p.get("status","")).lower().startswith("active")] + research=sorted(research,key=lambda p:(p.get("priority",9),p.get("deadline") or "9999")) + waiting=[p for p in projects if any(k in str(p.get("status","")).lower() for k in ["waiting","needs","audit","source"])] + print("\nFOCUS VIEW\n"+"="*10) + print("\nTop actions") + for i,t in enumerate(top,1): + print(f"{i}. {t.get('title')} — due {t.get('due') or 'no date'} — {t.get('project')}") + print(f" next: {t.get('next','')}") + print("\nSuggested deep-work block") + if research: + p=research[0] + print(f"- {p.get('name')} — {p.get('next_action')}") + print(f" file: {p.get('path')}") + else: + print("- No active research project detected.") + print("\nWaiting / source gaps") + for p in waiting[:8]: + print(f"- {p.get('name')} [{p.get('status')}] — {p.get('next_action')}") + +def render(args): + if not args.no_sync: sync_markdown() + timed_check_call("pm.render.static", [sys.executable, str(APP_ROOT/"scripts/render_static.py")]) + print(f"Rendered deterministic static views to {DATA.parent / 'dashboard'}.") + +def weekly_review(args): + if not args.no_sync: sync_markdown() + sys.path.insert(0, str(APP_ROOT/"scripts")) + from generate_weekly_review import build_review + out=build_review(args.week, force=args.force) + sync_markdown() + if args.render: + subprocess.check_call([sys.executable, str(APP_ROOT/"scripts/render_static.py")]) + print(f"Weekly review: {out}") + +def export_codex(args): + if not args.no_sync: sync_markdown() + ps=load("projects.json"); ts=[t for t in load("tasks.json") if is_open_task(t)]; ns=load("notes.json") if (DATA/"notes.json").exists() else [] + lines=["# Agent context packet","",f"Generated: {datetime.now().isoformat(timespec='seconds')}","","Markdown is canonical. JSON in data/ is generated cache. Exclude confidential source material and raw private imports.","","## Active projects"] + for p in sorted(ps,key=lambda p:(p.get("priority",9),p.get("name",""))): lines += [f"### {p['name']}",f"- ID: {p.get('id')}",f"- Status: {p.get('status')}",f"- Priority: {p.get('priority')}",f"- Deadline: {p.get('deadline') or ''}".rstrip(),f"- Next: {p.get('next_action')}",f"- File: {p.get('path')}",""] + lines.append("## Open tasks") + for t in sorted(ts,key=lambda t:(t.get("priority",9),t.get("due") or "9999")): lines.append(f"- [{t.get('priority')}] {t['title']} — {t['project']} — due {t.get('due')} — {t.get('next')} — file: {t.get('path')}") + lines += ["","## Recent notes"] + for n in ns[:30]: lines.append(f"- {n.get('date')} [{n.get('kind')}] {n.get('title')} — {n.get('project')} — file: {n.get('path')}") + out=ROOT/"codex/CONTEXT_PACKET.md"; out.write_text("\n".join(lines)+"\n",encoding="utf-8"); print(f"Wrote {out}") + +def log(args): + d=project_dir(args.project); f=d/"progress.md" + f.write_text((f.read_text(encoding="utf-8") if f.exists() else "")+f"\n## {date.today().isoformat()}\n\n{args.message}\n",encoding="utf-8"); print(f"Logged to {f}") + +def capture(args): + now=datetime.now() + title=args.title or args.text[:60] + slug=slugify(title) + out=ROOT/"_inbox"/f"{now.strftime('%Y-%m-%d-%H%M')}-{slug}.md" + body=[ + "---", + "kind: inbox-note", + f"title: \"{title.replace(chr(34), chr(39))}\"", + f"date: {now.date().isoformat()}", + f"status: uncategorized", + f"project: {args.project or ''}", + f"source: {args.source or 'manual'}", + "---", + "", + f"# {title}", + "", + args.text, + "", + "## Triage", + "", + "- Project/area:", + "- Is this a task, note, source, or decision?", + "- Next action:", + "- File to move/link to:", + "", + ] + out.write_text("\n".join(body),encoding="utf-8") + print(f"Captured {out}") + +def new_daily(args): tmpl(TEMPLATE_ROOT/"daily_note.md",ROOT/"journal/daily"/f"{args.date or date.today().isoformat()}.md",{"date":args.date or date.today().isoformat()},args.force) +def new_health(args): tmpl(TEMPLATE_ROOT/"health/daily_health_log.md",ROOT/"areas/wellness/daily"/f"{args.date or date.today().isoformat()}.md",{"date":args.date or date.today().isoformat()},args.force) +def new_note(args): + d=project_dir(args.project); when=args.date or date.today().isoformat(); slug=slugify(args.title) + template,subdir=NOTE_TYPES[args.type] + tmpl(TEMPLATE_ROOT/template,d/subdir/f"{when}-{slug}.md",{"date":when,"project":args.project,"title":args.title,"slug":slug},args.force) +def list_notes(args): + if not args.no_sync: sync_markdown() + ns=load("notes.json") + if args.project: ns=[n for n in ns if n.get("project")==args.project] + if args.kind: ns=[n for n in ns if n.get("kind")==args.kind or n.get("kind")==args.kind+"-note"] + print("\nNOTES\n"+"="*5) + for n in ns[:args.limit]: + print(f"- {n.get('date','')} [{n.get('kind')}] {n.get('title')} — {n.get('project')}") + print(f" file: {n.get('path')}") +def sync_cmd(args): sync_markdown() + + +def vault_context_cmd(args): + sys.path.insert(0, str(APP_ROOT/"scripts")) + import markdown_reader + entries = markdown_reader.scan_vault(include_private=args.include_private) + types = sorted({e.get("type") or e.get("kind") for e in entries if e.get("type") or e.get("kind")}) + folders = sorted({str(e.get("path","")).split("/",1)[0] + "/" for e in entries if "/" in str(e.get("path",""))}) + recent = [{"path": e.get("path"), "title": e.get("title"), "type": e.get("type") or e.get("kind")} for e in entries[:20]] + print(json.dumps({"types": types, "noteCount": len(entries), "folders": folders, "recentNotes": recent, "vaultPath": str(ROOT)}, indent=2, ensure_ascii=False)) + +def get_note_cmd(args): + sys.path.insert(0, str(APP_ROOT/"scripts")) + import markdown_reader + p = (ROOT / args.path).resolve() + try: + p.relative_to(ROOT.resolve()) + except Exception: + raise SystemExit("Note path must stay inside the active vault") + raw = p.read_text(encoding="utf-8", errors="replace") + fm, body = markdown_reader.split_frontmatter(raw) + print(json.dumps({"path": str(p.relative_to(ROOT)), "frontmatter": markdown_reader.json_safe(fm), "content": body.strip()}, indent=2, ensure_ascii=False)) + +def search_notes_cmd(args): + sys.path.insert(0, str(APP_ROOT/"scripts")) + import markdown_reader + q = args.query.lower() + results = [] + for e in markdown_reader.scan_vault(include_private=args.include_private): + hay = " ".join(str(e.get(k) or "") for k in ["title", "path", "type", "kind", "snippet", "project", "status"]).lower() + if q not in hay: + # Fall back to body search only until limit is met. + try: + raw = (ROOT / e.get("path", "")).read_text(encoding="utf-8", errors="replace") + if q not in raw.lower(): + continue + except Exception: + continue + results.append({"path": e.get("path"), "title": e.get("title"), "type": e.get("type") or e.get("kind"), "project": e.get("project"), "snippet": e.get("snippet")}) + if len(results) >= args.limit: + break + print(json.dumps(results, indent=2, ensure_ascii=False)) + + +def scan_vault_cmd(args): + sys.path.insert(0, str(APP_ROOT/"scripts")) + import markdown_reader + entries = markdown_reader.scan_vault(include_private=args.include_private) + markdown_reader.write_cache(include_private=args.include_private) + print(f"Scanned {len(entries)} Markdown entries.") + print("Wrote data/vault_entries.json and data/relationships.json") + by_type = {} + for e in entries: + k = e.get("type") or e.get("kind") or "untyped" + by_type[k] = by_type.get(k, 0) + 1 + for k, v in sorted(by_type.items(), key=lambda kv: (-kv[1], kv[0]))[:12]: + print(f"- {k}: {v}") + +def preview_cmd(args): + sys.path.insert(0, str(APP_ROOT/"scripts")) + import markdown_reader + print(markdown_reader.markdown_preview(args.path, args.limit)) + +def graph_cmd(args): + if not args.no_sync: + sync_markdown() + subprocess.check_call([sys.executable, str(APP_ROOT/"scripts/render_static.py")]) + print(f"Graph view: {DATA.parent / 'dashboard/graph.html'}") + + + +def calendar_cmd(args): + if not args.no_sync: + sync_markdown() + subprocess.check_call([sys.executable, str(APP_ROOT/"scripts/render_calendar.py")]) + print(f"Calendar view: {DATA.parent / 'dashboard/calendar.html'}") + + + +def plan_cmd(args): + if not args.no_sync: sync_markdown() + from time_blocks import allocate_daily_plan, allocate_weekly_plan, parse_date, task_reason + from calendar_feed import load_calendar_events_by_day + from time_plan_context import read_time_planning_settings + + target_day = parse_date(args.date) if args.date else date.today() + if target_day is None: + print(f"Invalid --date: {args.date}", file=sys.stderr) + sys.exit(2) + week_start = parse_date(args.week) if args.week and len(args.week) == 10 else target_day + if week_start is None: + print(f"Invalid --week date: {args.week}", file=sys.stderr) + sys.exit(2) + week_prefix = args.week if args.week and len(args.week) < 10 else target_day.isoformat()[:8] + tasks=load("tasks.json") + no_work_before = parse_date(read_time_planning_settings(ROOT).get("no_work_before")) + calendar_end = max(target_day + timedelta(days=1), week_start + timedelta(days=args.weekdays + 14)) + calendar_import = load_calendar_events_by_day(ROOT, min(target_day, week_start), calendar_end) + calendar_events_by_day = calendar_import["events_by_day"] + + daily, daily_overflow = allocate_daily_plan( + tasks, + target_day, + start=args.start, + capacity_minutes=args.capacity_minutes, + horizon_days=args.horizon_days, + week_prefix=week_prefix, + no_work_before=no_work_before, + calendar_events=calendar_events_by_day.get(target_day, []), + ) + + print("\nDAILY TIME BLOCK PLAN\n"+"="*22) + print(f"Date: {target_day.isoformat()}") + if no_work_before and target_day < no_work_before: + print(f"Availability: no work blocks before {no_work_before.isoformat()}.") + print(f"Automatic allocation: open tasks ranked by overdue/today/due-soon/P1/P2.") + if calendar_import["status"].get("enabled"): + print( + "Calendar: " + f"{calendar_import['status'].get('event_count', 0)} work event(s) from " + f"{', '.join(calendar_import['status'].get('source_labels') or []) or 'configured feed'}." + ) + for error in calendar_import["status"].get("errors") or []: + print(f"Calendar warning: {error}") + if not daily: + print("- No urgent or high-priority tasks fit the workday capacity.") + total=0 + for item in daily: + t=item.task + total += item.minutes + if t.get("kind") == "calendar": + print(f"- {item.start}-{item.end} | calendar | {t.get('title')} ({t.get('source_label') or t.get('project')})") + print(f" blocks: {'yes' if t.get('blocking') else 'no'} | {item.minutes}m") + else: + print(f"- {item.start}-{item.end} | {item.reason} | P{t.get('priority')} | {t.get('title')} ({t.get('project')})") + print(f" est: {item.minutes}m | due: {t.get('due') or 'no date'} | next: {t.get('next','')}") + print(f" file: {t.get('path')}") + print(f"Total scheduled: {total} minutes ({total/60:.1f}h)") + if daily_overflow: + print(f"Overflow not scheduled today: {len(daily_overflow)} task(s).") + for t in daily_overflow[:8]: + print(f" - {task_reason(t, target_day, week_prefix)} | P{t.get('priority')} {t.get('title')} due {t.get('due') or 'no date'} [{t.get('project')}]") + + weekly, weekly_overflow = allocate_weekly_plan( + tasks, + week_start, + start=args.start, + capacity_minutes=args.capacity_minutes, + weekdays=args.weekdays, + horizon_days=args.horizon_days, + week_prefix=week_prefix, + no_work_before=no_work_before, + calendar_events_by_day=calendar_events_by_day, + ) + + print("\nWEEKLY TIME BLOCK PLAN\n"+"="*23) + print(f"Start day: {week_start.isoformat()} | workdays: {args.weekdays} | capacity/day: {args.capacity_minutes}m") + if no_work_before and week_start < no_work_before: + print(f"Availability: weekly allocation starts no earlier than {no_work_before.isoformat()}.") + print(f"Manual week tag still respected when block_week: {week_prefix}") + for day, items in weekly.items(): + day_total=sum(item.minutes for item in items) + print(f"\n{day.isoformat()} - {day_total}m ({day_total/60:.1f}h)") + if not items: + print(" - No urgent/high-priority allocation.") + continue + for item in items: + t=item.task + if t.get("kind") == "calendar": + print(f" - {item.start}-{item.end} | calendar | {t.get('title')} [{t.get('source_label') or t.get('project')}] ({item.minutes}m)") + else: + print(f" - {item.start}-{item.end} | {item.reason} | P{t.get('priority')} {t.get('title')} [{t.get('project')}] ({item.minutes}m)") + if weekly_overflow: + print(f"\nWeekly overflow after allocation: {len(weekly_overflow)} task(s).") + for t in weekly_overflow[:12]: + print(f" - {task_reason(t, week_start, week_prefix)} | P{t.get('priority')} {t.get('title')} due {t.get('due') or 'no date'} [{t.get('project')}]") + +def ai_plan_context_cmd(args): + if not args.no_sync: + sync_markdown() + from time_blocks import parse_clock, parse_date + from calendar_feed import load_calendar_events_by_day + from time_plan_context import build_agent_plan_context + + target_day = parse_date(args.date) if args.date else date.today() + if target_day is None: + print(f"Invalid --date: {args.date}", file=sys.stderr) + sys.exit(2) + week_start = parse_date(args.week) if args.week and len(args.week) == 10 else target_day + if week_start is None: + print(f"Invalid --week date: {args.week}", file=sys.stderr) + sys.exit(2) + try: + parse_clock(args.start) + except Exception: + print(f"Invalid --start: {args.start}; expected HH:MM", file=sys.stderr) + sys.exit(2) + + week_prefix = args.week if args.week and len(args.week) < 10 else week_start.isoformat()[:8] + calendar_end = max(target_day + timedelta(days=1), week_start + timedelta(days=args.weekdays + 14)) + calendar_import = load_calendar_events_by_day(ROOT, min(target_day, week_start), calendar_end) + context = build_agent_plan_context( + root=ROOT, + tasks=load("tasks.json"), + projects=load("projects.json"), + target_day=target_day, + week_start_day=week_start, + start=args.start, + capacity_minutes=args.capacity_minutes, + weekdays=args.weekdays, + horizon_days=args.horizon_days, + week_prefix=week_prefix, + ad_hoc=args.ad_hoc or "", + include_private=args.include_private, + calendar_events_by_day=calendar_import["events_by_day"], + calendar_status=calendar_import["status"], + ) + print(context["agent_prompt"]) + +def boards_cmd(args): + if not args.no_sync: + sync_markdown() + subprocess.check_call([sys.executable, str(APP_ROOT/"scripts/render_boards.py")]) + print(f"Board views: {DATA.parent / 'dashboard/boards.html'}") + +def deadline_audit_cmd(args): + if not args.no_sync: + sync_markdown() + tasks = [t for t in load("tasks.json") if is_open_task(t)] + dated = [t for t in tasks if t.get("due") or t.get("date") or t.get("start_date")] + missing = [t for t in dated if not str(t.get("deadline_type") or "").strip()] + hard = [t for t in dated if str(t.get("deadline_type") or "").lower() == "hard"] + soft = [t for t in dated if str(t.get("deadline_type") or "").lower() == "soft"] + print("\nDEADLINE AUDIT\n"+"="*14) + print(f"Dated open/waiting tasks: {len(dated)}") + print(f"- hard: {len(hard)}") + print(f"- soft: {len(soft)}") + print(f"- missing hard/soft: {len(missing)}") + if missing: + print("\nMissing deadline_type") + for task in sorted(missing, key=lambda t: (t.get("priority", 9), t.get("due") or t.get("date") or t.get("start_date") or "9999")): + print(f"- [P{task.get('priority')}] {task.get('title')} due {task.get('due') or task.get('date') or task.get('start_date')} :: {task.get('path')}") + +def qa_cmd(args): + sync_markdown() + timed_check_call("pm.validate_schema", [sys.executable, str(APP_ROOT/"scripts/validate_vault_schema.py"), "--root", str(ROOT)]) + timed_check_call("pm.privacy_scan", [sys.executable, str(APP_ROOT/"scripts/privacy_scan.py"), "--root", str(ROOT), "--all-files"]) + render(type("Args", (), {"no_sync": True})()) + print("QA: schema, privacy, synchronization, and static rendering completed.") + + +def init_cmd(args): + target = Path(args.vault).expanduser().resolve() + if target.exists(): + existing = [path for path in target.iterdir() if path.name != ".gitkeep"] + if existing: + raise SystemExit(f"Refusing to overwrite non-empty directory: {target}") + target.mkdir(parents=True, exist_ok=True) + shutil.copytree(EXAMPLE_VAULT_ROOT, target, dirs_exist_ok=True) + today_value = date.fromisoformat(args.today) if args.today else date.today() + replacements = { + "{{TODAY}}": today_value.isoformat(), + "{{TODAY_PLUS_7}}": (today_value + timedelta(days=7)).isoformat(), + "{{TODAY_PLUS_14}}": (today_value + timedelta(days=14)).isoformat(), + "{{TODAY_PLUS_30}}": (today_value + timedelta(days=30)).isoformat(), + } + for path in target.rglob("*"): + if not path.is_file() or path.suffix.lower() not in {".md", ".yaml", ".yml", ".json"}: + continue + text = path.read_text(encoding="utf-8") + for marker, value in replacements.items(): + text = text.replace(marker, value) + path.write_text(text, encoding="utf-8") + print(f"Created starter vault at {target}") + + +def doctor_cmd(_args): + checks: list[tuple[str, bool, str]] = [] + checks.append(("Python", sys.version_info >= (3, 11), f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}")) + node = shutil.which("node") + node_version = "" + if node: + node_version = subprocess.run([node, "--version"], capture_output=True, text=True, check=False).stdout.strip() + node_major = int(re.sub(r"\D", "", node_version.split(".")[0]) or 0) + checks.append(("Node.js", bool(node and node_major >= 22), node_version or "not found")) + checks.append(("Vault", ROOT.is_dir(), str(ROOT))) + config_path = ROOT / "settings" / "workbench.yml" + try: + load_workbench_config(ROOT) + config_ok = True + config_detail = str(config_path) if config_path.exists() else "defaults in use" + except Exception as exc: # pragma: no cover - defensive CLI boundary + config_ok = False + config_detail = str(exc) + checks.append(("Configuration", config_ok, config_detail)) + output_parent = DATA.parent + checks.append(("Output location", output_parent != APP_ROOT or ROOT == APP_ROOT, str(output_parent))) + for label, ok, detail in checks: + print(f"{'OK' if ok else 'FAIL'} {label}: {detail}") + if not all(ok for _label, ok, _detail in checks): + raise SystemExit(1) + print("Doctor found no blocking installation or configuration problems.") + +def ra_status_cmd(args): + if not args.no_sync: + sync_markdown() + collaborator_kinds={"collaborator-status","collaborator-checkin","collaborator-assignment","ra-status","ra-checkin","ra-plan"} + ts=[t for t in load("tasks.json") if is_open_task(t) and str(t.get("area") or t.get("domain") or "").lower() in {"collaborators","ra"}] + ns=[n for n in load("notes.json") if n.get("kind") in collaborator_kinds] + es=[e for e in load("events.json") if str(e.get("area") or e.get("domain") or "").lower() in {"collaborators","ra"}] + print("\nCOLLABORATOR MONITOR\n"+"="*20) + print("\nOpen collaborator tasks") + for t in sorted(ts,key=lambda t:(t.get("priority",9),t.get("due") or "9999")): + print(f"- [P{t.get('priority')}] {t.get('title')} — due {t.get('due')}") + print(f" next: {t.get('next')}") + print(f" file: {t.get('path')}") + print("\nRecent collaborator notes") + for n in ns[:12]: + print(f"- {n.get('date')} [{n.get('kind')}] {n.get('title')} — {n.get('project')}") + print(f" file: {n.get('path')}") + print("\nCollaborator dates") + for e in es[:12]: + print(f"- {e.get('date')} {e.get('title')}") + +def main(): + ap=argparse.ArgumentParser(description="Research Workbench CLI") + sub=ap.add_subparsers(dest="cmd",required=True) + p=sub.add_parser("init", help="Create a starter vault without overwriting existing content") + p.add_argument("--vault", required=True) + p.add_argument("--today", help=argparse.SUPPRESS) + p.set_defaults(func=init_cmd) + sub.add_parser("doctor", help="Run read-only installation and configuration checks").set_defaults(func=doctor_cmd) + for n,f in [("status",status),("today",today),("focus",focus),("render",render),("export-codex",export_codex)]: + p=sub.add_parser(n); p.add_argument("--no-sync",action="store_true"); p.set_defaults(func=f) + p=sub.add_parser("project-status"); p.add_argument("--no-sync", action="store_true"); p.set_defaults(func=status) + sub.add_parser("sync").set_defaults(func=sync_cmd) + p=sub.add_parser("weekly-review"); p.add_argument("--week"); p.add_argument("--force",action="store_true"); p.add_argument("--render",action="store_true"); p.add_argument("--no-sync",action="store_true"); p.set_defaults(func=weekly_review) + p=sub.add_parser("log"); p.add_argument("project"); p.add_argument("message"); p.set_defaults(func=log) + p=sub.add_parser("capture"); p.add_argument("text"); p.add_argument("--title"); p.add_argument("--project"); p.add_argument("--source"); p.set_defaults(func=capture) + p=sub.add_parser("new-daily"); p.add_argument("date",nargs="?"); p.add_argument("--force",action="store_true"); p.set_defaults(func=new_daily) + p=sub.add_parser("new-health"); p.add_argument("date",nargs="?"); p.add_argument("--force",action="store_true"); p.set_defaults(func=new_health) + p=sub.add_parser("new-note"); p.add_argument("project"); p.add_argument("type",choices=sorted(NOTE_TYPES)); p.add_argument("title"); p.add_argument("--date"); p.add_argument("--force",action="store_true"); p.set_defaults(func=new_note) + p=sub.add_parser("notes"); p.add_argument("--project"); p.add_argument("--kind"); p.add_argument("--limit",type=int,default=30); p.add_argument("--no-sync",action="store_true"); p.set_defaults(func=list_notes) + p=sub.add_parser("scan-vault"); p.add_argument("--include-private", action="store_true"); p.set_defaults(func=scan_vault_cmd) + + p=sub.add_parser("vault-context"); p.add_argument("--include-private", action="store_true"); p.set_defaults(func=vault_context_cmd) + p=sub.add_parser("get-note"); p.add_argument("path"); p.set_defaults(func=get_note_cmd) + p=sub.add_parser("search-notes"); p.add_argument("query"); p.add_argument("--limit", type=int, default=10); p.add_argument("--include-private", action="store_true"); p.set_defaults(func=search_notes_cmd) + p=sub.add_parser("preview"); p.add_argument("path"); p.add_argument("--limit", type=int, default=420); p.set_defaults(func=preview_cmd) + p=sub.add_parser("graph"); p.add_argument("--no-sync", action="store_true"); p.set_defaults(func=graph_cmd) + p=sub.add_parser("calendar"); p.add_argument("--no-sync", action="store_true"); p.set_defaults(func=calendar_cmd) + p=sub.add_parser("boards"); p.add_argument("--no-sync", action="store_true"); p.set_defaults(func=boards_cmd) + p=sub.add_parser("deadline-audit"); p.add_argument("--no-sync", action="store_true"); p.set_defaults(func=deadline_audit_cmd) + sub.add_parser("validate-schema").set_defaults(func=lambda args: timed_check_call("pm.validate_schema", [sys.executable, str(APP_ROOT/"scripts/validate_vault_schema.py"), "--root", str(ROOT)])) + sub.add_parser("duplicate-tasks").set_defaults(func=lambda args: timed_check_call("pm.duplicate_tasks", [sys.executable, str(APP_ROOT/"scripts/report_task_lifecycle_duplicates.py")])) + sub.add_parser("privacy-scan").set_defaults(func=lambda args: timed_check_call("pm.privacy_scan", [sys.executable, str(APP_ROOT/"scripts/privacy_scan.py"), "--root", str(ROOT), "--all-files"])) + p=sub.add_parser("qa"); p.set_defaults(func=qa_cmd) + p=sub.add_parser("ra"); p.add_argument("--no-sync", action="store_true"); p.set_defaults(func=ra_status_cmd) + p=sub.add_parser("plan") + p.add_argument("--date") + p.add_argument("--week", help="Week start as YYYY-MM-DD, or legacy manual block_week prefix such as YYYY-MM-") + p.add_argument("--start", default="09:00") + p.add_argument("--capacity-minutes", type=int, default=420) + p.add_argument("--weekdays", type=int, default=5) + p.add_argument("--horizon-days", type=int, default=14) + p.add_argument("--no-sync", action="store_true") + p.set_defaults(func=plan_cmd) + p=sub.add_parser("ai-plan-context") + p.add_argument("--date") + p.add_argument("--week", help="Week start as YYYY-MM-DD, or legacy manual block_week prefix such as YYYY-MM-") + p.add_argument("--start", default="09:00") + p.add_argument("--capacity-minutes", type=int, default=420) + p.add_argument("--weekdays", type=int, default=5) + p.add_argument("--horizon-days", type=int, default=14) + p.add_argument("--ad-hoc", default="") + p.add_argument("--include-private", action="store_true") + p.add_argument("--no-sync", action="store_true") + p.set_defaults(func=ai_plan_context_cmd) + args=ap.parse_args(); args.func(args) +if __name__=="__main__": main() diff --git a/scripts/privacy_scan.py b/scripts/privacy_scan.py index f7d9626..0c3c584 100644 --- a/scripts/privacy_scan.py +++ b/scripts/privacy_scan.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Fail when a public-source file appears to contain credentials or private residue.""" +"""Scan the public tree (and optionally Git history) for private residue.""" from __future__ import annotations import argparse @@ -13,40 +13,45 @@ ROOT = Path(__file__).resolve().parents[1] TEXT_SUFFIXES = { - "", - ".css", - ".html", - ".js", - ".json", - ".jsx", - ".md", - ".mjs", - ".py", - ".toml", - ".ts", - ".tsx", - ".txt", - ".yaml", - ".yml", + "", ".css", ".html", ".js", ".json", ".jsx", ".md", ".mjs", ".py", + ".toml", ".ts", ".tsx", ".txt", ".yaml", ".yml", } -SKIP_PARTS = {".git", ".venv", "node_modules", "dist", "vault", "__pycache__", ".pytest_cache"} -SKIP_FILES = {"package-lock.json"} +BINARY_DOCUMENT_SUFFIXES = { + ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".dta", + ".parquet", ".feather", ".rds", ".sqlite", ".sqlite3", ".db", +} +SKIP_PARTS = { + ".git", ".venv", "node_modules", "dist", "vault", "__pycache__", + ".pytest_cache", ".generated", "test-results", "playwright-report", + "private", "imports", "local_data", "large_data", "attachments", +} +SKIP_FILES = {"package-lock.json", "uv.lock"} +DISALLOWED_ROOTS = { + "projects", "tasks", "dates", "areas", "journal", "private", "attachments", + "dashboard", "data", ".generated", +} +ALLOWED_EMAIL_DOMAINS = {"example.com", "example.invalid"} +AUTHOR_PATHS = {"LICENSE", "pyproject.toml"} PATTERNS = { "private_key": re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----"), "github_token": re.compile(r"\b(?:github_pat_|gh[pousr]_)[A-Za-z0-9_]{20,}\b"), "api_key": re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b"), "aws_access_key": re.compile(r"\bAKIA[0-9A-Z]{16}\b"), - "credential_url": re.compile( + "tokenized_url": re.compile( r"https?://[^\s)>'\"]+[?&][^\s)>'\"]*(?:token|secret|signature|session|api_key|access_key)=", re.IGNORECASE, ), - "absolute_user_path": re.compile(r"(?:/Users/|C:\\Users\\)[A-Za-z0-9._ -]+"), - "personal_email": re.compile(r"\b[A-Z0-9._%+-]+@(?:gmail|outlook|yahoo|icloud)\.[A-Z]{2,}\b", re.IGNORECASE), + "gmail_url": re.compile( + r"https?://(?:mail\.google\.com|www\.overleaf\.com/project|dropbox\.com/(?:home|scl))/[^\s)>'\"]*", + re.IGNORECASE, + ), + "absolute_user_path": re.compile(r"(?:/Users/[^/\s]+|/home/[^/\s]+|C:\\Users\\[^\\\s]+)"), } +EMAIL_PATTERN = re.compile(r"\b[A-Z0-9._%+-]+@([A-Z0-9.-]+\.[A-Z]{2,})\b", re.IGNORECASE) -# Hashes let this repository guard against known private names and identifiers -# without publishing those strings in the scanner itself. +# Private names, organizations, project titles, identifiers, and path fragments +# are represented only by hashes so the denylist cannot disclose them. FORBIDDEN_TOKEN_HASHES = { "0a5d17d3b19f82f8340d3977609aa9e86b4ad8b9bd71bd9eced9271f1d5b2e4a", "0d58b31998267f61455706238ccec58e492e1c65a14c9722b9c4ea883be035c5", @@ -69,6 +74,36 @@ def token_hash(value: str) -> str: return hashlib.sha256(value.casefold().encode("utf-8")).hexdigest() +def path_hits(path: str) -> list[Hit]: + relative = Path(path) + hits: list[Hit] = [] + if relative.parts and relative.parts[0] in DISALLOWED_ROOTS: + hits.append(Hit(path, 0, "disallowed_public_root")) + if relative.suffix.lower() in BINARY_DOCUMENT_SUFFIXES: + hits.append(Hit(path, 0, "binary_document")) + return hits + + +def text_hits(path: str, text: str) -> list[Hit]: + if path == "scripts/privacy_scan.py": + return [] + hits: list[Hit] = [] + for line_number, line in enumerate(text.splitlines(), 1): + for rule, pattern in PATTERNS.items(): + if pattern.search(line): + hits.append(Hit(path, line_number, rule)) + for match in EMAIL_PATTERN.finditer(line): + if match.group(1).lower() not in ALLOWED_EMAIL_DOMAINS: + hits.append(Hit(path, line_number, "real_email_domain")) + tokens = re.findall(r"[A-Za-z0-9_.-]+", line) + candidates = tokens + [f"{left} {right}" for left, right in zip(tokens, tokens[1:])] + if path in AUTHOR_PATHS: + candidates = [value for value in candidates if value.casefold() not in {"simon", "fuchs", "simon fuchs"}] + if any(token_hash(value) in FORBIDDEN_TOKEN_HASHES for value in candidates): + hits.append(Hit(path, line_number, "private_denylist")) + return hits + + def files(root: Path, tracked_only: bool) -> Iterable[Path]: if tracked_only: result = subprocess.run( @@ -78,7 +113,7 @@ def files(root: Path, tracked_only: bool) -> Iterable[Path]: text=True, capture_output=True, ) - candidates = [root / item for item in result.stdout.splitlines()] + candidates: Iterable[Path] = (root / item for item in result.stdout.splitlines()) else: candidates = root.rglob("*") for path in candidates: @@ -87,8 +122,6 @@ def files(root: Path, tracked_only: bool) -> Iterable[Path]: relative = path.relative_to(root) if any(part in SKIP_PARTS for part in relative.parts): continue - if path.suffix.lower() not in TEXT_SUFFIXES: - continue yield path @@ -96,26 +129,69 @@ def scan(root: Path = ROOT, tracked_only: bool = True) -> list[Hit]: root = root.resolve() hits: list[Hit] = [] for path in files(root, tracked_only): - if path.resolve() == Path(__file__).resolve(): + relative = path.relative_to(root).as_posix() + hits.extend(path_hits(relative)) + if path.suffix.lower() not in TEXT_SUFFIXES: continue - text = path.read_text(encoding="utf-8", errors="replace") - for line_number, line in enumerate(text.splitlines(), 1): - for rule, pattern in PATTERNS.items(): - if pattern.search(line): - hits.append(Hit(path.relative_to(root).as_posix(), line_number, rule)) - tokens = re.findall(r"[A-Za-z0-9_.-]+", line) - pairs = tokens + [f"{left} {right}" for left, right in zip(tokens, tokens[1:])] - if any(token_hash(token) in FORBIDDEN_TOKEN_HASHES for token in pairs): - hits.append(Hit(path.relative_to(root).as_posix(), line_number, "private_residue")) - return hits + hits.extend(text_hits(relative, path.read_text(encoding="utf-8", errors="replace"))) + return sorted(set(hits), key=lambda hit: (hit.path, hit.line, hit.rule)) + + +def scan_root(root: Path = ROOT, tracked_only: bool = True) -> list[Hit]: + """Compatibility alias used by older callers and tests.""" + return scan(root, tracked_only) + + +def scan_history(root: Path = ROOT) -> list[Hit]: + root = root.resolve() + try: + commits = subprocess.run( + ["git", "rev-list", "--all"], + cwd=root, + check=True, + text=True, + capture_output=True, + ).stdout.splitlines() + except (OSError, subprocess.CalledProcessError): + return [] + + hits: list[Hit] = [] + seen_blobs: set[str] = set() + for commit in commits: + tree = subprocess.run( + ["git", "ls-tree", "-r", commit], + cwd=root, + check=True, + text=True, + capture_output=True, + ).stdout.splitlines() + for row in tree: + metadata, path = row.split("\t", 1) + blob = metadata.split()[2] + hits.extend(path_hits(path)) + suffix = Path(path).suffix.lower() + if blob in seen_blobs or suffix not in TEXT_SUFFIXES or Path(path).name in SKIP_FILES: + continue + seen_blobs.add(blob) + content = subprocess.run( + ["git", "cat-file", "-p", blob], + cwd=root, + check=True, + capture_output=True, + ).stdout.decode("utf-8", errors="replace") + hits.extend(text_hits(path, content)) + return sorted(set(hits), key=lambda hit: (hit.path, hit.line, hit.rule)) def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--root", type=Path, default=ROOT) parser.add_argument("--all-files", action="store_true") + parser.add_argument("--history", action="store_true") args = parser.parse_args() hits = scan(args.root, tracked_only=not args.all_files) + if args.history: + hits = sorted(set(hits + scan_history(args.root)), key=lambda hit: (hit.path, hit.line, hit.rule)) if hits: print("Privacy scan failed:") for hit in hits: diff --git a/scripts/render_boards.py b/scripts/render_boards.py new file mode 100644 index 0000000..170e0d9 --- /dev/null +++ b/scripts/render_boards.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python3 +from render_static import render_all + +if __name__ == "__main__": + render_all() diff --git a/scripts/render_calendar.py b/scripts/render_calendar.py new file mode 100644 index 0000000..170e0d9 --- /dev/null +++ b/scripts/render_calendar.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python3 +from render_static import render_all + +if __name__ == "__main__": + render_all() diff --git a/scripts/render_dashboard.py b/scripts/render_dashboard.py new file mode 100644 index 0000000..170e0d9 --- /dev/null +++ b/scripts/render_dashboard.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python3 +from render_static import render_all + +if __name__ == "__main__": + render_all() diff --git a/scripts/render_date.py b/scripts/render_date.py new file mode 100644 index 0000000..484a2ab --- /dev/null +++ b/scripts/render_date.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +"""Shared render clock for dashboard generation. + +Dashboards embed today-relative content (day offsets like "(+3d)", "today" +badges, week windows). For the committed dashboards to stay reproducible across +machines and CI, every renderer must agree on what "today" is. `date.today()` +returns the LOCAL date, so a render on a Pacific-time laptop and the GitHub +Actions render (which runs in UTC) disagree across the day boundary and the +`git diff --exit-code -- data dashboard` drift gate fails. + +Using the UTC date makes a local render and the CI render produce identical +output on the same calendar day. `PM_RENDER_DATE=YYYY-MM-DD` pins the date +explicitly when an exact, stable date is needed. +""" +from __future__ import annotations + +import os +from datetime import date, datetime, timezone + + +def render_today() -> date: + override = os.environ.get("PM_RENDER_DATE") + if override: + try: + return date.fromisoformat(override.strip()) + except ValueError: + pass + return datetime.now(timezone.utc).date() diff --git a/scripts/render_private_export.py b/scripts/render_private_export.py new file mode 100644 index 0000000..170e0d9 --- /dev/null +++ b/scripts/render_private_export.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python3 +from render_static import render_all + +if __name__ == "__main__": + render_all() diff --git a/scripts/render_static.py b/scripts/render_static.py new file mode 100644 index 0000000..7ef83eb --- /dev/null +++ b/scripts/render_static.py @@ -0,0 +1,255 @@ +"""Deterministic, metadata-driven static views for a Research Workbench vault.""" +from __future__ import annotations + +import html +import hashlib +import json +import os +import re +from collections import Counter, defaultdict +from pathlib import Path +from typing import Any + +try: + from scripts.workbench_paths import DASHBOARD_ROOT, DATA_ROOT +except ImportError: # pragma: no cover - direct script invocation + from workbench_paths import DASHBOARD_ROOT, DATA_ROOT + + +CSS = """ +:root{color-scheme:light dark;--bg:#f5f6f5;--panel:#fff;--ink:#202622;--muted:#68716c;--line:#d9ded9;--accent:#245a64} +*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.5 system-ui,sans-serif} +main{max-width:1180px;margin:auto;padding:24px}a{color:var(--accent)}nav{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px} +nav a,.pill{border:1px solid var(--line);border-radius:6px;background:var(--panel);padding:6px 9px;text-decoration:none} +.hero,.card{border:1px solid var(--line);border-radius:8px;background:var(--panel);padding:16px}.hero{margin-bottom:16px} +.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px}.stack{display:grid;gap:8px} +.item{border-top:1px solid var(--line);padding:9px 0}.item:first-child{border-top:0}.muted{color:var(--muted)} +.meta{display:flex;gap:7px;flex-wrap:wrap;color:var(--muted);font-size:12px}.private{border-left:3px solid #a66;padding-left:9px} +table{width:100%;border-collapse:collapse;background:var(--panel)}th,td{text-align:left;padding:8px;border-bottom:1px solid var(--line)} +@media(prefers-color-scheme:dark){:root{--bg:#151816;--panel:#1d221f;--ink:#eef2ef;--muted:#aab4ae;--line:#39423d;--accent:#8ac4cf}} +""" + +CLOSED = {"done", "complete", "completed", "cancelled", "canceled", "dropped", "archived", "archive"} + + +def esc(value: Any) -> str: + return html.escape(str(value if value is not None else "")) + + +def slug(value: Any) -> str: + return re.sub(r"[^a-z0-9]+", "-", str(value or "").lower()).strip("-") or "item" + + +def load(name: str) -> list[dict[str, Any]]: + path = DATA_ROOT / name + if not path.exists(): + return [] + parsed = json.loads(path.read_text(encoding="utf-8")) + return parsed if isinstance(parsed, list) else [] + + +def visible(items: list[dict[str, Any]], include_private: bool) -> list[dict[str, Any]]: + return items if include_private else [item for item in items if not bool(item.get("private"))] + + +def navigation(name: str) -> str: + prefix = "../" * (len(Path(name).parts) - 1) + return ( + f'' + ) + + +def write_page(name: str, title: str, body: str, *, private_warning: bool = True) -> None: + DASHBOARD_ROOT.mkdir(parents=True, exist_ok=True) + destination = DASHBOARD_ROOT / name + destination.parent.mkdir(parents=True, exist_ok=True) + warning = ( + '

Privacy: “private” is a display filter, not encryption.

' + if private_warning else "" + ) + stamp = os.environ.get("PM_RENDER_TIMESTAMP", "deterministic build") + document = ( + '' + f"{esc(title)} · Research Workbench
{navigation(name)}" + f'

{esc(title)}

{warning}

Generated: {esc(stamp)}

' + f"{body}
\n" + ) + destination.write_text(document, encoding="utf-8") + + +def item_html(item: dict[str, Any]) -> str: + private_class = " private" if item.get("private") else "" + date = item.get("due") or item.get("date") or item.get("deadline") or "" + return ( + f'
{esc(item.get("title") or item.get("name") or item.get("id"))}' + f'
{esc(item.get("kind") or item.get("type") or "entry")}' + f'{esc(item.get("project") or item.get("domain") or "other")}' + f'{esc(item.get("status") or "")}{esc(date)}' + f'{esc(item.get("path") or "")}
' + ) + + +def render_overview(projects: list[dict[str, Any]], tasks: list[dict[str, Any]], events: list[dict[str, Any]], notes: list[dict[str, Any]]) -> None: + open_tasks = [task for task in tasks if str(task.get("status") or "").lower() not in CLOSED] + domains = Counter(str(item.get("domain") or item.get("area") or "other") for item in projects + tasks + notes) + body = ( + '
' + f'
{len(projects)}

Projects

' + f'
{len(open_tasks)}

Open tasks

' + f'
{len(events)}

Dated events

' + f'
{len(notes)}

Notes

' + '

Domains

' + + "".join(f'
{esc(label)}

{count} entries

' for label, count in sorted(domains.items())) + + '

Open task queue

' + + "".join(item_html(item) for item in open_tasks[:30]) + + ("

No open tasks.

" if not open_tasks else "") + + "
" + ) + write_page("index.html", "Research Workbench", body) + + +def render_today(tasks: list[dict[str, Any]], events: list[dict[str, Any]]) -> None: + current = [item for item in tasks if str(item.get("status") or "").lower() not in CLOSED] + current.sort(key=lambda item: (int(item.get("priority") or 99), str(item.get("due") or "9999-12-31"))) + dated = sorted(events, key=lambda item: str(item.get("date") or "9999-12-31")) + body = '

Focus

' + "".join(item_html(item) for item in current[:10]) + body += '

Upcoming

' + "".join(item_html(item) for item in dated[:10]) + "
" + write_page("today.html", "Today", body) + + +def render_calendar(tasks: list[dict[str, Any]], events: list[dict[str, Any]]) -> None: + rows = [item for item in tasks + events if item.get("due") or item.get("date") or item.get("start_date")] + rows.sort(key=lambda item: str(item.get("due") or item.get("date") or item.get("start_date"))) + body = '
' + for item in rows: + body += ( + f'' + f'' + f'' + ) + body += "
DateItemKindProject
{esc(item.get("due") or item.get("date") or item.get("start_date"))}{esc(item.get("title"))}{esc(item.get("kind") or item.get("type"))}{esc(item.get("project"))}
" + write_page("calendar.html", "Calendar", body) + + +def render_boards(tasks: list[dict[str, Any]]) -> None: + lanes: dict[str, list[dict[str, Any]]] = defaultdict(list) + for task in tasks: + status = str(task.get("status") or "open").lower() + lane = "done" if status in CLOSED else "waiting" if status in {"waiting", "blocked"} else "active" + lanes[lane].append(task) + body = '
' + for lane in ("active", "waiting", "done"): + body += f'

{lane.title()}

' + "".join(item_html(item) for item in lanes[lane]) + "
" + body += "
" + write_page("boards.html", "Boards", body) + + +def render_projects(projects: list[dict[str, Any]], tasks: list[dict[str, Any]]) -> None: + project_dir = DASHBOARD_ROOT / "projects" + project_dir.mkdir(parents=True, exist_ok=True) + cards: list[str] = [] + for project in projects: + project_id = str(project.get("id") or project.get("project") or slug(project.get("name"))) + related = [task for task in tasks if str(task.get("project") or "") == project_id] + cards.append( + f'' + ) + detail = '

Project metadata

' + item_html(project) + '

Tasks

' + detail += "".join(item_html(item) for item in related) or '

No linked tasks.

' + detail += "
" + write_page(f"projects/{slug(project_id)}.html", str(project.get("name") or project_id), detail) + write_page("status.html", "Projects", '
' + "".join(cards) + "
") + + +def render_collaborators(notes: list[dict[str, Any]], tasks: list[dict[str, Any]]) -> None: + safe_notes = visible(notes, False) + safe_tasks = visible(tasks, False) + people = [ + note + for note in safe_notes + if str(note.get("kind") or "").lower() == "collaborator-status" + ] + cards: list[str] = [] + for person in people: + name = str(person.get("assignee") or person.get("title") or person.get("id") or "Collaborator") + person_slug = slug(person.get("id") or name) + related_notes = [ + note + for note in safe_notes + if str(note.get("assignee") or "").casefold() == name.casefold() + ] + related_tasks = [ + task + for task in safe_tasks + if str(task.get("assignee") or "").casefold() == name.casefold() + ] + cards.append( + f'

{esc(name)}

' + f'

{len(related_tasks)} tasks · {len(related_notes)} notes

' + ) + body = '

Tasks

' + body += "".join(item_html(item) for item in related_tasks) or '

No linked tasks.

' + body += '

Notes

' + body += "".join(item_html(item) for item in related_notes) or '

No linked notes.

' + body += "
" + write_page(f"collaborators/{person_slug}.html", name, body) + empty = '

No public-safe collaborator status notes.

' if not cards else "" + write_page("collaborators/index.html", "Collaborators", '
' + "".join(cards) + empty + "
") + + +def render_graph(projects: list[dict[str, Any]], tasks: list[dict[str, Any]], notes: list[dict[str, Any]]) -> None: + lines = ["graph TD"] + for project in projects: + project_id = str(project.get("id") or project.get("project") or slug(project.get("name"))) + project_node = f"p_{slug(project_id).replace('-', '_')}" + lines.append(f' {project_node}["{str(project.get("name") or project_id).replace(chr(34), chr(39))}"]') + for item in tasks + notes: + if str(item.get("project") or "") != project_id: + continue + digest = hashlib.sha256(str(item.get("path") or "").encode("utf-8")).hexdigest()[:12] + item_node = f"n_{digest}" + lines.append(f' {project_node} --> {item_node}["{str(item.get("title") or item.get("path")).replace(chr(34), chr(39))}"]') + body = '

Mermaid source for the project-to-entry graph.

' + esc("\n".join(lines)) + "
" + write_page("graph.html", "Graph", body) + + +def render_public(projects: list[dict[str, Any]], tasks: list[dict[str, Any]], events: list[dict[str, Any]], notes: list[dict[str, Any]]) -> None: + safe_projects = visible(projects, False) + safe_tasks = visible(tasks, False) + safe_events = visible(events, False) + safe_notes = visible(notes, False) + body = ( + '

This export includes only entries without private: true. ' + 'That field is a display filter, not encryption; review before sharing.

' + '

Projects

' + "".join(item_html(item) for item in safe_projects) + + '

Tasks and dates

' + + "".join(item_html(item) for item in safe_tasks + safe_events) + + '

Notes

' + + "".join(item_html(item) for item in safe_notes) + + "
" + ) + write_page("public.html", "Public-safe display", body, private_warning=False) + + +def render_all() -> None: + projects = load("projects.json") + tasks = load("tasks.json") + events = load("events.json") + notes = load("notes.json") + render_overview(projects, tasks, events, notes) + render_today(tasks, events) + render_calendar(tasks, events) + render_boards(tasks) + render_projects(projects, tasks) + render_collaborators(notes, tasks) + render_graph(projects, tasks, notes) + render_public(projects, tasks, events, notes) + print(f"Rendered static views to {DASHBOARD_ROOT}") + + +if __name__ == "__main__": + render_all() diff --git a/scripts/render_status_index.py b/scripts/render_status_index.py new file mode 100644 index 0000000..170e0d9 --- /dev/null +++ b/scripts/render_status_index.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python3 +from render_static import render_all + +if __name__ == "__main__": + render_all() diff --git a/scripts/render_today.py b/scripts/render_today.py new file mode 100644 index 0000000..170e0d9 --- /dev/null +++ b/scripts/render_today.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python3 +from render_static import render_all + +if __name__ == "__main__": + render_all() diff --git a/scripts/report_task_lifecycle_duplicates.py b/scripts/report_task_lifecycle_duplicates.py new file mode 100644 index 0000000..af3da70 --- /dev/null +++ b/scripts/report_task_lifecycle_duplicates.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Report task IDs that appear in multiple lifecycle folders.""" +from __future__ import annotations + +import argparse +import json +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any + +try: + from markdown_reader import split_frontmatter +except ModuleNotFoundError: + from scripts.markdown_reader import split_frontmatter + + +try: + from scripts.workbench_paths import VAULT_ROOT +except ImportError: # pragma: no cover + from workbench_paths import VAULT_ROOT + +ROOT = VAULT_ROOT +LIFECYCLE_DIRS = {"active", "waiting", "done", "cancelled", "someday"} + + +@dataclass(frozen=True) +class DuplicateTaskId: + task_id: str + folders: list[str] + paths: list[str] + + +def scalar(value: Any) -> str: + return str(value or "").strip().strip('"').strip("'") + + +def task_id_for(path: Path, frontmatter: dict[str, Any]) -> str: + return scalar(frontmatter.get("id")) or path.stem + + +def lifecycle_folder(path: Path) -> str: + parts = path.parts + if len(parts) >= 3 and parts[-3] == "tasks": + return parts[-2] + return "" + + +def duplicate_task_ids(root: Path = ROOT) -> list[DuplicateTaskId]: + root = root.resolve() + by_id: dict[str, list[Path]] = {} + for path in sorted((root / "tasks").glob("*/*.md")): + if path.name.lower() == "readme.md": + continue + folder = path.parent.name + if folder not in LIFECYCLE_DIRS: + continue + frontmatter, _body = split_frontmatter(path.read_text(encoding="utf-8", errors="replace")) + by_id.setdefault(task_id_for(path, frontmatter), []).append(path) + + duplicates: list[DuplicateTaskId] = [] + for task_id, paths in sorted(by_id.items()): + folders = sorted({path.parent.name for path in paths}) + if len(folders) <= 1: + continue + duplicates.append( + DuplicateTaskId( + task_id=task_id, + folders=folders, + paths=[path.relative_to(root).as_posix() for path in paths], + ) + ) + return duplicates + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=ROOT, help="Vault root to inspect.") + parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON.") + args = parser.parse_args() + + duplicates = duplicate_task_ids(args.root) + if args.json: + print(json.dumps([asdict(item) for item in duplicates], indent=2)) + return 0 + + if not duplicates: + print("No duplicate task IDs across lifecycle folders.") + return 0 + + print(f"Duplicate task IDs across lifecycle folders: {len(duplicates)}") + for item in duplicates: + print(f"- {item.task_id} [{', '.join(item.folders)}]") + for path in item.paths: + print(f" - {path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/seed_render_vault.py b/scripts/seed_render_vault.py new file mode 100755 index 0000000..614c69d --- /dev/null +++ b/scripts/seed_render_vault.py @@ -0,0 +1,425 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import shutil +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import yaml + + +VAULT_DIRS = { + "_inbox", + "archive", + "areas", + "codex", + "dates", + "docs", + "journal", + "notes", + "projects", + "sources", + "tasks", + "templates", + "visuals", +} + +ROOT_FILES = { + "AGENTS.md", + "CLAUDE.md", + "GEMINI.md", + "LICENSE", + "OPENCLAW.md", + "README.md", + "START_HERE.md", +} + +ROOT_EXTENSIONS = {".md", ".markdown", ".ics"} + +EXCLUDED_PARTS = { + ".backups", + ".git", + ".github", + ".venv", + ".pytest_cache", + "__pycache__", + "node_modules", + "dist", + "dashboard", + "data", + "private", + "imports", + "local_data", + "large_data", + "vault", +} + +EXCLUDED_SUFFIXES = {".sqlite", ".db", ".dta", ".parquet", ".feather", ".rds", ".pyc"} +MANIFEST_NAME = ".vault_seed_manifest.json" +# reset-from-seed snapshots the hosted vault before each deploy; keep only the +# newest few so the fixed persistent disk cannot fill up over many deploys. +BACKUP_RETENTION = 5 +FORCE_SYNC_CONFIG = Path("config/render_vault_force_sync.json") +TASK_LIFECYCLE_DIRS = ("active", "waiting", "someday", "done", "cancelled") + + +def repo_root() -> Path: + return Path(__file__).resolve().parents[1] + + +def configured_path(name: str, default: Path | None = None) -> Path | None: + raw = os.environ.get(name) + if not raw: + return default + return Path(raw).expanduser().resolve() + + +def should_seed(source: Path, target: Path) -> bool: + try: + target.relative_to(source) + except ValueError: + return True + return target != source + + +def included(rel: Path) -> bool: + parts = rel.parts + if not parts or any(part in EXCLUDED_PARTS for part in parts): + return False + if rel.name == MANIFEST_NAME: + return False + if rel.suffix.lower() in EXCLUDED_SUFFIXES: + return False + if len(parts) == 1: + return rel.name in ROOT_FILES or rel.suffix.lower() in ROOT_EXTENSIONS + return parts[0] in VAULT_DIRS + + +def vault_manifest_path(target: Path) -> Path: + return target / MANIFEST_NAME + + +def app_commit() -> str | None: + return os.environ.get("RENDER_GIT_COMMIT") or os.environ.get("GIT_COMMIT") or os.environ.get("SOURCE_VERSION") + + +def file_hash(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def included_files(root: Path) -> dict[str, Path]: + files: dict[str, Path] = {} + if not root.exists(): + return files + for path in root.rglob("*"): + if path.is_dir(): + continue + rel = path.relative_to(root) + if included(rel): + files[rel.as_posix()] = path + return files + + +def parse_force_sync_paths(raw: str) -> list[str]: + raw = raw.strip() + if not raw: + return [] + try: + value = json.loads(raw) + except json.JSONDecodeError: + value = [part.strip() for part in raw.replace("\n", ",").split(",")] + if isinstance(value, dict): + value = value.get("paths", []) + if not isinstance(value, list): + raise ValueError("PM_VAULT_FORCE_SYNC_PATHS must be a JSON list, JSON object with paths, or comma-separated paths") + paths: list[str] = [] + for item in value: + text = str(item).strip().strip("/") + if not text: + continue + rel = Path(text) + if rel.is_absolute() or ".." in rel.parts: + raise ValueError(f"Invalid force-sync path: {item}") + paths.append(rel.as_posix()) + return sorted(dict.fromkeys(paths)) + + +def force_sync_paths_from_config(source: Path) -> list[str]: + raw = os.environ.get("PM_VAULT_FORCE_SYNC_PATHS") + if raw: + return parse_force_sync_paths(raw) + config_path = source / FORCE_SYNC_CONFIG + if not config_path.exists(): + return [] + payload = json.loads(config_path.read_text(encoding="utf-8")) + return parse_force_sync_paths(json.dumps(payload)) + + +def markdown_frontmatter(path: Path) -> dict[str, Any]: + try: + raw = path.read_text(encoding="utf-8", errors="replace") + except OSError: + return {} + if not raw.startswith("---"): + return {} + parts = raw.split("---", 2) + if len(parts) < 3: + return {} + parsed = yaml.safe_load(parts[1]) or {} + return parsed if isinstance(parsed, dict) else {} + + +def task_id(path: Path) -> str | None: + metadata = markdown_frontmatter(path) + value = metadata.get("id") + if value is None: + return None + text = str(value).strip() + return text or None + + +def stale_task_lifecycle_paths(target: Path, rel: str, task_identifier: str) -> list[Path]: + destination = target / rel + stale: list[Path] = [] + for lifecycle in TASK_LIFECYCLE_DIRS: + folder = target / "tasks" / lifecycle + if not folder.exists(): + continue + for path in folder.glob("*.md"): + if path == destination: + continue + if task_id(path) == task_identifier: + stale.append(path) + return stale + + +def force_sync_paths(source: Path, target: Path, rel_paths: list[str]) -> dict[str, int]: + copied = 0 + removed_stale = 0 + missing = 0 + skipped = 0 + for rel in rel_paths: + source_path = source / rel + if not source_path.exists() or not source_path.is_file() or not included(Path(rel)): + missing += 1 + continue + destination = target / rel + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source_path, destination) + copied += 1 + parts = Path(rel).parts + if len(parts) == 3 and parts[0] == "tasks" and parts[1] in TASK_LIFECYCLE_DIRS: + identifier = task_id(source_path) + if identifier: + for stale_path in stale_task_lifecycle_paths(target, rel, identifier): + stale_path.unlink() + removed_stale += 1 + else: + skipped += 1 + return { + "force_synced": copied, + "force_removed_stale": removed_stale, + "force_missing": missing, + "force_skipped": skipped, + } + + +def calculate_drift(source: Path, target: Path, *, include_paths: bool = False) -> dict[str, Any]: + source_files = included_files(source) + target_files = included_files(target) + missing = sorted(set(source_files) - set(target_files)) + extra = sorted(set(target_files) - set(source_files)) + common = sorted(set(source_files) & set(target_files)) + changed = [ + rel + for rel in common + if source_files[rel].stat().st_size != target_files[rel].stat().st_size + or file_hash(source_files[rel]) != file_hash(target_files[rel]) + ] + same = [rel for rel in common if rel not in set(changed)] + payload: dict[str, Any] = { + "counts": { + "source_files": len(source_files), + "target_files": len(target_files), + "missing": len(missing), + "changed": len(changed), + "extra": len(extra), + "same": len(same), + "skipped_existing": len(common), + "skipped": len(common), + } + } + if include_paths: + payload["paths"] = { + "missing": missing, + "changed": sorted(changed), + "extra": extra, + "same": same, + } + return payload + + +def write_manifest(target: Path, *, source: Path, mode: str, counts: dict[str, Any], backup_path: Path | None = None) -> None: + manifest = { + "generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), + "mode": mode, + "seed_source": str(source), + "vault_root": str(target), + "app_commit": app_commit(), + "counts": counts, + "backup_path": str(backup_path) if backup_path else None, + } + target.mkdir(parents=True, exist_ok=True) + vault_manifest_path(target).write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def copy_missing(source: Path, target: Path) -> dict[str, int]: + copied = 0 + skipped = 0 + target.mkdir(parents=True, exist_ok=True) + for rel, path in included_files(source).items(): + destination = target / rel + if destination.exists(): + skipped += 1 + continue + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(path, destination) + copied += 1 + return {"copied": copied, "skipped": skipped} + + +def prune_backups(target: Path, keep: int) -> list[Path]: + """Delete the oldest vault snapshots under .backups, retaining the newest ``keep``. + + Snapshot dir names are timestamped (``vault-YYYYMMDDTHHMMSSZ``), so a lexical + sort is chronological. Pruning runs *before* a new snapshot is written so a + near-full persistent disk frees space and the deploy self-heals instead of + failing partway through the copy. + """ + backups_root = target / ".backups" + if keep < 0 or not backups_root.exists(): + return [] + snapshots = sorted( + path for path in backups_root.iterdir() + if path.is_dir() and path.name.startswith("vault-") + ) + removed: list[Path] = [] + while len(snapshots) > keep: + oldest = snapshots.pop(0) + shutil.rmtree(oldest, ignore_errors=True) + removed.append(oldest) + return removed + + +def backup_vault(target: Path) -> Path: + # Retain only the newest BACKUP_RETENTION snapshots. Prune first (leaving room + # for the one about to be written) so the fixed persistent disk cannot fill up + # over many deploys and fail the reset-from-seed step. + prune_backups(target, keep=max(0, BACKUP_RETENTION - 1)) + timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + backup_root = target / ".backups" / f"vault-{timestamp}" + backup_root.parent.mkdir(parents=True, exist_ok=True) + for path in (target.iterdir() if target.exists() else []): + # Only archive canonical vault content. Skipping EXCLUDED_PARTS (.backups, + # .git and the GitHub-sync working tree, caches, build output, generated + # data, etc.) keeps each snapshot small; previously these inflated every + # backup and were the main driver of disk growth. + if path.name in EXCLUDED_PARTS: + continue + destination = backup_root / path.name + if path.is_dir(): + shutil.copytree(path, destination) + else: + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(path, destination) + return backup_root + + +def reset_from_seed(source: Path, target: Path, *, backup: bool) -> dict[str, Any]: + if not backup: + raise SystemExit("reset-from-seed requires --backup so hosted vault contents are archived first.") + backup_path = backup_vault(target) + for rel in included_files(target): + path = target / rel + if path.exists(): + path.unlink() + for rel, path in included_files(source).items(): + destination = target / rel + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(path, destination) + return {"restored": len(included_files(source)), "backup_path": backup_path} + + +def seed_vault(source: Path, target: Path) -> tuple[int, int]: + result = copy_missing(source, target) + return result["copied"], result["skipped"] + + +def main() -> int: + parser = argparse.ArgumentParser(description="Seed or compare a Render persistent Markdown vault.") + # The deploy startup runs this with no args, so PM_VAULT_SEED_MODE lets the + # hosted service pick the mode without changing the Docker command. Set it to + # "reset-from-seed" to make every deploy realign the persistent vault with the + # deployed commit (== GitHub main at build time), backing up first. + env_mode = os.environ.get("PM_VAULT_SEED_MODE", "copy-missing").strip() or "copy-missing" + if env_mode not in {"dry-run", "copy-missing", "reset-from-seed"}: + env_mode = "copy-missing" + parser.add_argument("--mode", choices=["dry-run", "copy-missing", "reset-from-seed"], default=env_mode) + parser.add_argument("--backup", action="store_true", help="Required for reset-from-seed.") + parser.add_argument("--include-paths", action="store_true", help="Print drift paths as well as counts.") + args = parser.parse_args() + # reset-from-seed is destructive, so always snapshot first when it runs. + backup = args.backup or args.mode == "reset-from-seed" + + source = configured_path("PM_VAULT_SEED_SOURCE", repo_root()) + target = configured_path("PM_VAULT_ROOT") + if target is None: + print("PM_VAULT_ROOT is not set; skipping vault seed.") + return 0 + if source is None or not source.exists(): + raise SystemExit(f"Seed source does not exist: {source}") + if not should_seed(source, target): + print("PM_VAULT_ROOT points at the source tree; skipping vault seed.") + return 0 + + if args.mode == "dry-run": + drift = calculate_drift(source, target, include_paths=args.include_paths) + print(json.dumps(drift, indent=2, sort_keys=True)) + return 0 + + if args.mode == "copy-missing": + result = copy_missing(source, target) + force_result = force_sync_paths(source, target, force_sync_paths_from_config(source)) + drift = calculate_drift(source, target, include_paths=False) + write_manifest(target, source=source, mode=args.mode, counts={**result, **force_result, **drift["counts"]}) + print( + f"Seeded Render vault at {target}: copied {result['copied']}, skipped {result['skipped']}, " + f"force-synced {force_result['force_synced']}." + ) + return 0 + + result = reset_from_seed(source, target, backup=backup) + drift = calculate_drift(source, target, include_paths=False) + write_manifest( + target, + source=source, + mode=args.mode, + counts={**{key: value for key, value in result.items() if key != "backup_path"}, **drift["counts"]}, + backup_path=result["backup_path"], + ) + print(f"Reset Render vault at {target}: restored {result['restored']} file(s).") + print(f"Backup: {result['backup_path']}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/sync_markdown.py b/scripts/sync_markdown.py new file mode 100755 index 0000000..c2f7dc5 --- /dev/null +++ b/scripts/sync_markdown.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Dict, List + +try: + import yaml # type: ignore +except Exception as exc: + raise SystemExit("PyYAML is required. Install with: python3 -m pip install pyyaml") from exc + +try: + from scripts.workbench_paths import DATA_ROOT, VAULT_ROOT +except ImportError: # pragma: no cover + from workbench_paths import DATA_ROOT, VAULT_ROOT + +ROOT = VAULT_ROOT +DATA = DATA_ROOT +CORE_NAMES = {"README.md", "progress.md", "next.md", "scratch.md", "linked_sources.md", "decision_log.md", "questions.md"} +EVENT_KINDS = {"event", "calendar", "conference", "seminar", "workshop", "travel"} + + +def json_safe(value: Any) -> Any: + if isinstance(value, dict): + return {str(k): json_safe(v) for k, v in value.items()} + if isinstance(value, (list, tuple, set)): + return [json_safe(v) for v in value] + if hasattr(value, "isoformat"): + return value.isoformat() + return value + + +def read_fm(path: Path) -> Dict[str, Any] | None: + text = path.read_text(encoding="utf-8", errors="replace") + if not text.startswith("---"): + return None + lines = text.splitlines(keepends=True) + if not lines or lines[0].strip() != "---": + return None + for i in range(1, len(lines)): + if lines[i].strip() == "---": + try: + data = yaml.safe_load("".join(lines[1:i])) or {} + except Exception: + data = {} + if isinstance(data, dict): + return {str(k): json_safe(v) for k, v in data.items()} + return {} + return None + + +def rel(path: Path) -> str: + return path.relative_to(ROOT).as_posix() + + +def truthy(v: Any) -> bool: + return bool(v) and str(v).lower() not in {"false", "0", "no", "none"} + + +def projects() -> List[Dict[str, Any]]: + out = [] + for path in list((ROOT / "projects").glob("*/README.md")) + list((ROOT / "areas").glob("*/README.md")): + fm = read_fm(path) + if not fm or fm.get("kind") != "project" or str(fm.get("dashboard", "true")).lower() == "false": + continue + out.append({ + "id": fm.get("id") or path.parent.name, + "name": fm.get("title") or path.parent.name, + "area": fm.get("area", ""), + "domain": fm.get("domain", ""), + "status": fm.get("status", "active"), + "priority": fm.get("priority", 9), + "energy": fm.get("energy", ""), + "lead": fm.get("lead", ""), + "deadline": fm.get("deadline", ""), + "deadline_type": fm.get("deadline_type", ""), + "last_touched": fm.get("last_touched", ""), + "next_action": fm.get("next_action", ""), + "private": truthy(fm.get("private")), + "public_export_level": fm.get("public_export_level") or fm.get("export_level") or "", + "path": rel(path), + "sources": fm.get("sources", []) if isinstance(fm.get("sources", []), list) else [], + }) + return sorted(out, key=lambda p: (p.get("priority", 9), p.get("deadline") or "9999-12-31", p.get("name", ""))) + + +def tasks() -> List[Dict[str, Any]]: + out = [] + for path in (ROOT / "tasks").glob("**/*.md"): + if path.name == "README.md": + continue + fm = read_fm(path) + kind = str((fm or {}).get("kind") or "").lower() + if not fm or not (kind == "task" or kind.endswith("-task")): + continue + out.append({ + "id": fm.get("id") or path.stem, + "project": fm.get("project", ""), + "domain": fm.get("domain", ""), + "area": fm.get("area", ""), + "title": fm.get("title") or path.stem, + "status": fm.get("status", "open"), + "priority": fm.get("priority", 9), + "due": fm.get("due", ""), + "deadline_type": fm.get("deadline_type", ""), + "assignee": fm.get("assignee", fm.get("assigned_to", fm.get("owner", ""))), + "next": fm.get("next", ""), + "estimate_minutes": fm.get("estimate_minutes", fm.get("duration_minutes", 60)), + "energy": fm.get("energy", ""), + "time_block": fm.get("time_block", ""), + "block_day": fm.get("block_day", ""), + "block_week": fm.get("block_week", ""), + "private": truthy(fm.get("private")), + "path": rel(path), + }) + return sorted(out, key=lambda t: (t.get("status") == "done", t.get("priority", 9), t.get("due") or "9999-12-31", t.get("title", ""))) + + +def events() -> List[Dict[str, Any]]: + out = [] + for path in (ROOT / "dates").glob("*.md"): + if path.name == "README.md": + continue + fm = read_fm(path) + kind = str((fm or {}).get("kind") or (fm or {}).get("type") or "").lower() + if not fm or kind not in EVENT_KINDS: + continue + out.append({ + "date": fm.get("date", ""), + "title": fm.get("title") or path.stem, + "project": fm.get("project", ""), + "domain": fm.get("domain", ""), + "area": fm.get("area", ""), + "type": fm.get("type") or kind, + "kind": kind, + "end_date": fm.get("end_date", ""), + "module": fm.get("module", ""), + "trip_key": fm.get("trip_key", ""), + "private": truthy(fm.get("private")), + "path": rel(path), + }) + return sorted(out, key=lambda e: (e.get("date") or "9999-12-31", e.get("title", ""))) + + +def notes() -> List[Dict[str, Any]]: + out = [] + for base in [ROOT / "projects", ROOT / "areas", ROOT / "journal", ROOT / "notes", ROOT / "_inbox"]: + if not base.exists(): + continue + for path in base.glob("**/*.md"): + if path.name in CORE_NAMES or "archive" in path.parts: + continue + fm = read_fm(path) or {} + kind = str(fm.get("kind") or "").lower() + if not kind or kind == "project" or kind == "task" or kind.endswith("-task") or kind in EVENT_KINDS: + continue + try: + rel_parts = path.relative_to(base).parts + inferred_project = rel_parts[0] if len(rel_parts) > 1 and base.name in {"projects", "areas"} else "" + except Exception: + inferred_project = "" + out.append({ + "id": fm.get("id") or path.stem, + "kind": kind, + "project": fm.get("project") or inferred_project, + "domain": fm.get("domain", ""), + "area": fm.get("area", ""), + "module": fm.get("module", ""), + "assignee": fm.get("assignee", fm.get("assigned_to", fm.get("owner", ""))), + "title": fm.get("title") or path.stem, + "date": fm.get("date") or fm.get("week") or "", + "status": fm.get("status") or "", + "private": truthy(fm.get("private")), + "path": rel(path), + }) + return sorted(out, key=lambda n: (n.get("date") or "0000", n.get("project", ""), n.get("title", "")), reverse=True) + + +def main() -> None: + DATA.mkdir(parents=True, exist_ok=True) + ps, ts, es, ns = projects(), tasks(), events(), notes() + (DATA / "projects.json").write_text(json.dumps(ps, indent=2, ensure_ascii=False), encoding="utf-8") + (DATA / "tasks.json").write_text(json.dumps(ts, indent=2, ensure_ascii=False), encoding="utf-8") + (DATA / "events.json").write_text(json.dumps(es, indent=2, ensure_ascii=False), encoding="utf-8") + (DATA / "notes.json").write_text(json.dumps(ns, indent=2, ensure_ascii=False), encoding="utf-8") + # Tolaria-style full-vault Markdown cache. + import markdown_reader + markdown_reader.write_cache(include_private=True) + print(f"Synced {len(ps)} projects, {len(ts)} tasks, {len(es)} dates/events, {len(ns)} notes from Markdown.") + print("Updated Tolaria-style cache: data/vault_entries.json and data/relationships.json.") + + +if __name__ == "__main__": + main() diff --git a/scripts/time_blocks.py b/scripts/time_blocks.py new file mode 100644 index 0000000..0570304 --- /dev/null +++ b/scripts/time_blocks.py @@ -0,0 +1,796 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date, timedelta +from typing import Any + + +DONE_STATUSES = { + "done", + "complete", + "completed", + "cancelled", + "canceled", + "archive", + "archived", +} + +UNSCHEDULABLE_STATUSES = { + "waiting", + "blocked", + "pending", + "follow-up", + "followup", +} + +ADMIN_BATCH_MAX_MINUTES = 90 +SOFT_DEADLINE_HORIZON_DAYS = 3 +SOFT_OVERDUE_ESCALATION_DAYS = 7 + + +@dataclass(frozen=True) +class ScheduledTask: + task: dict[str, Any] + day: date + start: str + end: str + minutes: int + reason: str + segment_index: int = 1 + segment_count: int = 1 + + +def parse_date(value: Any) -> date | None: + if value is None or not str(value).strip(): + return None + try: + return date.fromisoformat(str(value)) + except Exception: + return None + + +def parse_minutes(value: Any, default: int = 60) -> int: + try: + minutes = int(value) + except Exception: + return default + return minutes if minutes > 0 else default + + +def parse_clock(value: str) -> int: + raw = value.strip() + hour, minute = raw.split(":", 1) + return int(hour) * 60 + int(minute) + + +def format_clock(minutes: int) -> str: + return f"{minutes // 60:02d}:{minutes % 60:02d}" + + +def priority_value(task: dict[str, Any]) -> int: + try: + return int(task.get("priority", 9)) + except Exception: + return 9 + + +def is_open_task(task: dict[str, Any]) -> bool: + status = str(task.get("status", "open")).strip().lower() + return status not in DONE_STATUSES + + +def is_deferred_task(task: dict[str, Any]) -> bool: + status = str(task.get("status", "open")).strip().lower() + path = str(task.get("path", "")).lower() + return status in {"someday", "later", "parked", "paused"} or "/someday/" in path or priority_value(task) >= 4 + + +def is_schedulable_task(task: dict[str, Any], target_day: date) -> bool: + if not is_open_task(task): + return False + if str(task.get("block_day", "")) == target_day.isoformat(): + return True + status = str(task.get("status", "open")).strip().lower() + policy = normalized_schedule_policy(task) + policy_schedules = policy in {"schedule-today", "today", "research-anchor", "anchor", "admin-edge", "admin-batch", "edge"} + if is_deferred_task(task) and not is_manual_urgent(task) and not policy_schedules: + return False + return status not in UNSCHEDULABLE_STATUSES + + +def task_identity(task: dict[str, Any]) -> str: + return str(task.get("path") or task.get("id") or task.get("title") or id(task)) + + +def is_calendar_event(task: dict[str, Any]) -> bool: + return str(task.get("kind") or "") == "calendar" + + +def urgent_status(task: dict[str, Any]) -> bool: + status = str(task.get("status", "")).lower() + project = str(task.get("project", "")).lower() + return "urgent" in status or "urgent" in project + + +def is_manual_urgent(task: dict[str, Any]) -> bool: + values = (task.get("urgent"), task.get("focus_manual")) + if any(value is True for value in values): + return True + if any(str(value or "").strip().lower() in {"true", "1", "yes", "urgent", "focus"} for value in values): + return True + return focus_rank_value(task) is not None + + +def focus_rank_value(task: dict[str, Any]) -> int | None: + value = task.get("focus_rank") + if value in (None, ""): + return None + try: + return int(value) + except Exception: + return None + + +def deadline_type(task: dict[str, Any]) -> str: + normalized = str(task.get("deadline_type") or "").strip().lower() + return normalized if normalized in {"hard", "soft"} else "soft" + + +def is_soft_deadline(task: dict[str, Any]) -> bool: + return deadline_type(task) == "soft" + + +def normalized_category(value: Any) -> str: + return str(value or "").strip().lower().replace("_", "-") + + +def normalized_schedule_policy(task: dict[str, Any]) -> str: + return normalized_category(task.get("schedule_policy")) + + +def task_time_category(task: dict[str, Any]) -> str: + explicit = normalized_category(task.get("time_category")) + if explicit in {"research", "admin", "other"}: + return explicit + policy = normalized_schedule_policy(task) + if policy in {"research-anchor", "anchor"}: + return "research" + if policy in {"admin-edge", "admin-batch", "edge"}: + return "admin" + raw = normalized_category(task.get("domain") or task.get("area")) + if raw == "research": + return "research" + if raw == "admin": + return "admin" + if raw in {"other", "personal", "life-admin", "service", "teaching", "data", "system", "archive"}: + return "other" + # Preserve legacy/test payload behavior until the API enriches real tasks. + return "research" + + +def is_research_task(task: dict[str, Any]) -> bool: + if is_calendar_event(task): + return False + return task_time_category(task) == "research" + + +def is_admin_like_task(task: dict[str, Any]) -> bool: + if is_calendar_event(task): + return False + return not is_research_task(task) + + +def is_manual_day_task(task: dict[str, Any], target_day: date) -> bool: + return str(task.get("block_day", "")) == target_day.isoformat() + + +def is_due_urgent_admin(task: dict[str, Any], target_day: date) -> bool: + due = parse_date(task.get("due")) + return is_manual_urgent(task) or priority_value(task) <= 1 or (due is not None and due <= target_day) + + +def with_overflow_reason(task: dict[str, Any], reason: str) -> dict[str, Any]: + return {**task, "_overflow_reason": reason} + + +def largest_research_block_minutes(scheduled: list[ScheduledTask]) -> int: + return max((item.minutes for item in scheduled if is_research_task(item.task)), default=0) + + +def admin_like_task_count(scheduled: list[ScheduledTask]) -> int: + return len({task_identity(item.task) for item in scheduled if is_admin_like_task(item.task)}) + + +def admin_like_scheduled_minutes(scheduled: list[ScheduledTask]) -> int: + return sum(item.minutes for item in scheduled if is_admin_like_task(item.task)) + + +def should_defer_admin_like_task( + task: dict[str, Any], + scheduled: list[ScheduledTask], + target_day: date, + has_research_candidate: bool, + pending_admin_count: int = 0, + pending_admin_minutes: int = 0, +) -> str | None: + if not is_admin_like_task(task) or is_manual_day_task(task, target_day): + return None + if is_manual_urgent(task): + return None + largest_research = largest_research_block_minutes(scheduled) + if has_research_candidate and largest_research <= 0: + return "no research block fits" + if largest_research > 0 and task_minutes(task) >= largest_research: + return "research anchor protected" + admin_count = admin_like_task_count(scheduled) + pending_admin_count + if admin_count >= 2: + return "admin cap" + if admin_count >= 1 and not is_due_urgent_admin(task, target_day): + return "admin cap" + admin_minutes = admin_like_scheduled_minutes(scheduled) + pending_admin_minutes + if admin_minutes + task_minutes(task) > ADMIN_BATCH_MAX_MINUTES: + return "admin batch cap" + return None + + +def task_reason(task: dict[str, Any], target_day: date, week_prefix: str | None = None) -> str: + if task.get("_overflow_reason"): + return str(task["_overflow_reason"]) + target = target_day.isoformat() + due = parse_date(task.get("due")) + priority = priority_value(task) + soft = is_soft_deadline(task) + rank = focus_rank_value(task) + policy = normalized_schedule_policy(task) + if rank is not None: + return f"focus #{rank}" + if is_manual_urgent(task): + return "manual urgent" + if str(task.get("block_day", "")) == target: + return "manual day" + if week_prefix and str(task.get("block_week", "")) == week_prefix: + return "manual week" + if policy in {"schedule-today", "today"}: + return "policy: today" + if policy in {"research-anchor", "anchor"}: + return "policy: research anchor" + if policy in {"admin-edge", "admin-batch", "edge"}: + return "policy: admin edge" + if soft and due is not None and due < target_day: + return "soft target overdue" + if soft and due == target_day: + return "soft target today" + if soft and due is not None and (due - target_day).days <= SOFT_DEADLINE_HORIZON_DAYS: + return "soft target" + if due is not None and due < target_day: + return "overdue" + if due == target_day: + return "due today" + if due is not None and (due - target_day).days <= 3: + return "due soon" + if priority <= 1: + return "priority 1" + if due is not None and (due - target_day).days <= 7: + return "this week" + if priority <= 2: + return "priority 2" + if urgent_status(task): + return "urgent status" + return "selected" + + +def urgency_bucket(task: dict[str, Any], target_day: date, week_prefix: str | None = None) -> tuple[int, int, int, str]: + target = target_day.isoformat() + due = parse_date(task.get("due")) + due_delta = 9999 if due is None else (due - target_day).days + priority = priority_value(task) + title = str(task.get("title", "")) + soft = is_soft_deadline(task) + + focus_rank = focus_rank_value(task) + policy = normalized_schedule_policy(task) + if focus_rank is not None: + bucket = 0 + due_delta = focus_rank + elif is_manual_urgent(task): + bucket = 0 + elif str(task.get("block_day", "")) == target: + bucket = 1 + elif policy in {"schedule-today", "today"}: + bucket = 1 + elif policy in {"research-anchor", "anchor", "admin-edge", "admin-batch", "edge"}: + bucket = 5 + elif soft and priority <= 1: + bucket = 5 + elif soft and priority <= 2: + bucket = 7 + elif soft and due is not None and due_delta <= -SOFT_OVERDUE_ESCALATION_DAYS: + bucket = 9 + elif soft and week_prefix and str(task.get("block_week", "")) == week_prefix: + bucket = 9 + elif soft and urgent_status(task): + bucket = 9 + elif soft and due is not None and due_delta <= SOFT_DEADLINE_HORIZON_DAYS: + bucket = 10 + elif soft: + bucket = 11 + elif due is not None and due < target_day: + bucket = 2 + elif due == target_day: + bucket = 3 + elif due is not None and due_delta <= 3: + bucket = 4 + elif priority <= 1: + bucket = 5 + elif due is not None and due_delta <= 7: + bucket = 6 + elif priority <= 2: + bucket = 7 + elif week_prefix and str(task.get("block_week", "")) == week_prefix: + bucket = 8 + elif urgent_status(task): + bucket = 9 + elif due is not None and due_delta <= 14: + bucket = 10 + else: + bucket = 11 + return (bucket, due_delta, priority, title) + + +def should_auto_schedule(task: dict[str, Any], target_day: date, horizon_days: int, week_prefix: str | None = None) -> bool: + if not is_open_task(task): + return False + policy = normalized_schedule_policy(task) + if str(task.get("block_day", "")) == target_day.isoformat(): + return True + if not is_schedulable_task(task, target_day): + return False + if week_prefix and str(task.get("block_week", "")) == week_prefix: + return True + if policy in {"defer", "deferred", "park", "parked"} and not is_manual_urgent(task): + return False + if policy in {"schedule-today", "today", "research-anchor", "anchor", "admin-edge", "admin-batch", "edge"}: + return True + if is_deferred_task(task) and not is_manual_urgent(task): + return False + due = parse_date(task.get("due")) + due_horizon = min(horizon_days, SOFT_DEADLINE_HORIZON_DAYS) if is_soft_deadline(task) else horizon_days + if due is not None and (due - target_day).days <= due_horizon: + return True + return priority_value(task) <= 2 or is_manual_urgent(task) or urgent_status(task) + + +def ranked_candidates( + tasks: list[dict[str, Any]], + target_day: date, + *, + horizon_days: int = 14, + week_prefix: str | None = None, +) -> list[dict[str, Any]]: + candidates = [task for task in tasks if should_auto_schedule(task, target_day, horizon_days, week_prefix)] + return sorted(candidates, key=lambda task: urgency_bucket(task, target_day, week_prefix)) + + +def non_working_overflow(tasks: list[dict[str, Any]], target_day: date, horizon_days: int, week_prefix: str | None) -> list[dict[str, Any]]: + return [ + {**task, "_overflow_reason": "non-working day"} + for task in ranked_candidates(tasks, target_day, horizon_days=horizon_days, week_prefix=week_prefix) + ] + + +def task_minutes(task: dict[str, Any]) -> int: + return parse_minutes(task.get("estimate_minutes", task.get("duration_minutes")), 60) + + +def work_windows( + *, + start: str = "09:00", + capacity_minutes: int = 420, + lunch_start: str | None = "12:00", + lunch_end: str | None = "13:00", +) -> list[tuple[int, int]]: + cursor = parse_clock(start) + remaining = max(0, capacity_minutes) + windows: list[tuple[int, int]] = [] + lunch_start_minutes = parse_clock(lunch_start) if lunch_start else None + lunch_end_minutes = parse_clock(lunch_end) if lunch_end else None + + while remaining > 0: + if lunch_start_minutes is not None and lunch_end_minutes is not None: + if lunch_start_minutes <= cursor < lunch_end_minutes: + cursor = lunch_end_minutes + continue + if cursor < lunch_start_minutes: + end = min(lunch_start_minutes, cursor + remaining) + if end > cursor: + windows.append((cursor, end)) + remaining -= end - cursor + cursor = lunch_end_minutes if end == lunch_start_minutes else end + continue + + end = cursor + remaining + if end > cursor: + windows.append((cursor, end)) + remaining = 0 + + return windows + + +def available_minutes(windows: list[tuple[int, int]]) -> int: + return sum(max(0, end - start) for start, end in windows) + + +def subtract_interval_from_windows(windows: list[tuple[int, int]], busy_start: int, busy_end: int) -> list[tuple[int, int]]: + if busy_end <= busy_start: + return windows + updated: list[tuple[int, int]] = [] + for start_minute, end_minute in windows: + if busy_end <= start_minute or busy_start >= end_minute: + updated.append((start_minute, end_minute)) + continue + if start_minute < busy_start: + updated.append((start_minute, min(busy_start, end_minute))) + if busy_end < end_minute: + updated.append((max(busy_end, start_minute), end_minute)) + return [(start_minute, end_minute) for start_minute, end_minute in updated if end_minute > start_minute] + + +def reserve_calendar_events( + windows: list[tuple[int, int]], + calendar_events: list[dict[str, Any]] | None, + target_day: date, +) -> tuple[list[ScheduledTask], list[tuple[int, int]], int]: + if not calendar_events: + return [], windows, 0 + scheduled: list[ScheduledTask] = [] + remaining_windows = list(windows) + blocked_minutes = 0 + work_start = min((start_minute for start_minute, _ in windows), default=parse_clock("09:00")) + work_end = max((end_minute for _, end_minute in windows), default=work_start) + + for index, event in enumerate(calendar_events): + if str(event.get("date") or target_day.isoformat()) != target_day.isoformat(): + continue + all_day = bool(event.get("all_day")) + if all_day: + display_start = work_start + display_end = work_end + before = available_minutes(remaining_windows) + for start_minute, end_minute in list(remaining_windows): + remaining_windows = subtract_interval_from_windows(remaining_windows, start_minute, end_minute) + blocked = before - available_minutes(remaining_windows) + else: + try: + display_start = parse_clock(str(event.get("start"))) + display_end = parse_clock(str(event.get("end"))) + except Exception: + continue + before = available_minutes(remaining_windows) + remaining_windows = subtract_interval_from_windows(remaining_windows, display_start, display_end) + blocked = before - available_minutes(remaining_windows) + if display_end <= display_start: + continue + if all_day and blocked <= 0: + continue + blocked_minutes += max(0, blocked) + event_task = { + "id": event.get("id") or f"calendar-{target_day.isoformat()}-{index}", + "path": event.get("path") or f"calendar:{target_day.isoformat()}:{index}", + "title": event.get("title") or "Work meeting", + "project": event.get("source_label") or "Calendar", + "domain": "calendar", + "time_category": "calendar", + "priority": "", + "status": "busy", + "due": target_day.isoformat(), + "next": "", + "private": bool(event.get("private")), + "kind": "calendar", + "readonly": True, + "source_label": event.get("source_label") or "Calendar", + "source_event_id": event.get("source_event_id") or "", + "all_day": all_day, + "blocking": blocked > 0 or all_day, + } + minutes = available_minutes([(display_start, display_end)]) if not all_day else max(0, blocked) + scheduled.append( + ScheduledTask( + task=event_task, + day=target_day, + start=format_clock(display_start), + end=format_clock(display_end), + minutes=minutes, + reason=str(event.get("reason") or ("all-day work event" if all_day else "work meeting")), + ) + ) + return scheduled, remaining_windows, blocked_minutes + + +def reserve_segments(windows: list[tuple[int, int]], minutes: int) -> list[tuple[int, int]] | None: + if minutes > available_minutes(windows): + return None + needed = minutes + segments: list[tuple[int, int]] = [] + for index, (start_minute, end_minute) in enumerate(windows): + if needed <= 0: + break + available = end_minute - start_minute + if available <= 0: + continue + take = min(needed, available) + segments.append((start_minute, start_minute + take)) + windows[index] = (start_minute + take, end_minute) + needed -= take + return segments if needed == 0 else None + + +def reserve_single_segment_from_end(windows: list[tuple[int, int]], minutes: int) -> tuple[int, int] | None: + if minutes <= 0: + return None + for index in range(len(windows) - 1, -1, -1): + start_minute, end_minute = windows[index] + if end_minute - start_minute < minutes: + continue + segment = (end_minute - minutes, end_minute) + replacement: list[tuple[int, int]] = [] + if start_minute < segment[0]: + replacement.append((start_minute, segment[0])) + if segment[1] < end_minute: + replacement.append((segment[1], end_minute)) + windows[index:index + 1] = replacement + return segment + return None + + +def latest_schedulable_day(task: dict[str, Any]) -> date | None: + due = parse_date(task.get("due")) + if due is None: + return None + if due.weekday() == 5: + return due - timedelta(days=1) + if due.weekday() == 6: + return due - timedelta(days=2) + return due + + +def missed_weekend_deadline(task: dict[str, Any], day: date, plan_start: date) -> bool: + due = parse_date(task.get("due")) + latest = latest_schedulable_day(task) + if due is None or latest is None or due.weekday() < 5: + return False + return plan_start <= latest < day + + +def should_consider_for_week( + task: dict[str, Any], + workdays: list[date], + *, + start_day: date, + horizon_days: int, + week_prefix: str | None = None, +) -> bool: + if not is_open_task(task): + return False + if any(str(task.get("block_day", "")) == day.isoformat() for day in workdays): + return True + if not is_schedulable_task(task, start_day): + return False + if week_prefix and str(task.get("block_week", "")) == week_prefix: + return True + if is_deferred_task(task) and not is_manual_urgent(task): + return False + due = parse_date(task.get("due")) + if due is not None and (due - start_day).days <= horizon_days: + return True + return priority_value(task) <= 2 or is_manual_urgent(task) or urgent_status(task) + + +def allocate_daily_plan( + tasks: list[dict[str, Any]], + target_day: date, + *, + start: str = "09:00", + capacity_minutes: int = 420, + horizon_days: int = 14, + week_prefix: str | None = None, + lunch_start: str | None = "12:00", + lunch_end: str | None = "13:00", + no_work_before: date | None = None, + calendar_events: list[dict[str, Any]] | None = None, +) -> tuple[list[ScheduledTask], list[dict[str, Any]]]: + if no_work_before is not None and target_day < no_work_before: + return [], non_working_overflow(tasks, target_day, horizon_days, week_prefix) + + windows = work_windows( + start=start, + capacity_minutes=capacity_minutes, + lunch_start=lunch_start, + lunch_end=lunch_end, + ) + initial_available = available_minutes(windows) + calendar_blocks, windows, calendar_blocked_minutes = reserve_calendar_events(windows, calendar_events, target_day) + scheduled: list[ScheduledTask] = list(calendar_blocks) + overflow: list[dict[str, Any]] = [] + candidates = ranked_candidates(tasks, target_day, horizon_days=horizon_days, week_prefix=week_prefix) + urgent_candidates = [task for task in candidates if is_manual_urgent(task)] + manual_candidates = [task for task in candidates if not is_manual_urgent(task) and is_manual_day_task(task, target_day)] + research_candidates = [task for task in candidates if not is_manual_urgent(task) and not is_manual_day_task(task, target_day) and is_research_task(task)] + admin_candidates = [task for task in candidates if not is_manual_urgent(task) and not is_manual_day_task(task, target_day) and is_admin_like_task(task)] + has_research_candidate = bool(research_candidates) + considered_ids: set[str] = set() + + def consider_task(task: dict[str, Any]) -> None: + considered_ids.add(task_identity(task)) + if defer_reason := should_defer_admin_like_task(task, scheduled, target_day, has_research_candidate): + overflow.append(with_overflow_reason(task, defer_reason)) + return + minutes = task_minutes(task) + segments = reserve_segments(windows, minutes) + if segments is None: + if calendar_blocked_minutes > 0 and minutes <= initial_available: + overflow.append(with_overflow_reason(task, "calendar conflict")) + else: + overflow.append(task) + return + segment_count = len(segments) + for segment_index, (segment_start, segment_end) in enumerate(segments, start=1): + scheduled.append( + ScheduledTask( + task=task, + day=target_day, + start=format_clock(segment_start), + end=format_clock(segment_end), + minutes=segment_end - segment_start, + reason=task_reason(task, target_day, week_prefix), + segment_index=segment_index, + segment_count=segment_count, + ) + ) + + def schedule_admin_batch() -> None: + selected: list[dict[str, Any]] = [] + selected_minutes = 0 + for task in admin_candidates: + considered_ids.add(task_identity(task)) + defer_reason = should_defer_admin_like_task( + task, + scheduled, + target_day, + has_research_candidate, + pending_admin_count=len(selected), + pending_admin_minutes=selected_minutes, + ) + if defer_reason: + overflow.append(with_overflow_reason(task, defer_reason)) + continue + selected.append(task) + selected_minutes += task_minutes(task) + if not selected: + return + segment = reserve_single_segment_from_end(windows, selected_minutes) + if segment is None: + reason = "calendar conflict" if calendar_blocked_minutes > 0 and selected_minutes <= initial_available else "admin batch window" + overflow.extend(with_overflow_reason(task, reason) for task in selected) + return + cursor = segment[0] + for task in selected: + minutes = task_minutes(task) + scheduled.append( + ScheduledTask( + task=task, + day=target_day, + start=format_clock(cursor), + end=format_clock(cursor + minutes), + minutes=minutes, + reason=task_reason(task, target_day, week_prefix), + ) + ) + cursor += minutes + + for task in urgent_candidates: + consider_task(task) + + for task in manual_candidates: + consider_task(task) + + if has_research_candidate and largest_research_block_minutes(scheduled) <= 0: + for task in research_candidates: + consider_task(task) + if largest_research_block_minutes(scheduled) > 0: + break + + schedule_admin_batch() + + for task in research_candidates: + if task_identity(task) not in considered_ids: + consider_task(task) + scheduled.sort(key=lambda item: (parse_clock(item.start), 0 if is_calendar_event(item.task) else 1, item.task.get("title", ""))) + return scheduled, overflow + + +def workdays_from(start_day: date, count: int) -> list[date]: + days: list[date] = [] + current = start_day + while len(days) < count: + if current.weekday() < 5: + days.append(current) + current += timedelta(days=1) + return days + + +def first_planning_day(start_day: date, no_work_before: date | None) -> date: + if no_work_before is not None and start_day < no_work_before: + return no_work_before + return start_day + + +def allocate_weekly_plan( + tasks: list[dict[str, Any]], + start_day: date, + *, + start: str = "09:00", + capacity_minutes: int = 420, + weekdays: int = 5, + horizon_days: int = 14, + week_prefix: str | None = None, + lunch_start: str | None = "12:00", + lunch_end: str | None = "13:00", + no_work_before: date | None = None, + calendar_events_by_day: dict[date, list[dict[str, Any]]] | None = None, +) -> tuple[dict[date, list[ScheduledTask]], list[dict[str, Any]]]: + effective_start = first_planning_day(start_day, no_work_before) + days = workdays_from(effective_start, weekdays) + remaining_tasks = [task for task in tasks if is_open_task(task)] + scheduled_by_day: dict[date, list[ScheduledTask]] = {} + overflow_reasons_by_id: dict[str, str] = {} + + for day in days: + eligible_tasks = [ + task + for task in remaining_tasks + if not missed_weekend_deadline(task, day, start_day) + ] + scheduled, _day_overflow = allocate_daily_plan( + eligible_tasks, + day, + start=start, + capacity_minutes=capacity_minutes, + horizon_days=horizon_days, + week_prefix=week_prefix, + lunch_start=lunch_start, + lunch_end=lunch_end, + no_work_before=no_work_before, + calendar_events=(calendar_events_by_day or {}).get(day, []), + ) + for task in _day_overflow: + if task.get("_overflow_reason"): + overflow_reasons_by_id[task_identity(task)] = str(task["_overflow_reason"]) + scheduled_by_day[day] = scheduled + scheduled_ids = {task_identity(item.task) for item in scheduled} + remaining_tasks = [task for task in remaining_tasks if task_identity(task) not in scheduled_ids] + + missed = [ + {**task, "_overflow_reason": "missed weekend deadline"} + for task in remaining_tasks + if (latest := latest_schedulable_day(task)) is not None + and (due := parse_date(task.get("due"))) is not None + and due.weekday() >= 5 + and start_day <= latest <= days[-1] + ] + missed_ids = {task_identity(task) for task in missed} + overflow = missed + [ + with_overflow_reason(task, overflow_reasons_by_id[task_identity(task)]) + if task_identity(task) in overflow_reasons_by_id + else task + for task in remaining_tasks + if task_identity(task) not in missed_ids + and should_consider_for_week( + task, + days, + start_day=start_day, + horizon_days=horizon_days, + week_prefix=week_prefix, + ) + ] + overflow.sort(key=lambda task: urgency_bucket(task, start_day, week_prefix)) + return scheduled_by_day, overflow diff --git a/scripts/time_plan_context.py b/scripts/time_plan_context.py new file mode 100644 index 0000000..1ba0e20 --- /dev/null +++ b/scripts/time_plan_context.py @@ -0,0 +1,463 @@ +from __future__ import annotations + +from datetime import date +from pathlib import Path +from typing import Any + +import yaml + +try: + from scripts.time_blocks import ( + allocate_daily_plan, + allocate_weekly_plan, + is_open_task, + ranked_candidates, + task_minutes, + task_reason, + ) +except ImportError: + from time_blocks import ( + allocate_daily_plan, + allocate_weekly_plan, + is_open_task, + ranked_candidates, + task_minutes, + task_reason, + ) + + +def _truthy(value: Any) -> bool: + return bool(value) and str(value).lower() not in {"false", "0", "no", "none"} + + +def _priority(value: Any) -> int: + try: + return int(value) + except Exception: + return 99 + + +def _days_until(value: Any, target_day: date) -> int | None: + try: + return (date.fromisoformat(str(value)[:10]) - target_day).days + except Exception: + return None + + +def _split_frontmatter(text: str) -> tuple[dict[str, Any], str]: + if not (text.startswith("---\n") or text.startswith("---\r\n")): + return {}, text + lines = text.splitlines(keepends=True) + for i in range(1, len(lines)): + if lines[i].strip() == "---": + raw = "".join(lines[1:i]) + try: + parsed = yaml.safe_load(raw) or {} + except Exception: + parsed = {} + return (parsed if isinstance(parsed, dict) else {}), "".join(lines[i + 1 :]) + return {}, text + + +def read_time_planning_settings(root: Path) -> dict[str, Any]: + rel_path = Path("settings/time_planning.md") + path = root / rel_path + if not path.exists(): + return {"no_work_before": None} + raw = path.read_text(encoding="utf-8", errors="replace") + frontmatter, _ = _split_frontmatter(raw) + no_work_before = frontmatter.get("no_work_before") or frontmatter.get("not_working_until") + return {"no_work_before": str(no_work_before) if no_work_before else None} + + +def read_time_planning_preferences(root: Path) -> dict[str, Any]: + rel_path = Path("settings/time_planning.md") + path = root / rel_path + if not path.exists(): + return { + "path": rel_path.as_posix(), + "exists": False, + "private": True, + "summary": "No stored time-planning preference profile found.", + "content": "", + } + raw = path.read_text(encoding="utf-8", errors="replace") + frontmatter, body = _split_frontmatter(raw) + content = body.strip() + return { + "path": rel_path.as_posix(), + "exists": True, + "private": True, + "summary": content[:1400] + ("..." if len(content) > 1400 else ""), + "content": content, + "settings": { + "no_work_before": str(frontmatter.get("no_work_before") or frontmatter.get("not_working_until") or ""), + }, + } + + +def time_task_summary(task: dict[str, Any], target_day: date, week_prefix: str | None = None) -> dict[str, Any]: + minutes = task_minutes(task) + payload = { + "path": task.get("path", ""), + "title": task.get("title", ""), + "project": task.get("project", ""), + "domain": task.get("domain", ""), + "time_category": task.get("time_category", ""), + "schedule_policy": task.get("schedule_policy", ""), + "priority": str(task.get("priority", "")) if task.get("priority") is not None else "", + "urgent": bool(task.get("urgent")), + "focus_manual": bool(task.get("focus_manual")), + "focus_rank": task.get("focus_rank", ""), + "status": task.get("status", ""), + "due": task.get("due", ""), + "deadline_type": task.get("deadline_type", ""), + "next": task.get("next", ""), + "minutes": minutes, + "estimate_minutes": minutes, + "reason": task_reason(task, target_day, week_prefix), + "private": bool(task.get("private")), + "kind": task.get("kind") or "task", + } + if task.get("readonly"): + payload["readonly"] = bool(task.get("readonly")) + if task.get("source_label"): + payload["sourceLabel"] = task.get("source_label") + if task.get("source_event_id"): + payload["sourceEventId"] = task.get("source_event_id") + if task.get("all_day") is not None: + payload["allDay"] = bool(task.get("all_day")) + if task.get("blocking") is not None: + payload["blocking"] = bool(task.get("blocking")) + return payload + + +def time_block_payload(item: Any) -> dict[str, Any]: + payload = time_task_summary(item.task, item.day, "") + payload.update( + { + "start": item.start, + "end": item.end, + "minutes": item.minutes, + "reason": item.reason, + "segment_index": getattr(item, "segment_index", 1), + "segment_count": getattr(item, "segment_count", 1), + } + ) + return payload + + +def build_deterministic_plan( + tasks: list[dict[str, Any]], + target_day: date, + week_start_day: date, + *, + start: str, + capacity_minutes: int, + weekdays: int, + horizon_days: int, + week_prefix: str, + no_work_before: date | None = None, + calendar_events_by_day: dict[date, list[dict[str, Any]]] | None = None, + calendar_status: dict[str, Any] | None = None, +) -> dict[str, Any]: + daily_blocks, daily_overflow = allocate_daily_plan( + tasks, + target_day, + start=start, + capacity_minutes=capacity_minutes, + horizon_days=horizon_days, + week_prefix=week_prefix, + no_work_before=no_work_before, + calendar_events=(calendar_events_by_day or {}).get(target_day, []), + ) + weekly_blocks, weekly_overflow = allocate_weekly_plan( + tasks, + week_start_day, + start=start, + capacity_minutes=capacity_minutes, + weekdays=weekdays, + horizon_days=horizon_days, + week_prefix=week_prefix, + no_work_before=no_work_before, + calendar_events_by_day=calendar_events_by_day, + ) + return { + "settings": { + "date": target_day.isoformat(), + "week_start": week_start_day.isoformat(), + "start": start, + "capacity_minutes": capacity_minutes, + "weekdays": weekdays, + "horizon_days": horizon_days, + "no_work_before": no_work_before.isoformat() if no_work_before else "", + }, + "daily": { + "date": target_day.isoformat(), + "total_minutes": sum(item.minutes for item in daily_blocks), + "blocks": [time_block_payload(item) for item in daily_blocks], + }, + "daily_overflow": [time_task_summary(task, target_day, week_prefix) for task in daily_overflow], + "weekly": [ + { + "date": day.isoformat(), + "total_minutes": sum(item.minutes for item in items), + "blocks": [time_block_payload(item) for item in items], + } + for day, items in weekly_blocks.items() + ], + "weekly_overflow": [time_task_summary(task, week_start_day, week_prefix) for task in weekly_overflow], + "calendar": calendar_status or {"enabled": False, "last_fetch": "", "event_count": 0, "source_labels": [], "errors": []}, + } + + +def pressing_tasks( + tasks: list[dict[str, Any]], + target_day: date, + *, + horizon_days: int, + week_prefix: str, + include_private: bool, + limit: int = 18, +) -> list[dict[str, Any]]: + visible = [task for task in tasks if include_private or not _truthy(task.get("private"))] + ranked = ranked_candidates(visible, target_day, horizon_days=horizon_days, week_prefix=week_prefix) + return [time_task_summary(task, target_day, week_prefix) for task in ranked[:limit]] + + +def _project_identity(project: dict[str, Any]) -> str: + return str(project.get("id") or project.get("project") or project.get("name") or project.get("title") or "").lower() + + +def _project_matches_task(project: dict[str, Any], task: dict[str, Any]) -> bool: + key = _project_identity(project) + if not key: + return False + candidates = { + str(task.get("project") or "").lower(), + str(task.get("project_id") or "").lower(), + } + return key in candidates + + +def pressing_projects( + projects: list[dict[str, Any]], + tasks: list[dict[str, Any]], + target_day: date, + *, + include_private: bool, + limit: int = 10, +) -> list[dict[str, Any]]: + open_tasks = [task for task in tasks if is_open_task(task) and (include_private or not _truthy(task.get("private")))] + summaries: list[dict[str, Any]] = [] + for project in projects: + if not include_private and _truthy(project.get("private")): + continue + linked = [task for task in open_tasks if _project_matches_task(project, task)] + deadline = project.get("deadline") or project.get("due") or project.get("date") or "" + days = _days_until(deadline, target_day) + priority = _priority(project.get("priority")) + status = str(project.get("status") or "").lower() + pressing = linked or priority <= 2 or (days is not None and days <= 14) or any(token in status for token in ["active", "urgent", "needs", "waiting"]) + if not pressing: + continue + summaries.append( + { + "path": project.get("path", ""), + "title": project.get("name") or project.get("title") or project.get("id") or "Untitled project", + "id": project.get("id") or project.get("project") or "", + "status": project.get("status", ""), + "priority": str(project.get("priority", "")) if project.get("priority") is not None else "", + "deadline": deadline, + "deadline_type": project.get("deadline_type") or "", + "days_until_deadline": days, + "next": project.get("next_action") or project.get("next") or project.get("snippet") or "", + "open_task_count": len(linked), + "private": bool(project.get("private")), + } + ) + return sorted( + summaries, + key=lambda item: ( + _priority(item.get("priority")), + item.get("days_until_deadline") if item.get("days_until_deadline") is not None else 9999, + -int(item.get("open_task_count") or 0), + str(item.get("title", "")), + ), + )[:limit] + + +def _format_task_line(task: dict[str, Any]) -> str: + category = task.get("time_category") or task.get("domain") or "uncategorized" + return ( + f"- {task.get('reason', 'selected')} | P{task.get('priority') or '-'} | " + f"{category} | {task.get('deadline_type') or 'soft'} deadline | {task.get('title')} [{task.get('project') or 'no project'}] | " + f"due {task.get('due') or 'no date'} | {task.get('minutes')}m | file: {task.get('path')}" + ) + + +def _format_plan_block(block: dict[str, Any]) -> str: + if block.get("kind") == "calendar": + source = block.get("sourceLabel") or block.get("project") or "Calendar" + return ( + f"- {block.get('start')}-{block.get('end')} | calendar | {source} | " + f"{block.get('title')} | {block.get('minutes')}m | {block.get('reason')}" + ) + category = block.get("time_category") or block.get("domain") or "uncategorized" + return ( + f"- {block.get('start')}-{block.get('end')} | {block.get('reason')} | " + f"P{block.get('priority') or '-'} | {category} | {block.get('deadline_type') or 'soft'} deadline | {block.get('title')} " + f"[{block.get('project') or 'no project'}] | {block.get('minutes')}m" + ) + + +def render_agent_prompt(context: dict[str, Any]) -> str: + plan = context["deterministic_plan"] + lines = [ + "# Codex Agent Planning Brief", + "", + "You are helping create an ad hoc time plan. Markdown tasks are canonical.", + "Do not mark tasks complete, do not edit task frontmatter, and do not write journal/schedule files unless explicitly asked after this plan.", + "", + "## Requested Output", + "- A revised daily time-block plan with start/end times.", + "- A weekly allocation sketch for work that does not fit today.", + "- A short rationale explaining which urgent projects/tasks were prioritized.", + "- An overflow/defer list with why each item was deferred.", + "", + "## Settings", + f"- Date: {context['settings']['date']}", + f"- Week start: {context['settings']['week_start']}", + f"- Start: {context['settings']['start']}", + f"- Capacity/day: {context['settings']['capacity_minutes']} minutes", + f"- Workdays: {context['settings']['weekdays']}", + f"- Horizon: {context['settings']['horizon_days']} days", + f"- No work before: {context['settings'].get('no_work_before') or 'none'}", + "", + "## Ad Hoc Constraints", + context.get("ad_hoc") or "None provided.", + "", + "## Stored Planning Preferences", + context["preferences"].get("content") or context["preferences"].get("summary") or "No stored preferences.", + "", + "## Deterministic Daily Baseline", + ] + if plan["daily"]["blocks"]: + lines.extend(_format_plan_block(block) for block in plan["daily"]["blocks"]) + else: + lines.append("- No deterministic daily allocation.") + lines.extend(["", "## Deterministic Weekly Baseline"]) + for day in plan["weekly"]: + lines.append(f"### {day['date']} ({day['total_minutes']}m)") + if day["blocks"]: + lines.extend(_format_plan_block(block) for block in day["blocks"]) + else: + lines.append("- No allocation.") + lines.extend(["", "## Pressing Tasks"]) + if context["pressing_tasks"]: + lines.extend(_format_task_line(task) for task in context["pressing_tasks"]) + else: + lines.append("- No pressing open tasks found.") + lines.extend(["", "## Pressing Projects"]) + if context["pressing_projects"]: + for project in context["pressing_projects"]: + lines.append( + f"- P{project.get('priority') or '-'} | {project.get('title')} | " + f"{project.get('open_task_count')} open tasks | {project.get('deadline_type') or 'soft'} deadline {project.get('deadline') or 'none'} | " + f"next: {project.get('next') or 'none'} | file: {project.get('path')}" + ) + else: + lines.append("- No pressing project summaries found.") + lines.extend(["", "## Overflow From Deterministic Plan"]) + if plan["daily_overflow"]: + lines.append("### Today") + lines.extend(_format_task_line(task) for task in plan["daily_overflow"][:12]) + if plan["weekly_overflow"]: + lines.append("### Week") + lines.extend(_format_task_line(task) for task in plan["weekly_overflow"][:12]) + if not plan["daily_overflow"] and not plan["weekly_overflow"]: + lines.append("- No deterministic overflow.") + calendar = plan.get("calendar") or {} + if calendar.get("enabled"): + lines.extend( + [ + "", + "## Calendar Import", + f"- Sources: {', '.join(calendar.get('source_labels') or []) or 'none'}", + f"- Imported work events in planning range: {calendar.get('event_count', 0)}", + f"- Last fetch: {calendar.get('last_fetch') or 'not fetched'}", + ] + ) + if calendar.get("errors"): + lines.append(f"- Calendar warnings: {'; '.join(calendar.get('errors') or [])}") + return "\n".join(lines).strip() + "\n" + + +def build_agent_plan_context( + *, + root: Path, + tasks: list[dict[str, Any]], + projects: list[dict[str, Any]], + target_day: date, + week_start_day: date, + start: str, + capacity_minutes: int, + weekdays: int, + horizon_days: int, + week_prefix: str, + ad_hoc: str = "", + include_private: bool = True, + calendar_events_by_day: dict[date, list[dict[str, Any]]] | None = None, + calendar_status: dict[str, Any] | None = None, +) -> dict[str, Any]: + visible_tasks = [task for task in tasks if include_private or not _truthy(task.get("private"))] + visible_projects = [project for project in projects if include_private or not _truthy(project.get("private"))] + planning_settings = read_time_planning_settings(root) + no_work_before = None + try: + no_work_before = date.fromisoformat(str(planning_settings.get("no_work_before"))) if planning_settings.get("no_work_before") else None + except Exception: + no_work_before = None + context = { + "settings": { + "date": target_day.isoformat(), + "week_start": week_start_day.isoformat(), + "start": start, + "capacity_minutes": capacity_minutes, + "weekdays": weekdays, + "horizon_days": horizon_days, + "include_private": include_private, + "no_work_before": no_work_before.isoformat() if no_work_before else "", + }, + "ad_hoc": ad_hoc.strip(), + "preferences": read_time_planning_preferences(root), + "deterministic_plan": build_deterministic_plan( + visible_tasks, + target_day, + week_start_day, + start=start, + capacity_minutes=capacity_minutes, + weekdays=weekdays, + horizon_days=horizon_days, + week_prefix=week_prefix, + no_work_before=no_work_before, + calendar_events_by_day=calendar_events_by_day, + calendar_status=calendar_status, + ), + "pressing_tasks": pressing_tasks( + tasks, + target_day, + horizon_days=horizon_days, + week_prefix=week_prefix, + include_private=include_private, + ), + "pressing_projects": pressing_projects( + visible_projects, + tasks, + target_day, + include_private=include_private, + ), + } + context["calendar"] = calendar_status or {"enabled": False, "last_fetch": "", "event_count": 0, "source_labels": [], "errors": []} + context["agent_prompt"] = render_agent_prompt(context) + return context diff --git a/scripts/validate_vault_schema.py b/scripts/validate_vault_schema.py new file mode 100644 index 0000000..8ddcdb8 --- /dev/null +++ b/scripts/validate_vault_schema.py @@ -0,0 +1,342 @@ +#!/usr/bin/env python3 +"""Validate canonical Markdown frontmatter fields. + +The vault intentionally stays Markdown-first, but a small set of fields drives +core app behavior. This script checks those fields before generated caches or +hosted deployments can drift. +""" +from __future__ import annotations + +import argparse +import json +import sys +from dataclasses import asdict, dataclass +from datetime import date +from pathlib import Path +from typing import Any, Iterable + +try: + from markdown_reader import split_frontmatter +except ModuleNotFoundError: # Imported as scripts.validate_vault_schema in tests. + from scripts.markdown_reader import split_frontmatter + + +try: + from scripts.workbench_paths import VAULT_ROOT +except ImportError: # pragma: no cover + from workbench_paths import VAULT_ROOT + +ROOT = VAULT_ROOT + +TASK_STATUSES = { + "open", + "active", + "waiting", + "blocked", + "done", + "complete", + "completed", + "cancelled", + "canceled", + "dropped", + "archive", + "archived", + "someday", + "later", + "parked", + "paused", +} +DEPRECATED_TASK_STATUSES = { + "complete": "done", + "completed": "done", + "canceled": "cancelled", + "archive": "archived", +} +DONE_STATUSES = {"done", "complete", "completed"} +CANCELLED_STATUSES = {"cancelled", "canceled", "dropped", "archive", "archived"} +CLOSED_STATUSES = DONE_STATUSES | CANCELLED_STATUSES +DEADLINE_TYPES = {"hard", "soft"} +PRIORITIES = {1, 2, 3, 4, 5} +BOOLEAN_STRINGS = {"true", "false", "yes", "no", "0", "1"} +TASK_OWNERSHIP_FIELDS = {"assignee", "assigned_to", "owner"} +DEPRECATED_TASK_OWNERSHIP_FIELDS = { + "assigned": "assignee", + "responsible": "assignee", + "lead": "assignee", +} + +TRAVEL_LEDGER_FIELDS = { + "id", + "trip_key", + "trip_title", + "item", + "category", + "estimate", + "estimate_usd", + "actual", + "actual_usd", + "currency", + "status", + "reimbursable", + "source", + "receipt_pointer", + "private_pointer", + "reimbursed_amount", + "reimbursed_amount_usd", + "reimbursed_date", + "covered_by", + "counts_against_research_account", + "notes", +} +TRAVEL_LEDGER_REQUIRED_FIELDS = {"id", "trip_key", "item", "category", "status"} +TRAVEL_LEDGER_STATUSES = { + "approval_needed", + "approved", + "ready_to_book", + "booked", + "booked_price_not_stored", + "planned", + "estimate", + "submitted", + "reimbursement_pending", + "reimbursed", + "complete", + "needs_submission_check", + "not_reimbursable", + "cancelled", + "canceled", +} + + +@dataclass(frozen=True) +class SchemaIssue: + path: str + field: str + message: str + + +def relpath(path: Path, root: Path) -> str: + try: + return path.relative_to(root).as_posix() + except ValueError: + return path.as_posix() + + +def normalize_status(value: Any) -> str: + return str(value or "").strip().lower() + + +def scalar_text(value: Any) -> str: + return str(value).strip() + + +def parse_priority(value: Any) -> int | None: + if value is None or value == "": + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def is_boolean_like(value: Any) -> bool: + if isinstance(value, bool): + return True + if isinstance(value, str): + return value.strip().lower() in BOOLEAN_STRINGS + return False + + +def is_number(value: Any) -> bool: + if isinstance(value, bool): + return False + if isinstance(value, (int, float)): + return True + if isinstance(value, str): + try: + float(value) + return True + except ValueError: + return False + return False + + +def is_iso_date(value: Any) -> bool: + if hasattr(value, "isoformat"): + return True + if value is None or value == "": + return True + try: + date.fromisoformat(str(value)) + return True + except ValueError: + return False + + +def add_issue(issues: list[SchemaIssue], path: Path, root: Path, field: str, message: str) -> None: + issues.append(SchemaIssue(relpath(path, root), field, message)) + + +def validate_task(path: Path, frontmatter: dict[str, Any], root: Path) -> list[SchemaIssue]: + issues: list[SchemaIssue] = [] + status = normalize_status(frontmatter.get("status")) + if not status: + add_issue(issues, path, root, "status", "task status is required") + elif status not in TASK_STATUSES: + add_issue(issues, path, root, "status", f"unsupported task status {status!r}") + elif status in DEPRECATED_TASK_STATUSES: + add_issue(issues, path, root, "status", f"deprecated task status {status!r}; use {DEPRECATED_TASK_STATUSES[status]!r}") + + task_id = scalar_text(frontmatter.get("id") or "") + if not task_id: + add_issue(issues, path, root, "id", "task id is required") + elif task_id != path.stem: + add_issue(issues, path, root, "id", f"task id {task_id!r} must match filename stem {path.stem!r}") + + priority = parse_priority(frontmatter.get("priority")) + if frontmatter.get("priority") not in (None, "") and priority not in PRIORITIES: + add_issue(issues, path, root, "priority", "priority must be an integer from 1 to 5") + + deadline_type = normalize_status(frontmatter.get("deadline_type")) + if deadline_type and deadline_type not in DEADLINE_TYPES: + add_issue(issues, path, root, "deadline_type", "deadline_type must be hard or soft") + if status not in CLOSED_STATUSES and any(frontmatter.get(field) for field in ("due", "date", "start_date")) and not deadline_type: + add_issue(issues, path, root, "deadline_type", "open dated tasks must explicitly set deadline_type to hard or soft") + + for field in ("due", "date", "start_date", "end_date", "completed", "last_touched"): + if field in frontmatter and not is_iso_date(frontmatter.get(field)): + add_issue(issues, path, root, field, "date fields must use YYYY-MM-DD") + + if "urgent" in frontmatter and not is_boolean_like(frontmatter.get("urgent")): + add_issue(issues, path, root, "urgent", "urgent must be boolean-like") + + for field, replacement in DEPRECATED_TASK_OWNERSHIP_FIELDS.items(): + if field in frontmatter: + add_issue(issues, path, root, field, f"deprecated task ownership field; use {replacement!r}") + + ownership_values = [field for field in TASK_OWNERSHIP_FIELDS if frontmatter.get(field)] + if len(ownership_values) > 1: + add_issue(issues, path, root, "assignee", f"use only one task ownership field, found: {', '.join(sorted(ownership_values))}") + for field in TASK_OWNERSHIP_FIELDS: + value = frontmatter.get(field) + if isinstance(value, (dict, list)): + add_issue(issues, path, root, field, "task ownership fields must be scalar text") + + if path.parts[-2] == "done" and status not in DONE_STATUSES: + add_issue(issues, path, root, "status", "tasks/done files must have a done/completed status") + if path.parts[-2] == "cancelled" and status not in CANCELLED_STATUSES: + add_issue(issues, path, root, "status", "tasks/cancelled files must have a cancelled/archive status") + if path.parts[-2] in {"active", "waiting"} and status in CLOSED_STATUSES: + add_issue(issues, path, root, "status", "closed tasks must not remain in active/waiting directories") + + return issues + + +def validate_unique_task_ids(task_frontmatter: Iterable[tuple[Path, dict[str, Any]]], root: Path) -> list[SchemaIssue]: + issues: list[SchemaIssue] = [] + seen: dict[str, Path] = {} + for path, frontmatter in task_frontmatter: + task_id = scalar_text(frontmatter.get("id") or "") + if not task_id: + continue + if task_id in seen: + add_issue(issues, path, root, "id", f"duplicate task id also used by {relpath(seen[task_id], root)}") + else: + seen[task_id] = path + return issues + + +def validate_travel_ledger(path: Path, frontmatter: dict[str, Any], root: Path) -> list[SchemaIssue]: + issues: list[SchemaIssue] = [] + ledger = frontmatter.get("ledger") + if ledger is None: + return issues + if not isinstance(ledger, list): + add_issue(issues, path, root, "ledger", "travel ledger must be a list of row objects") + return issues + + seen_ids: set[str] = set() + for index, row in enumerate(ledger): + prefix = f"ledger[{index}]" + if not isinstance(row, dict): + add_issue(issues, path, root, prefix, "ledger rows must be objects") + continue + unknown = sorted(set(str(key) for key in row) - TRAVEL_LEDGER_FIELDS) + if unknown: + add_issue(issues, path, root, prefix, f"unknown ledger field(s): {', '.join(unknown)}") + missing = sorted(field for field in TRAVEL_LEDGER_REQUIRED_FIELDS if not row.get(field)) + if missing: + add_issue(issues, path, root, prefix, f"missing required ledger field(s): {', '.join(missing)}") + + row_id = scalar_text(row.get("id") or "") + if row_id: + if row_id in seen_ids: + add_issue(issues, path, root, f"{prefix}.id", f"duplicate ledger id {row_id!r}") + seen_ids.add(row_id) + + status = normalize_status(row.get("status")) + if status and status not in TRAVEL_LEDGER_STATUSES: + add_issue(issues, path, root, f"{prefix}.status", f"unsupported ledger status {status!r}") + + if row.get("currency") and len(str(row.get("currency")).strip()) != 3: + add_issue(issues, path, root, f"{prefix}.currency", "currency must be a 3-letter code") + + for amount_field in ("estimate", "estimate_usd", "actual", "actual_usd", "reimbursed_amount", "reimbursed_amount_usd"): + if amount_field in row and row.get(amount_field) not in (None, "") and not is_number(row.get(amount_field)): + add_issue(issues, path, root, f"{prefix}.{amount_field}", "amount fields must be numeric") + + if "reimbursable" in row and not is_boolean_like(row.get("reimbursable")): + add_issue(issues, path, root, f"{prefix}.reimbursable", "reimbursable must be boolean-like") + + if "reimbursed_date" in row and not is_iso_date(row.get("reimbursed_date")): + add_issue(issues, path, root, f"{prefix}.reimbursed_date", "reimbursed_date must use YYYY-MM-DD") + + return issues + + +def validate_root(root: Path = ROOT) -> list[SchemaIssue]: + root = root.resolve() + issues: list[SchemaIssue] = [] + task_frontmatter: list[tuple[Path, dict[str, Any]]] = [] + + for path in sorted((root / "tasks").glob("*/*.md")): + if path.name.lower() == "readme.md": + continue + frontmatter, _ = split_frontmatter(path.read_text(encoding="utf-8", errors="replace")) + if frontmatter.get("kind") != "task": + continue + task_frontmatter.append((path, frontmatter)) + issues.extend(validate_task(path, frontmatter, root)) + + issues.extend(validate_unique_task_ids(task_frontmatter, root)) + + for path in sorted(root.rglob("*.md")): + if any(part in {".git", ".venv", "node_modules", "dashboard", "data"} for part in path.relative_to(root).parts): + continue + frontmatter, _ = split_frontmatter(path.read_text(encoding="utf-8", errors="replace")) + if frontmatter.get("kind") == "travel-ledger" or path.name == "travel_ledger.md": + issues.extend(validate_travel_ledger(path, frontmatter, root)) + + return issues + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=ROOT, help="Vault root to validate.") + parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON.") + args = parser.parse_args() + + issues = validate_root(args.root) + if args.json: + print(json.dumps([asdict(issue) for issue in issues], indent=2)) + elif issues: + print("Vault schema validation failed:") + for issue in issues: + print(f"- {issue.path} :: {issue.field}: {issue.message}") + else: + print("Vault schema validation passed.") + return 1 if issues else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/vault_parsing.py b/scripts/vault_parsing.py index 673aa19..b7610d1 100644 --- a/scripts/vault_parsing.py +++ b/scripts/vault_parsing.py @@ -20,14 +20,26 @@ except Exception as exc: # pragma: no cover raise SystemExit("PyYAML is required. Install with: python3 -m pip install pyyaml") from exc -ROOT = Path(__file__).resolve().parents[1] / "vault" +try: + from scripts.workbench_paths import VAULT_ROOT +except ImportError: # pragma: no cover + from workbench_paths import VAULT_ROOT + +ROOT = VAULT_ROOT # NOTE: CORE_FIELDS is deliberately NOT shared. The two consumers diverge for a # real reason: server/vault.py promotes assignee/assigned_to/domain to top-level # entry fields, while markdown_reader.py surfaces them via `properties`. Unifying # the set would drop assignee from the reader's cache (it has no top-level slot # for it), which RA rendering depends on. Each consumer keeps its own CORE_FIELDS. -STRUCTURED_PROPERTY_FIELDS = {"ledger"} +STRUCTURED_PROPERTY_FIELDS = { + "bullets", + "criteria", + "evidence", + "fitness_plan", + "ledger", + "source_pointers", +} WIKILINK_RE = re.compile(r"\[\[([^\]|#]+)(?:[#|][^\]]*)?\]\]") H1_RE = re.compile(r"^#\s+(.+?)\s*$", re.MULTILINE) diff --git a/scripts/workbench_config.py b/scripts/workbench_config.py new file mode 100644 index 0000000..a3c9b99 --- /dev/null +++ b/scripts/workbench_config.py @@ -0,0 +1,166 @@ +"""Load and apply vault-local Research Workbench configuration.""" +from __future__ import annotations + +from copy import deepcopy +from pathlib import Path +from typing import Any + +import yaml + + +DEFAULT_CONFIG: dict[str, Any] = { + "application": { + "name": "Research Workbench", + "owner_label": "Owner", + "owner_aliases": ["owner", "me", "self"], + }, + "modules": { + "overview": {"enabled": True, "label": "Overview"}, + "tasks": {"enabled": True, "label": "Tasks"}, + "projects": {"enabled": True, "label": "Projects"}, + "notes": {"enabled": True, "label": "Library"}, + "calendar": {"enabled": True, "label": "Calendar"}, + "boards": {"enabled": True, "label": "Boards"}, + "graph": {"enabled": True, "label": "Graph"}, + "time_planning": {"enabled": True, "label": "Time Plan"}, + "admin": {"enabled": True, "label": "Admin Center"}, + "travel": {"enabled": True, "label": "Travel Center"}, + "collaborators": {"enabled": True, "label": "Collaborators"}, + "wellness": {"enabled": True, "label": "Wellness"}, + "performance": {"enabled": True, "label": "Performance Review"}, + "scholar_metrics": {"enabled": True, "label": "Scholar Metrics"}, + "github_sync": {"enabled": False, "label": "GitHub Sync"}, + "markdown_editor": {"enabled": True, "label": "Editor"}, + "codex_backlog": {"enabled": True, "label": "Agent Backlog"}, + }, + "domains": { + "labels": { + "research": "Research", + "admin": "Administration", + "teaching": "Teaching", + "personal": "Personal", + "system": "System", + "archive": "Archive", + "other": "Other", + }, + "classification_rules": [ + {"domain": "research", "field": "domain", "values": ["research"]}, + {"domain": "admin", "field": "domain", "values": ["admin"]}, + {"domain": "teaching", "field": "domain", "values": ["teaching"]}, + {"domain": "personal", "field": "domain", "values": ["personal", "wellness"]}, + {"domain": "system", "field": "domain", "values": ["system"]}, + {"domain": "archive", "field": "domain", "values": ["archive"]}, + ], + }, + "profile": { + "scholar_statistics_source": "", + }, +} + + +def _merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + merged = deepcopy(base) + for key, value in override.items(): + if isinstance(value, dict) and isinstance(merged.get(key), dict): + merged[key] = _merge(merged[key], value) + else: + merged[key] = deepcopy(value) + return merged + + +def load_workbench_config(vault_root: Path) -> dict[str, Any]: + path = vault_root / "settings" / "workbench.yml" + if not path.exists(): + return deepcopy(DEFAULT_CONFIG) + try: + parsed = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + except (OSError, yaml.YAMLError): + parsed = {} + return _merge(DEFAULT_CONFIG, parsed if isinstance(parsed, dict) else {}) + + +def safe_ui_config(config: dict[str, Any]) -> dict[str, Any]: + application = config.get("application") if isinstance(config.get("application"), dict) else {} + domains = config.get("domains") if isinstance(config.get("domains"), dict) else {} + modules = config.get("modules") if isinstance(config.get("modules"), dict) else {} + return { + "application_name": str(application.get("name") or "Research Workbench"), + "owner_label": str(application.get("owner_label") or "Owner"), + "owner_aliases": [ + str(value).strip() + for value in application.get("owner_aliases", []) + if str(value).strip() + ], + "modules": { + str(key): { + "enabled": bool(value.get("enabled", True)) if isinstance(value, dict) else bool(value), + "label": str(value.get("label") or key.replace("_", " ").title()) if isinstance(value, dict) + else key.replace("_", " ").title(), + } + for key, value in modules.items() + }, + "domain_labels": { + str(key): str(value) + for key, value in (domains.get("labels") or {}).items() + }, + } + + +def normalized(value: Any) -> str: + return str(value or "").strip().lower().replace("_", "-") + + +def classify_entry( + entry: dict[str, Any], + config: dict[str, Any], + project_domains: dict[str, str] | None = None, +) -> tuple[str, str]: + domains = config.get("domains") if isinstance(config.get("domains"), dict) else {} + labels = domains.get("labels") if isinstance(domains.get("labels"), dict) else {} + valid = {normalized(key) for key in labels} | {"other"} + + explicit = normalized(entry.get("domain")) + if explicit in valid: + return explicit, "frontmatter" + + project = normalized(entry.get("project")) + if project and project_domains and project_domains.get(project): + return project_domains[project], "project" + + rules = domains.get("classification_rules") + if isinstance(rules, list): + for rule in rules: + if not isinstance(rule, dict): + continue + target = normalized(rule.get("domain")) + field = str(rule.get("field") or "").strip() + values = rule.get("values") + if target not in valid or not field or not isinstance(values, list): + continue + current = normalized(entry.get(field) or (entry.get("properties") or {}).get(field)) + if current and current in {normalized(value) for value in values}: + return target, f"rule:{field}" + + kind = normalized(entry.get("kind") or entry.get("type")) + collection = normalized(entry.get("collection")) + status = normalized(entry.get("status")) + if collection == "templates" or kind == "template": + return "system", "structural" + if collection == "archive" or status in {"archive", "archived"}: + return "archive", "structural" + return "other", "fallback" + + +def apply_domain_classification(entries: list[dict[str, Any]], config: dict[str, Any]) -> None: + project_domains: dict[str, str] = {} + for entry in entries: + if str(entry.get("kind") or "").lower() != "project": + continue + project = normalized(entry.get("id") or entry.get("project")) + domain, _source = classify_entry(entry, config) + if project and domain != "other": + project_domains[project] = domain + for entry in entries: + domain, source = classify_entry(entry, config, project_domains) + entry["domain"] = domain + entry["domain_source"] = source diff --git a/scripts/workbench_paths.py b/scripts/workbench_paths.py new file mode 100644 index 0000000..d18971c --- /dev/null +++ b/scripts/workbench_paths.py @@ -0,0 +1,28 @@ +"""Shared filesystem locations for the application and the active vault.""" +from __future__ import annotations + +import os +from pathlib import Path + + +APP_ROOT = Path(__file__).resolve().parents[1] + + +def configured_vault_root() -> Path: + raw = os.environ.get("PM_VAULT_ROOT", "").strip() + return Path(raw).expanduser().resolve() if raw else (APP_ROOT / "vault").resolve() + + +def configured_output_root(vault_root: Path | None = None) -> Path: + raw = os.environ.get("PM_OUTPUT_ROOT", "").strip() + if raw: + return Path(raw).expanduser().resolve() + return ((vault_root or configured_vault_root()) / ".generated").resolve() + + +VAULT_ROOT = configured_vault_root() +OUTPUT_ROOT = configured_output_root(VAULT_ROOT) +DATA_ROOT = OUTPUT_ROOT / "data" +DASHBOARD_ROOT = OUTPUT_ROOT / "dashboard" +TEMPLATE_ROOT = APP_ROOT / "templates" +EXAMPLE_VAULT_ROOT = APP_ROOT / "example-vault" diff --git a/server/__init__.py b/server/__init__.py index 0c7bbb0..32e765a 100644 --- a/server/__init__.py +++ b/server/__init__.py @@ -1 +1 @@ -"""Markdown Task Manager API package.""" +"""Research Workbench backend.""" diff --git a/server/app.py b/server/app.py index e60ae20..a14c67e 100644 --- a/server/app.py +++ b/server/app.py @@ -1,8 +1,14 @@ from __future__ import annotations +import hashlib import hmac +import json +import logging import os import time +import urllib.error +import urllib.request +from datetime import date as Date, datetime, timedelta, timezone from pathlib import Path from typing import Any @@ -10,16 +16,40 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles -from pydantic import BaseModel, Field +from pydantic import BaseModel -from .github_sync import GitHubSyncError, GitHubSyncService -from .vault import ROOT as VAULT_ROOT -from .vault import SaveConflictError, VaultError, VaultService +from scripts.time_blocks import ( + allocate_daily_plan, + allocate_weekly_plan, + parse_clock, + parse_date, + task_minutes, + task_reason, +) +from scripts.calendar_feed import load_calendar_events_by_day +from scripts.seed_render_vault import calculate_drift, configured_path, repo_root, vault_manifest_path +from .github_sync import GitHubSyncError, GitHubSyncService, sync_files +from scripts.time_plan_context import build_agent_plan_context, read_time_planning_settings +from scripts.workbench_config import load_workbench_config, safe_ui_config +from scripts.workbench_paths import APP_ROOT, DASHBOARD_ROOT, DATA_ROOT +from .vault import ROOT as VAULT_ROOT, SaveConflictError, VaultError, VaultService -APP_ROOT = Path(__file__).resolve().parents[1] -DIST = APP_ROOT / "web" / "dist" -STARTED_AT = time.time() +logger = logging.getLogger("pm.performance") +PERF_LOGS_ENABLED = os.environ.get("PM_PERF_LOGS", "").lower() in {"1", "true", "yes", "on"} +LAST_HOSTED_SERVICE_SUCCESS_AT: str | None = None +LAST_VAULT_SCAN_AT: str | None = None +LAST_MARKDOWN_SAVE_AT: str | None = None +if PERF_LOGS_ENABLED: + logging.basicConfig(level=logging.INFO) + logger.setLevel(logging.INFO) + + +def log_perf(event: str, **fields: Any) -> None: + if not PERF_LOGS_ENABLED: + return + parts = " ".join(f"{key}={value}" for key, value in fields.items()) + logger.info("%s %s", event, parts) class SaveFileRequest(BaseModel): @@ -29,6 +59,12 @@ class SaveFileRequest(BaseModel): move_to: str | None = None +class TaskMetadataPatchRequest(BaseModel): + path: str + updates: dict[str, Any] + current_modified_at: float | None = None + + class MetadataPatchRequest(BaseModel): path: str updates: dict[str, Any] @@ -37,7 +73,7 @@ class MetadataPatchRequest(BaseModel): class CreateFileRequest(BaseModel): kind: str = "note" - title: str = Field(min_length=1, max_length=200) + title: str directory: str | None = None @@ -53,26 +89,325 @@ class GitHubSyncResetRequest(BaseModel): dry_run: bool = True -def configured_origins() -> list[str]: - defaults = { - "http://127.0.0.1:5173", - "http://localhost:5173", - "http://127.0.0.1:8765", - "http://localhost:8765", +def get_service() -> VaultService: + return VaultService(VAULT_ROOT) + + +def get_github_sync_service(service: VaultService = Depends(get_service)) -> GitHubSyncService: + return GitHubSyncService(service.root) + + +def is_task_entry(entry: dict[str, Any]) -> bool: + kind = str(entry.get("kind") or entry.get("type") or "").lower() + return kind == "task" or str(entry.get("path") or "").startswith("tasks/") + + +def entry_properties(entry: dict[str, Any]) -> dict[str, Any]: + return entry.get("properties") if isinstance(entry.get("properties"), dict) else {} + + +def normalized_token(value: Any) -> str: + return str(value or "").strip().strip('"').strip("'").lower().replace("_", "-") + + +def project_id_for_entry(entry: dict[str, Any]) -> str: + raw = entry.get("project") or entry.get("id") + if raw: + return str(raw).strip().strip('"').strip("'") + path = str(entry.get("path") or "") + parts = path.split("/") + if len(parts) >= 2 and parts[0] == "projects": + return parts[1] + return "" + + +def domain_from_token(value: Any) -> str: + token = normalized_token(value) + return token if token in {"research", "admin", "teaching", "personal", "system", "archive", "other"} else "" + + +def build_project_time_metadata(entries: list[dict[str, Any]]) -> dict[str, dict[str, str]]: + metadata: dict[str, dict[str, str]] = {} + for entry in entries: + kind = str(entry.get("kind") or entry.get("type") or "").lower() + if kind != "project": + continue + project_id = project_id_for_entry(entry) + if not project_id: + continue + properties = entry_properties(entry) + domain = domain_from_token(entry.get("domain") or properties.get("domain")) + area = domain_from_token(entry.get("area") or properties.get("area")) + metadata[project_id] = { + "domain": domain, + "area": area, + } + return metadata + + +def infer_time_domain(entry: dict[str, Any], project_metadata: dict[str, dict[str, str]]) -> str: + properties = entry_properties(entry) + direct = domain_from_token(entry.get("domain") or properties.get("domain")) + if direct: + return direct + area = domain_from_token(entry.get("area") or properties.get("area")) + if area: + return area + + project_id = project_id_for_entry(entry) + project_meta = project_metadata.get(project_id, {}) + if project_meta.get("domain"): + return project_meta["domain"] + if project_meta.get("area"): + return project_meta["area"] + + return "other" + + +def time_category_from_domain(domain: str) -> str: + if domain == "research": + return "research" + if domain == "admin": + return "admin" + return "other" + + +def time_plan_task(entry: dict[str, Any], project_metadata: dict[str, dict[str, str]] | None = None) -> dict[str, Any]: + project_metadata = project_metadata or {} + properties = entry.get("properties") if isinstance(entry.get("properties"), dict) else {} + domain = infer_time_domain(entry, project_metadata) + time_category = normalized_token(properties.get("time_category") or entry.get("time_category")) or time_category_from_domain(domain) + if time_category not in {"research", "admin", "other"}: + time_category = time_category_from_domain(domain) + return { + "id": entry.get("id") or entry.get("path"), + "path": entry.get("path", ""), + "title": entry.get("title", ""), + "project": entry.get("project", ""), + "domain": domain, + "time_category": time_category, + "status": entry.get("status") or "open", + "priority": entry.get("priority") or 9, + "urgent": bool(entry.get("urgent") or properties.get("urgent")), + "due": entry.get("due") or "", + "deadline_type": entry.get("deadline_type") or properties.get("deadline_type") or "", + "next": entry.get("next") or "", + "estimate_minutes": properties.get("estimate_minutes", properties.get("duration_minutes", 60)), + "block_day": properties.get("block_day", ""), + "block_week": properties.get("block_week", ""), + "time_block": properties.get("time_block", ""), + "private": bool(entry.get("private")), + } + + +def time_plan_project(entry: dict[str, Any]) -> dict[str, Any]: + return { + "id": entry.get("id") or entry.get("project") or "", + "name": entry.get("title") or entry.get("id") or entry.get("path") or "Untitled project", + "title": entry.get("title") or "", + "path": entry.get("path", ""), + "status": entry.get("status", ""), + "priority": entry.get("priority", ""), + "deadline": entry.get("deadline") or entry.get("due") or entry.get("date") or "", + "deadline_type": entry.get("deadline_type") or "", + "next_action": entry.get("next") or entry.get("snippet") or "", + "private": bool(entry.get("private")), + } + + +def time_task_summary(task: dict[str, Any], target_day: Date, week_prefix: str) -> dict[str, Any]: + minutes = task_minutes(task) + payload = { + "path": task.get("path", ""), + "title": task.get("title", ""), + "project": task.get("project", ""), + "domain": task.get("domain", ""), + "time_category": task.get("time_category", ""), + "priority": str(task.get("priority", "")) if task.get("priority") is not None else "", + "urgent": bool(task.get("urgent")), + "status": task.get("status", ""), + "due": task.get("due", ""), + "deadline_type": task.get("deadline_type", ""), + "next": task.get("next", ""), + "minutes": minutes, + "estimate_minutes": minutes, + "reason": task_reason(task, target_day, week_prefix), + "private": bool(task.get("private")), + "kind": task.get("kind") or "task", } - extra = { - value.strip() - for value in os.environ.get("PM_CORS_ORIGINS", "").split(",") - if value.strip() + if task.get("readonly"): + payload["readonly"] = bool(task.get("readonly")) + if task.get("source_label"): + payload["sourceLabel"] = task.get("source_label") + if task.get("source_event_id"): + payload["sourceEventId"] = task.get("source_event_id") + if task.get("all_day") is not None: + payload["allDay"] = bool(task.get("all_day")) + if task.get("blocking") is not None: + payload["blocking"] = bool(task.get("blocking")) + return payload + + +def time_block_payload(item: Any) -> dict[str, Any]: + payload = time_task_summary(item.task, item.day, "") + payload.update( + { + "start": item.start, + "end": item.end, + "minutes": item.minutes, + "reason": item.reason, + "segment_index": getattr(item, "segment_index", 1), + "segment_count": getattr(item, "segment_count", 1), + } + ) + return payload + + +def parse_plan_date(value: str | None, field: str, default: Date | None = None) -> Date: + if not value: + if default is not None: + return default + return Date.today() + parsed = parse_date(value) + if parsed is None: + raise HTTPException(status_code=400, detail=f"Invalid {field}; expected YYYY-MM-DD") + return parsed + + +def no_work_before_date(service: VaultService) -> Date | None: + raw = read_time_planning_settings(service.root).get("no_work_before") + if not raw: + return None + parsed = parse_date(raw) + if parsed is None: + return None + return parsed + + +def calendar_fetch_range(target_day: Date, week_start_day: Date, weekdays: int) -> tuple[Date, Date]: + start_day = min(target_day, week_start_day) + end_day = max(target_day + timedelta(days=1), week_start_day + timedelta(days=weekdays + 14)) + return start_day, end_day + + +def markdown_block_kind(entry: dict[str, Any]) -> str: + if is_task_entry(entry): + return "" + properties = entry_properties(entry) + status = normalized_token(entry.get("status") or properties.get("status")) + if status in {"cancelled", "canceled"}: + return "" + entry_type = normalized_token(entry.get("type") or properties.get("type")) + entry_kind = normalized_token(entry.get("kind") or properties.get("kind")) + if entry_type in {"conference", "travel"}: + return entry_type + if entry_kind in {"conference", "travel"}: + return entry_kind + return "" + + +def markdown_block_dates(entry: dict[str, Any], start_day: Date, end_day: Date) -> list[Date]: + properties = entry_properties(entry) + raw_start = ( + entry.get("start_date") + or properties.get("start_date") + or entry.get("date") + or properties.get("date") + ) + first_day = parse_date(raw_start) + if first_day is None: + return [] + raw_end = entry.get("end_date") or properties.get("end_date") or raw_start + last_day = parse_date(raw_end) or first_day + if last_day < first_day: + last_day = first_day + + days: list[Date] = [] + current = max(first_day, start_day) + final = min(last_day, end_day - timedelta(days=1)) + while current <= final: + days.append(current) + current += timedelta(days=1) + return days + + +def markdown_calendar_blocks_by_day(entries: list[dict[str, Any]], start_day: Date, end_day: Date) -> dict[Date, list[dict[str, Any]]]: + grouped: dict[Date, list[dict[str, Any]]] = {} + for entry in entries: + block_kind = markdown_block_kind(entry) + if not block_kind: + continue + for block_day in markdown_block_dates(entry, start_day, end_day): + grouped.setdefault(block_day, []).append( + { + "title": entry.get("title") or entry.get("id") or "Blocked day", + "path": entry.get("path") or "", + "project": entry.get("project") or "", + "block_kind": block_kind, + "private": bool(entry.get("private")), + } + ) + + events_by_day: dict[Date, list[dict[str, Any]]] = {} + for block_day, items in grouped.items(): + items.sort(key=lambda item: (item["block_kind"], item["title"])) + titles = [str(item["title"]) for item in items if item.get("title")] + kinds = sorted({str(item["block_kind"]) for item in items if item.get("block_kind")}) + if len(titles) == 1: + title = titles[0] + else: + title = f"{'/'.join(kinds).title()} day: {titles[0]} + {len(titles) - 1} more" + reason = "travel day" if kinds == ["travel"] else "conference day" if kinds == ["conference"] else "conference/travel day" + source_id = "|".join([block_day.isoformat(), *titles, *kinds]) + event_id = hashlib.sha256(source_id.encode("utf-8")).hexdigest()[:16] + events_by_day[block_day] = [ + { + "id": f"markdown-date-{event_id}", + "path": items[0].get("path") or f"calendar:markdown:{block_day.isoformat()}", + "title": title, + "date": block_day.isoformat(), + "source_label": "Markdown dates", + "source_event_id": str(items[0].get("path") or ""), + "all_day": True, + "reason": reason, + "private": any(bool(item.get("private")) for item in items), + } + ] + return events_by_day + + +def merge_calendar_imports(calendar_import: dict[str, Any], markdown_events_by_day: dict[Date, list[dict[str, Any]]]) -> dict[str, Any]: + imported_events = calendar_import.get("events_by_day") if isinstance(calendar_import.get("events_by_day"), dict) else {} + events_by_day: dict[Date, list[dict[str, Any]]] = { + day: list(events) + for day, events in imported_events.items() } - return sorted(defaults | extra) + for day, events in markdown_events_by_day.items(): + events_by_day.setdefault(day, []).extend(events) + for day_events in events_by_day.values(): + day_events.sort(key=lambda event: (event.get("start") or "00:00", event.get("title") or "")) + + status = dict(calendar_import.get("status") or {}) + markdown_event_count = sum(len(events) for events in markdown_events_by_day.values()) + labels = list(status.get("source_labels") or []) + if markdown_event_count and "Markdown dates" not in labels: + labels.append("Markdown dates") + status.update( + { + "enabled": bool(status.get("enabled")) or markdown_event_count > 0, + "event_count": int(status.get("event_count") or 0) + markdown_event_count, + "source_labels": labels, + "errors": list(status.get("errors") or []), + "last_fetch": str(status.get("last_fetch") or ""), + } + ) + return {"status": status, "events_by_day": events_by_day} def is_local_request(request: Request) -> bool: - client_host = request.client.host if request.client else "" - request_host = (request.url.hostname or "").lower() - local_hosts = {"127.0.0.1", "::1", "localhost", "testclient", "testserver"} - return client_host in local_hosts and request_host in local_hosts + host = request.client.host if request.client else "" + require_local_auth = os.environ.get("PM_REQUIRE_LOCAL_AUTH", "").strip().lower() in {"1", "true", "yes", "on"} + return not require_local_auth and host in {"127.0.0.1", "::1", "localhost", "testclient"} async def require_auth( @@ -80,71 +415,364 @@ async def require_auth( authorization: str | None = Header(default=None), x_pm_app_token: str | None = Header(default=None), ) -> None: - if is_local_request(request) and os.environ.get("PM_REQUIRE_LOCAL_AUTH", "").lower() not in {"1", "true", "yes"}: + if is_local_request(request): return - expected = os.environ.get("PM_APP_TOKEN", "") + expected = os.environ.get("PM_APP_TOKEN") if not expected: raise HTTPException(status_code=503, detail="PM_APP_TOKEN is required for non-local requests") supplied = x_pm_app_token if authorization and authorization.lower().startswith("bearer "): supplied = authorization.split(" ", 1)[1].strip() + # Constant-time comparison so a timing side channel cannot reveal the token + # byte-by-byte. if not supplied or not hmac.compare_digest(supplied, expected): raise HTTPException(status_code=401, detail="Invalid or missing app token") -def get_service() -> VaultService: - return VaultService(VAULT_ROOT) - - -def get_sync_service(service: VaultService = Depends(get_service)) -> GitHubSyncService: - return GitHubSyncService(service.root) - - -app = FastAPI( - title="Markdown Task Manager", - version="1.0.0", - description="A private-by-default Markdown project and task workbench.", -) +app = FastAPI(title="Research Workbench", version="1.1.0") +configured_origins = [ + origin.strip() + for origin in os.environ.get("PM_CORS_ORIGINS", "").split(",") + if origin.strip() +] app.add_middleware( CORSMiddleware, - allow_origins=configured_origins(), - allow_credentials=False, - allow_methods=["GET", "POST", "PUT", "PATCH"], - allow_headers=["Authorization", "Content-Type", "X-PM-App-Token"], + allow_origins=[ + "http://127.0.0.1:5173", + "http://localhost:5173", + "http://127.0.0.1:8765", + "http://localhost:8765", + *configured_origins, + ], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], ) @app.middleware("http") -async def security_headers(request: Request, call_next): +async def log_request_timing(request: Request, call_next): + started = time.perf_counter() response = await call_next(request) - response.headers["X-Content-Type-Options"] = "nosniff" - response.headers["Referrer-Policy"] = "no-referrer" - response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()" - response.headers["Content-Security-Policy"] = ( - "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; " - "img-src 'self' data:; font-src 'self'; connect-src 'self'" - ) - if request.url.path.startswith("/api/"): - response.headers["Cache-Control"] = "no-store" + if request.url.path == "/dashboard" or request.url.path.startswith("/dashboard/"): + response.headers["Cache-Control"] = "no-store, max-age=0" + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" + if PERF_LOGS_ENABLED and request.url.path.startswith("/api/"): + elapsed_ms = round((time.perf_counter() - started) * 1000, 2) + logger.info( + "api.request method=%s path=%s status=%s elapsed_ms=%s", + request.method, + request.url.path, + response.status_code, + elapsed_ms, + ) return response @app.get("/api/health") async def health() -> dict[str, Any]: - exists = VAULT_ROOT.exists() and VAULT_ROOT.is_dir() - return { - "ok": exists, - "vault_available": exists, + app_commit = ( + os.environ.get("RENDER_GIT_COMMIT") + or os.environ.get("GIT_COMMIT") + or os.environ.get("SOURCE_VERSION") + ) + vault_exists = VAULT_ROOT.exists() + payload: dict[str, Any] = { + "ok": vault_exists, + "vault_root_exists": vault_exists, "auth_configured": bool(os.environ.get("PM_APP_TOKEN")), - "github_sync_enabled": os.environ.get("PM_GITHUB_SYNC_ENABLED", "").lower() in {"1", "true", "yes"}, - "uptime_seconds": max(0, int(time.time() - STARTED_AT)), - "version": app.version, + "seed_configured": bool(os.environ.get("PM_VAULT_SEED_SOURCE")), + "app_commit": app_commit, + "vault_manifest_exists": vault_manifest_path(VAULT_ROOT).exists(), + } + if not vault_exists: + payload["error"] = "Vault root does not exist" + return payload + + +@app.get("/api/config", dependencies=[Depends(require_auth)]) +async def config(service: VaultService = Depends(get_service)) -> dict[str, Any]: + """Return display-safe configuration without filesystem paths or secrets.""" + return safe_ui_config(load_workbench_config(service.root)) + + +def file_mtime(path: Path) -> float | None: + try: + return path.stat().st_mtime + except OSError: + return None + + +def short_vault_hash(root: Path) -> dict[str, Any]: + digest = hashlib.sha256() + files = sync_files(root) + for raw_path in files: + rel_path = Path(raw_path) + path = rel_path if rel_path.is_absolute() else root / rel_path + rel = rel_path.as_posix() if not rel_path.is_absolute() else path.relative_to(root).as_posix() + digest.update(rel.encode("utf-8")) + digest.update(b"\0") + try: + digest.update(path.read_bytes()) + except OSError: + continue + digest.update(b"\0") + return {"algorithm": "sha256", "value": digest.hexdigest(), "file_count": len(files)} + + +def utc_now_iso() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +def mark_vault_scan() -> str: + global LAST_VAULT_SCAN_AT + LAST_VAULT_SCAN_AT = utc_now_iso() + return LAST_VAULT_SCAN_AT + + +def mark_markdown_save() -> str: + global LAST_MARKDOWN_SAVE_AT + LAST_MARKDOWN_SAVE_AT = utc_now_iso() + return LAST_MARKDOWN_SAVE_AT + + +def entry_counts(entries: list[dict[str, Any]]) -> dict[str, int]: + return { + "entries": len(entries), + "tasks": sum(1 for entry in entries if entry.get("kind") == "task"), + "projects": sum(1 for entry in entries if entry.get("kind") == "project"), + } + + +def seed_manifest_status(root: Path) -> dict[str, Any]: + path = vault_manifest_path(root) + payload: dict[str, Any] = { + "exists": path.exists(), + "path": str(path), + "generated_at": None, + "mode": None, + "seed_source": None, + "app_commit": None, + "counts": {}, + "error": "", + } + if not path.exists(): + return payload + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + payload["error"] = str(exc) + return payload + if not isinstance(raw, dict): + payload["error"] = "Seed manifest is not a JSON object" + return payload + counts = raw.get("counts") + payload.update( + { + "generated_at": raw.get("generated_at"), + "mode": raw.get("mode"), + "seed_source": raw.get("seed_source"), + "app_commit": raw.get("app_commit"), + "counts": counts if isinstance(counts, dict) else {}, + } + ) + return payload + + +def generated_cache_freshness(root: Path) -> dict[str, Any]: + source_files = sync_files(root) + newest_source = max( + (file_mtime(path if (path := Path(raw_path)).is_absolute() else root / path) or 0 for raw_path in source_files), + default=0, + ) + generated_paths = [ + DATA_ROOT / "projects.json", + DATA_ROOT / "tasks.json", + DATA_ROOT / "vault_entries.json", + DATA_ROOT / "relationships.json", + DASHBOARD_ROOT / "boards.html", + DASHBOARD_ROOT / "calendar.html", + DASHBOARD_ROOT / "graph.html", + DASHBOARD_ROOT / "index.html", + DASHBOARD_ROOT / "public.html", + DASHBOARD_ROOT / "status.html", + DASHBOARD_ROOT / "today.html", + ] + files = [] + stale = False + for path in generated_paths: + mtime = file_mtime(path) + fresh = bool(mtime and (not newest_source or mtime >= newest_source)) + if not fresh: + stale = True + files.append( + { + "path": path.relative_to(DATA_ROOT.parent).as_posix(), + "exists": mtime is not None, + "modified_at": mtime, + "fresh": fresh, + } + ) + return { + "fresh": not stale, + "source_file_count": len(source_files), + "newest_source_modified_at": newest_source or None, + "files": files, + } + + +def hosted_service_check_url() -> str: + exact = os.environ.get("PM_PUBLIC_DASHBOARD_URL") or os.environ.get("PM_HOSTED_HEALTH_URL") + if exact: + return exact + base = os.environ.get("PM_SELF_HOSTED_URL") or os.environ.get("PM_RENDER_SERVICE_URL") or os.environ.get("RENDER_EXTERNAL_URL") + if not base: + return "" + return f"{base.rstrip('/')}/api/health" + + +def hosted_service_availability() -> dict[str, Any]: + global LAST_HOSTED_SERVICE_SUCCESS_AT + + url = hosted_service_check_url() + checked_at = datetime.now(timezone.utc).isoformat() + if not url: + return { + "configured": False, + "url": "", + "checked_at": checked_at, + "ok": None, + "status_code": None, + "last_success_at": LAST_HOSTED_SERVICE_SUCCESS_AT, + "error": "Set PM_HOSTED_HEALTH_URL or PM_SELF_HOSTED_URL to enable live checks.", + } + + try: + request = urllib.request.Request(url, headers={"User-Agent": "research-workbench-diagnostics"}) + with urllib.request.urlopen(request, timeout=2) as response: + status_code = int(response.getcode()) + ok = 200 <= status_code < 400 + if ok: + LAST_HOSTED_SERVICE_SUCCESS_AT = checked_at + return { + "configured": True, + "url": url, + "checked_at": checked_at, + "ok": ok, + "status_code": status_code, + "last_success_at": LAST_HOSTED_SERVICE_SUCCESS_AT, + "error": "" if ok else f"HTTP {status_code}", + } + except urllib.error.HTTPError as exc: + return { + "configured": True, + "url": url, + "checked_at": checked_at, + "ok": False, + "status_code": int(exc.code), + "last_success_at": LAST_HOSTED_SERVICE_SUCCESS_AT, + "error": f"HTTP {exc.code}", + } + except (urllib.error.URLError, TimeoutError, OSError) as exc: + return { + "configured": True, + "url": url, + "checked_at": checked_at, + "ok": False, + "status_code": None, + "last_success_at": LAST_HOSTED_SERVICE_SUCCESS_AT, + "error": str(exc), + } + + +@app.get("/api/diagnostics", dependencies=[Depends(require_auth)]) +async def diagnostics( + service: VaultService = Depends(get_service), + sync: GitHubSyncService = Depends(get_github_sync_service), +) -> dict[str, Any]: + health_payload = await health() + sync_payload = sync.status() + actions = sync_payload.get("actions") if isinstance(sync_payload.get("actions"), dict) else {} + entries = service.scan_entries() + scan_at = mark_vault_scan() + return { + "app_commit": health_payload.get("app_commit"), + "vault_root": str(service.root), + "vault_hash": short_vault_hash(service.root), + "entry_counts": entry_counts(entries), + "last_vault_scan_at": scan_at, + "last_markdown_save_at": LAST_MARKDOWN_SAVE_AT, + "seed": seed_manifest_status(service.root), + "generated_cache": generated_cache_freshness(service.root), + "hosted_service": hosted_service_availability(), + "github_sync": { + "enabled": sync_payload.get("enabled"), + "configured": sync_payload.get("configured"), + "token_configured": sync_payload.get("token_configured"), + "git_available": sync_payload.get("git_available"), + "repo": sync_payload.get("repo"), + "branch": sync_payload.get("branch"), + "remote_head": sync_payload.get("remote_head"), + "vault_file_count": sync_payload.get("vault_file_count"), + "errors": sync_payload.get("errors") or [], + }, + "actions": { + "latest": actions.get("latest"), + "errors": actions.get("errors") or [], + }, } +@app.get("/api/vault/drift", dependencies=[Depends(require_auth)]) +async def vault_drift( + include_paths: bool = Query(default=False), + service: VaultService = Depends(get_service), +) -> dict[str, Any]: + source = configured_path("PM_VAULT_SEED_SOURCE", repo_root()) + if source is None or not source.exists(): + raise HTTPException(status_code=400, detail=f"Seed source does not exist: {source}") + drift = calculate_drift(source, service.root, include_paths=include_paths) + drift["seed_source"] = str(source) + drift["vault_root"] = str(service.root) + drift["vault_manifest_exists"] = vault_manifest_path(service.root).exists() + return drift + + @app.get("/api/vault/entries", dependencies=[Depends(require_auth)]) async def entries(service: VaultService = Depends(get_service)) -> dict[str, Any]: - return {"entries": service.scan_entries()} + entries_payload = service.scan_entries() + mark_vault_scan() + return {"entries": entries_payload} + + +@app.get("/api/scholar", dependencies=[Depends(require_auth)]) +async def scholar(service: VaultService = Depends(get_service)) -> dict[str, Any]: + """Serve the optional scholar-statistics snapshot configured by the vault. + + The workbench reads a stored snapshot rather than scraping any external + profile. The configured path must remain inside the active vault. + """ + workbench_config = load_workbench_config(service.root) + profile = workbench_config.get("profile") if isinstance(workbench_config.get("profile"), dict) else {} + relative_source = str(profile.get("scholar_statistics_source") or "").strip() + if not relative_source: + return {"available": False} + snapshot = (service.root / relative_source).resolve() + try: + snapshot.relative_to(service.root) + except ValueError: + return {"available": False} + if not snapshot.exists(): + return {"available": False} + try: + data = json.loads(snapshot.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {"available": False} + if not isinstance(data, dict): + return {"available": False} + data["available"] = True + return data @app.get("/api/vault/file", dependencies=[Depends(require_auth)]) @@ -154,10 +782,10 @@ async def file( ) -> dict[str, Any]: try: return service.get_file(path) - except FileNotFoundError as exc: - raise HTTPException(status_code=404, detail="File not found") from exc except VaultError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail="File not found") from exc @app.put("/api/vault/file", dependencies=[Depends(require_auth)]) @@ -166,7 +794,9 @@ async def save_file( service: VaultService = Depends(get_service), ) -> dict[str, Any]: try: - return service.save_file(payload.path, payload.content, payload.current_modified_at, payload.move_to) + result = service.save_file(payload.path, payload.content, payload.current_modified_at, payload.move_to) + mark_markdown_save() + return result except SaveConflictError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc except VaultError as exc: @@ -175,11 +805,13 @@ async def save_file( @app.patch("/api/vault/task-metadata", dependencies=[Depends(require_auth)]) async def patch_task_metadata( - payload: MetadataPatchRequest, + payload: TaskMetadataPatchRequest, service: VaultService = Depends(get_service), ) -> dict[str, Any]: try: - return service.patch_task_metadata(payload.path, payload.updates, payload.current_modified_at) + result = service.patch_task_metadata(payload.path, payload.updates, payload.current_modified_at) + mark_markdown_save() + return result except SaveConflictError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc except VaultError as exc: @@ -192,60 +824,62 @@ async def patch_metadata( service: VaultService = Depends(get_service), ) -> dict[str, Any]: try: - return service.patch_metadata(payload.path, payload.updates, payload.current_modified_at) + result = service.patch_metadata(payload.path, payload.updates, payload.current_modified_at) + mark_markdown_save() + return result except SaveConflictError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc except VaultError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc +@app.post("/api/vault/reload", dependencies=[Depends(require_auth)]) +async def reload_vault(service: VaultService = Depends(get_service)) -> dict[str, Any]: + service.invalidate_scan_cache() + entries_payload = service.scan_entries(force=True) + mark_vault_scan() + return {"entries": entries_payload} + + @app.post("/api/vault/create", dependencies=[Depends(require_auth)]) async def create_file( payload: CreateFileRequest, service: VaultService = Depends(get_service), ) -> dict[str, Any]: try: - return service.create_file(payload.kind, payload.title, payload.directory) + result = service.create_file(payload.kind, payload.title, payload.directory) + mark_markdown_save() + return result except VaultError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc -@app.post("/api/vault/reload", dependencies=[Depends(require_auth)]) -async def reload_vault(service: VaultService = Depends(get_service)) -> dict[str, Any]: - service.invalidate_scan_cache() - return {"entries": service.scan_entries(force=True)} - - @app.get("/api/git/status", dependencies=[Depends(require_auth)]) async def git_status(service: VaultService = Depends(get_service)) -> dict[str, Any]: return service.git_status() @app.get("/api/github-sync/status", dependencies=[Depends(require_auth)]) -async def github_sync_status(sync: GitHubSyncService = Depends(get_sync_service)) -> dict[str, Any]: +async def github_sync_status(sync: GitHubSyncService = Depends(get_github_sync_service)) -> dict[str, Any]: return sync.status() @app.post("/api/github-sync/push", dependencies=[Depends(require_auth)]) async def github_sync_push( payload: GitHubSyncPushRequest, - sync: GitHubSyncService = Depends(get_sync_service), + sync: GitHubSyncService = Depends(get_github_sync_service), ) -> dict[str, Any]: try: - return sync.push( - payload.commit_message, - dry_run=payload.dry_run, - delete_missing=payload.delete_missing, - ) + return sync.push(payload.commit_message, dry_run=payload.dry_run, delete_missing=payload.delete_missing) except GitHubSyncError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @app.post("/api/github-sync/reset-from-github", dependencies=[Depends(require_auth)]) -async def github_sync_reset( +async def github_sync_reset_from_github( payload: GitHubSyncResetRequest, service: VaultService = Depends(get_service), - sync: GitHubSyncService = Depends(get_sync_service), + sync: GitHubSyncService = Depends(get_github_sync_service), ) -> dict[str, Any]: try: result = sync.reset_from_github( @@ -260,11 +894,179 @@ async def github_sync_reset( raise HTTPException(status_code=400, detail=str(exc)) from exc +@app.get("/api/time-plan", dependencies=[Depends(require_auth)]) +async def time_plan( + date_value: str | None = Query(default=None, alias="date"), + week_start: str | None = Query(default=None), + start: str = Query(default="09:00"), + capacity_minutes: int = Query(default=420, ge=1, le=1440), + weekdays: int = Query(default=5, ge=1, le=14), + horizon_days: int = Query(default=14, ge=0, le=365), + service: VaultService = Depends(get_service), +) -> dict[str, Any]: + target_day = parse_plan_date(date_value, "date") + week_start_day = parse_plan_date(week_start, "week_start", target_day) + week_prefix = week_start_day.isoformat()[:8] + try: + parse_clock(start) + except Exception as exc: + raise HTTPException(status_code=400, detail="Invalid start; expected HH:MM") from exc + + endpoint_started = time.perf_counter() + entries = service.scan_entries() + project_time_metadata = build_project_time_metadata(entries) + tasks = [time_plan_task(entry, project_time_metadata) for entry in entries if is_task_entry(entry)] + no_work_before = no_work_before_date(service) + calendar_start, calendar_end = calendar_fetch_range(target_day, week_start_day, weekdays) + calendar_started = time.perf_counter() + calendar_import = load_calendar_events_by_day(service.root, calendar_start, calendar_end) + markdown_calendar_events = markdown_calendar_blocks_by_day(entries, calendar_start, calendar_end) + calendar_import = merge_calendar_imports(calendar_import, markdown_calendar_events) + calendar_events_by_day = calendar_import["events_by_day"] + calendar_ms = round((time.perf_counter() - calendar_started) * 1000, 2) + try: + allocation_started = time.perf_counter() + daily_blocks, daily_overflow = allocate_daily_plan( + tasks, + target_day, + start=start, + capacity_minutes=capacity_minutes, + horizon_days=horizon_days, + week_prefix=week_prefix, + no_work_before=no_work_before, + calendar_events=calendar_events_by_day.get(target_day, []), + ) + weekly_blocks, weekly_overflow = allocate_weekly_plan( + tasks, + week_start_day, + start=start, + capacity_minutes=capacity_minutes, + weekdays=weekdays, + horizon_days=horizon_days, + week_prefix=week_prefix, + no_work_before=no_work_before, + calendar_events_by_day=calendar_events_by_day, + ) + allocation_ms = round((time.perf_counter() - allocation_started) * 1000, 2) + except Exception as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + log_perf( + "time_plan.build", + entries=len(entries), + tasks=len(tasks), + calendar_ms=calendar_ms, + allocation_ms=allocation_ms, + elapsed_ms=round((time.perf_counter() - endpoint_started) * 1000, 2), + ) + + return { + "settings": { + "date": target_day.isoformat(), + "week_start": week_start_day.isoformat(), + "start": start, + "capacity_minutes": capacity_minutes, + "weekdays": weekdays, + "horizon_days": horizon_days, + "no_work_before": no_work_before.isoformat() if no_work_before else "", + }, + "daily": { + "date": target_day.isoformat(), + "total_minutes": sum(item.minutes for item in daily_blocks), + "blocks": [time_block_payload(item) for item in daily_blocks], + }, + "daily_overflow": [time_task_summary(task, target_day, week_prefix) for task in daily_overflow], + "weekly": [ + { + "date": day.isoformat(), + "total_minutes": sum(item.minutes for item in items), + "blocks": [time_block_payload(item) for item in items], + } + for day, items in weekly_blocks.items() + ], + "weekly_overflow": [time_task_summary(task, week_start_day, week_prefix) for task in weekly_overflow], + "calendar": calendar_import["status"], + } + + +@app.get("/api/time-plan/agent-context", dependencies=[Depends(require_auth)]) +async def time_plan_agent_context( + date_value: str | None = Query(default=None, alias="date"), + week_start: str | None = Query(default=None), + start: str = Query(default="09:00"), + capacity_minutes: int = Query(default=420, ge=1, le=1440), + weekdays: int = Query(default=5, ge=1, le=14), + horizon_days: int = Query(default=14, ge=0, le=365), + ad_hoc: str = Query(default=""), + include_private: bool = Query(default=False), + service: VaultService = Depends(get_service), +) -> dict[str, Any]: + target_day = parse_plan_date(date_value, "date") + week_start_day = parse_plan_date(week_start, "week_start", target_day) + week_prefix = week_start_day.isoformat()[:8] + try: + parse_clock(start) + except Exception as exc: + raise HTTPException(status_code=400, detail="Invalid start; expected HH:MM") from exc + + endpoint_started = time.perf_counter() + entries = service.scan_entries() + project_time_metadata = build_project_time_metadata(entries) + tasks = [time_plan_task(entry, project_time_metadata) for entry in entries if is_task_entry(entry)] + calendar_start, calendar_end = calendar_fetch_range(target_day, week_start_day, weekdays) + calendar_started = time.perf_counter() + calendar_import = load_calendar_events_by_day(service.root, calendar_start, calendar_end) + markdown_calendar_events = markdown_calendar_blocks_by_day(entries, calendar_start, calendar_end) + calendar_import = merge_calendar_imports(calendar_import, markdown_calendar_events) + calendar_ms = round((time.perf_counter() - calendar_started) * 1000, 2) + projects = [ + time_plan_project(entry) + for entry in entries + if str(entry.get("kind") or entry.get("type") or "").lower() == "project" + ] + try: + context_started = time.perf_counter() + context = build_agent_plan_context( + root=service.root, + tasks=tasks, + projects=projects, + target_day=target_day, + week_start_day=week_start_day, + start=start, + capacity_minutes=capacity_minutes, + weekdays=weekdays, + horizon_days=horizon_days, + week_prefix=week_prefix, + ad_hoc=ad_hoc, + include_private=include_private, + calendar_events_by_day=calendar_import["events_by_day"], + calendar_status=calendar_import["status"], + ) + log_perf( + "time_plan.agent_context", + entries=len(entries), + tasks=len(tasks), + projects=len(projects), + calendar_ms=calendar_ms, + context_ms=round((time.perf_counter() - context_started) * 1000, 2), + elapsed_ms=round((time.perf_counter() - endpoint_started) * 1000, 2), + ) + return context + except Exception as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +DIST = APP_ROOT / "web" / "dist" + +if DASHBOARD_ROOT.exists(): + app.mount("/dashboard", StaticFiles(directory=DASHBOARD_ROOT, html=True), name="dashboard") + if DIST.exists(): app.mount("/assets", StaticFiles(directory=DIST / "assets"), name="assets") @app.get("/{full_path:path}", include_in_schema=False) async def serve_app(full_path: str): + # Never serve the SPA shell for an unknown API path: returning index.html + # with HTTP 200 masks bugs and breaks JSON clients. 404 instead. if full_path == "api" or full_path.startswith("api/"): return JSONResponse({"detail": "Not found"}, status_code=404) requested = (DIST / full_path).resolve() diff --git a/server/github_sync.py b/server/github_sync.py index 770b331..e09774d 100644 --- a/server/github_sync.py +++ b/server/github_sync.py @@ -17,10 +17,6 @@ SYNC_ROOT_FILES = { - "AGENTS.md", - "CLAUDE.md", - "GEMINI.md", - "OPENCLAW.md", "README.md", "START_HERE.md", } @@ -31,7 +27,6 @@ "_inbox", "archive", "areas", - "config", "dates", "docs", "journal", @@ -57,7 +52,7 @@ "imports", "local_data", "large_data", - ".secrets", + "vault", } SYNC_EXCLUDED_SUFFIXES = {".sqlite", ".db", ".dta", ".parquet", ".feather", ".rds", ".pyc"} @@ -88,9 +83,9 @@ def from_env(cls) -> "GitHubSyncConfig": repo=repo, branch=os.environ.get("PM_GITHUB_BRANCH", "main").strip() or "main", token=os.environ.get("PM_GITHUB_TOKEN", "").strip(), - author_name=os.environ.get("PM_GITHUB_AUTHOR_NAME", "Render Task Manager").strip() or "Render Task Manager", - author_email=os.environ.get("PM_GITHUB_AUTHOR_EMAIL", "render-task-manager@example.invalid").strip() - or "render-task-manager@example.invalid", + author_name=os.environ.get("PM_GITHUB_AUTHOR_NAME", "Research Workbench Automation").strip() or "Research Workbench Automation", + author_email=os.environ.get("PM_GITHUB_AUTHOR_EMAIL", "workbench-automation@example.invalid").strip() + or "workbench-automation@example.invalid", remote_url_override=os.environ.get("PM_GITHUB_REMOTE_URL", "").strip(), sparse_dirs=sparse_dirs, ) @@ -259,6 +254,7 @@ def status(self) -> dict[str, Any]: remote_head = "" payload.update( { + "vault_root": str(self.root), "git_available": self.git_available(), "vault_file_count": len(sync_files(self.root)), "remote_head": remote_head, @@ -336,7 +332,7 @@ def github_api_json(self, path: str, query: dict[str, str]) -> dict[str, Any]: headers={ "Accept": "application/vnd.github+json", "Authorization": f"Bearer {self.config.token}", - "User-Agent": "task-manager-github-sync", + "User-Agent": "research-workbench-github-sync", "X-GitHub-Api-Version": "2022-11-28", }, ) diff --git a/server/vault.py b/server/vault.py index a888b38..694dbc5 100644 --- a/server/vault.py +++ b/server/vault.py @@ -4,6 +4,7 @@ import logging import os import re +import shutil import subprocess import tempfile import time @@ -15,6 +16,8 @@ import yaml +from scripts.workbench_config import apply_domain_classification, load_workbench_config +from scripts.workbench_paths import VAULT_ROOT from scripts.vault_parsing import ( # shared frontmatter/body primitives CODEX_INSTRUCTION_ITEM_RE, CODEX_INSTRUCTIONS_HEADING_RE, @@ -33,8 +36,7 @@ ) -APP_ROOT = Path(__file__).resolve().parents[1] -ROOT = Path(os.environ.get("PM_VAULT_ROOT", APP_ROOT / "vault")).expanduser().resolve() +ROOT = VAULT_ROOT # Server-specific: parse_file promotes assignee/assigned_to/domain to top-level # entry fields, so they are core here. See scripts/vault_parsing.py for why this @@ -105,6 +107,8 @@ "exports/private", "areas/health/private", "areas/health/imports", + "areas/wellness/private", + "areas/wellness/imports", "web/node_modules", "web/dist", "web/.tmp", @@ -630,6 +634,7 @@ def scan_entries(self, force: bool = False) -> list[dict[str, Any]]: entries = entries_response_copy(list(parsed_entries.values())) entries.sort(key=lambda e: (e.get("modified_at") or 0), reverse=True) + apply_domain_classification(entries, load_workbench_config(self.root)) backlink_started = time.perf_counter() linked_entries = self.add_backlinks(entries) backlink_ms = round((time.perf_counter() - backlink_started) * 1000, 2) @@ -680,6 +685,7 @@ def scan_entries_uncached(self) -> list[dict[str, Any]]: } ) entries.sort(key=lambda e: (e.get("modified_at") or 0), reverse=True) + apply_domain_classification(entries, load_workbench_config(self.root)) return self.add_backlinks(entries) def parse_file(self, path: Path) -> dict[str, Any]: @@ -991,3 +997,8 @@ def git_status(self) -> dict[str, Any]: return {"enabled": True, "branch": None, "changed": [], "error": "Could not read git status"} changed = [{"status": line[:2].strip(), "path": line[3:]} for line in raw.splitlines() if line.strip()] return {"enabled": True, "branch": branch or None, "changed": changed} + +def copy_vault_fixture(src: Path, dst: Path) -> None: + if dst.exists(): + shutil.rmtree(dst) + shutil.copytree(src, dst) diff --git a/templates/academic/coauthor_update.md b/templates/academic/coauthor_update.md new file mode 100644 index 0000000..368dc58 --- /dev/null +++ b/templates/academic/coauthor_update.md @@ -0,0 +1,32 @@ +--- +kind: coauthor-update +project: "{{project}}" +title: "{{title}}" +date: {{date}} +status: draft +--- +# Coauthor update — {{title}} + +## What changed + +- + +## Blockers + +- + +## Decisions needed + +- [ ] + +## Division of labor + +| Person | Task | Due | +|---|---|---:| +| | | | + +## Email draft + +```text + +``` diff --git a/templates/academic/code_run_log.md b/templates/academic/code_run_log.md new file mode 100644 index 0000000..ef18dce --- /dev/null +++ b/templates/academic/code_run_log.md @@ -0,0 +1,47 @@ +--- +kind: code-run-log +project: "{{project}}" +title: "{{title}}" +date: {{date}} +status: draft +--- +# Code run — {{title}} + +## Goal + +- + +## Environment + +- Machine: +- Repo path: +- Branch/commit: +- Runtime: + +## Command(s) + +```bash + +``` + +## Inputs + +- + +## Outputs + +- + +## Result / error + +- + +## Checks + +- [ ] Output dimensions make sense +- [ ] Key summary statistics checked +- [ ] Logs saved + +## Next debugging step + +- [ ] diff --git a/templates/academic/data_note.md b/templates/academic/data_note.md new file mode 100644 index 0000000..e866f74 --- /dev/null +++ b/templates/academic/data_note.md @@ -0,0 +1,43 @@ +--- +kind: data-note +project: "{{project}}" +title: "{{title}}" +date: {{date}} +status: draft +--- +# Data note — {{title}} + +## Source + +- Source: +- Access/path: +- Restrictions: +- Vintage/date downloaded: + +## Variables + +| Variable | Definition | Unit | Source | Issues | +|---|---|---|---|---| +| | | | | | + +## Cleaning decisions + +- + +## Quality checks + +- [ ] Row counts +- [ ] Missing values +- [ ] Duplicates +- [ ] Units/ranges +- [ ] Reproducible path documented + +## Output files + +| File | Purpose | Created by | Notes | +|---|---|---|---| +| | | | | + +## Next action + +- [ ] diff --git a/templates/academic/derivation_note.md b/templates/academic/derivation_note.md new file mode 100644 index 0000000..b035765 --- /dev/null +++ b/templates/academic/derivation_note.md @@ -0,0 +1,41 @@ +--- +kind: derivation-note +project: "{{project}}" +title: "{{title}}" +date: {{date}} +status: draft +--- +# Derivation — {{title}} + +## Claim + +- + +## Assumptions + +1. +2. + +## Notation + +| Symbol | Meaning | +|---|---| +| | | + +## Derivation + +```math + +``` + +## Edge cases / limits + +- + +## Relation to paper/model + +- + +## What to ask coauthors + +- [ ] diff --git a/templates/academic/feedback_note.md b/templates/academic/feedback_note.md new file mode 100644 index 0000000..d937347 --- /dev/null +++ b/templates/academic/feedback_note.md @@ -0,0 +1,43 @@ +--- +kind: feedback-note +project: "{{project}}" +title: "{{title}}" +date: {{date}} +source_type: "coauthor/seminar/referee/editor/self" +source: "" +status: unprocessed +--- +# Feedback — {{title}} + +## Context + +- Version/talk/draft: +- Source: + +## Must address + +- [ ] + +## Important but optional + +- [ ] + +## Parking lot + +- [ ] + +## Interpretation + +- + +## Action conversion + +| Action | Owner | Due | Status | +|---|---|---:|---| +| | | | open | + +## Response / draft language + +```text + +``` diff --git a/templates/academic/idea_note.md b/templates/academic/idea_note.md new file mode 100644 index 0000000..51b0150 --- /dev/null +++ b/templates/academic/idea_note.md @@ -0,0 +1,32 @@ +--- +kind: idea-note +project: "{{project}}" +title: "{{title}}" +date: {{date}} +status: seed +--- +# Idea — {{title}} + +## Core idea + +- + +## Why it might matter + +- + +## Minimal test + +- + +## Data/model needed + +- + +## Kill criteria + +- + +## Next action + +- [ ] diff --git a/templates/academic/literature_note.md b/templates/academic/literature_note.md new file mode 100644 index 0000000..840e978 --- /dev/null +++ b/templates/academic/literature_note.md @@ -0,0 +1,34 @@ +--- +kind: literature-note +project: "{{project}}" +title: "{{title}}" +date: {{date}} +authors: "" +year: "" +status: draft +--- +# Literature note — {{title}} + +## Citation + +- + +## Core contribution + +- + +## Model / empirical design + +- + +## Key result + +- + +## Relevance for this project + +- + +## What to cite it for + +- diff --git a/templates/academic/meeting_note.md b/templates/academic/meeting_note.md new file mode 100644 index 0000000..5a57a4e --- /dev/null +++ b/templates/academic/meeting_note.md @@ -0,0 +1,40 @@ +--- +kind: meeting-note +project: "{{project}}" +title: "{{title}}" +date: {{date}} +attendees: [] +status: draft +--- +# Meeting — {{title}} + +## Purpose + +- + +## Agenda + +- [ ] +- [ ] + +## Notes + +- + +## Decisions + +| Decision | Rationale | Owner | Date | +|---|---|---|---| +| | | | {{date}} | + +## Action items + +| Task | Owner | Due | Status | +|---|---|---:|---| +| | | | open | + +## Follow-up email draft + +```text + +``` diff --git a/templates/academic/model_note.md b/templates/academic/model_note.md new file mode 100644 index 0000000..89af3a6 --- /dev/null +++ b/templates/academic/model_note.md @@ -0,0 +1,63 @@ +--- +kind: model-note +project: "{{project}}" +title: "{{title}}" +date: {{date}} +status: draft +--- +# Model note — {{title}} + +## Object of interest + +- + +## Economic environment + +### Agents + +- + +### Choice variables + +- + +### State variables / parameters + +| Symbol | Meaning | Observed? | Notes | +|---|---|---|---| +| | | | | + +## Equilibrium / solution concept + +- + +## Main result / sufficient statistic / comparative static + +- + +## Derivation sketch + +```math + +``` + +## Empirical mapping + +- Data moments: +- Calibration/estimation: +- Counterfactual object: + +## Computational implementation + +- Code path: +- Inputs: +- Outputs: +- Tests: + +## Open questions + +- [ ] + +## Next action + +- [ ] diff --git a/templates/academic/paper_status.md b/templates/academic/paper_status.md new file mode 100644 index 0000000..d263f9d --- /dev/null +++ b/templates/academic/paper_status.md @@ -0,0 +1,43 @@ +--- +kind: paper-status +project: {{project}} +title: "{{title}} — paper status" +date: {{date}} +status: active +current_stage: "" +stage_index: 0 +private: false +--- +# {{title}} — paper status + +## Current stage + +## Stage timeline + +| Stage | Status | Notes | +|---:|---|---| +| 0 Idea capture | | | +| 1 Conceptualization | | | +| 2 Theory spine | | | +| 3 Data / measurement | | | +| 4 Reduced-form empirics | | | +| 5 Structural / quantitative | | | +| 6 Drafting / narrative | | | +| 7 Seminar / conference | | | +| 8 Submission package | | | +| 9 R&R / revision | | | +| 10 Production / outreach | | | + +## What is done + +## Active now + +## Blockers / waiting + +## Related tasks + +## Related dates + +## Communications / sources + +## Next stage gate diff --git a/templates/academic/presentation_plan.md b/templates/academic/presentation_plan.md new file mode 100644 index 0000000..2a2761f --- /dev/null +++ b/templates/academic/presentation_plan.md @@ -0,0 +1,37 @@ +--- +kind: presentation-plan +project: "{{project}}" +title: "{{title}}" +date: {{date}} +audience: "" +status: draft +--- +# Presentation plan — {{title}} + +## Audience and objective + +- + +## One-slide thesis + +- + +## Core arc + +1. +2. +3. + +## Slide skeleton + +| # | Slide | Message | Status | +|---:|---|---|---| +| 1 | | | todo | + +## Likely questions + +- [ ] + +## Post-talk follow-up + +- [ ] diff --git a/templates/academic/result_note.md b/templates/academic/result_note.md new file mode 100644 index 0000000..1e6d356 --- /dev/null +++ b/templates/academic/result_note.md @@ -0,0 +1,39 @@ +--- +kind: result-note +project: "{{project}}" +title: "{{title}}" +date: {{date}} +status: draft +--- +# Result card — {{title}} + +## Result + +- + +## Table / figure / output + +- File/path: +- Code path: + +## Interpretation + +- + +## Robustness / threats + +- + +## One-sentence punchline + +- + +## Draft text + +```text + +``` + +## Next action + +- [ ] diff --git a/templates/academic/revision_matrix.md b/templates/academic/revision_matrix.md new file mode 100644 index 0000000..b7308f2 --- /dev/null +++ b/templates/academic/revision_matrix.md @@ -0,0 +1,32 @@ +--- +kind: revision-matrix +project: "{{project}}" +title: "{{title}}" +date: {{date}} +status: draft +--- +# Revision matrix — {{title}} + +## Editor / referee summary + +- + +| Source | Comment | Severity | Response strategy | Manuscript change | Status | +|---|---|---|---|---|---| +| Editor | | high | | | open | +| R1 | | high | | | open | +| R2 | | medium | | | open | + +## Global revision strategy + +- + +## New results needed + +- [ ] + +## Response-letter language + +```text + +``` diff --git a/templates/academic/section_draft_note.md b/templates/academic/section_draft_note.md new file mode 100644 index 0000000..b0fd25a --- /dev/null +++ b/templates/academic/section_draft_note.md @@ -0,0 +1,38 @@ +--- +kind: writing-note +project: "{{project}}" +title: "{{title}}" +date: {{date}} +section: "" +status: draft +--- +# Section note — {{title}} + +## Purpose + +- + +## Reader should learn + +1. +2. + +## Proposed structure + +### 1. + +### 2. + +## Claims that need evidence + +- [ ] + +## Draft text + +```text + +``` + +## Open issues + +- [ ] diff --git a/templates/academic/technical_issue.md b/templates/academic/technical_issue.md new file mode 100644 index 0000000..fd73b3a --- /dev/null +++ b/templates/academic/technical_issue.md @@ -0,0 +1,36 @@ +--- +kind: technical-issue +project: "{{project}}" +title: "{{title}}" +date: {{date}} +status: open +severity: medium +--- +# Technical issue — {{title}} + +## Symptom + +- + +## Hypothesis + +- + +## Reproduction steps + +```bash + +``` + +## Evidence + +- + +## Candidate fixes + +1. +2. + +## Resolution + +- diff --git a/templates/daily_note.md b/templates/daily_note.md new file mode 100644 index 0000000..88ad8b3 --- /dev/null +++ b/templates/daily_note.md @@ -0,0 +1,44 @@ +--- +kind: daily-note +date: {{date}} +status: planned +--- +# Daily note — {{date}} + +## Sleep + +- Hours: +- Quality 1–5: +- Notes: + +## Food + +| Meal | What | Notes | +|---|---|---| +| Breakfast | | | +| Lunch | | | +| Dinner | | | +| Snacks / alcohol | | | + +## Exercise / movement + +- Walk/cardio: +- Strength: +- Mobility: + +## Energy / focus / mood + +- Energy 1–5: +- Focus 1–5: +- Mood/stress notes: + +## Symptoms + +| Time | Symptom | Severity | Context | Action | +|---|---|---:|---|---| +| | | | | | + +## Medical/admin + +- [ ] One Medical follow-up +- [ ] Dentist reschedule/cancellation list diff --git a/templates/health/daily_health_log.md b/templates/health/daily_health_log.md new file mode 100644 index 0000000..daa53c9 --- /dev/null +++ b/templates/health/daily_health_log.md @@ -0,0 +1,48 @@ +--- +kind: daily-health-log +date: {{date}} +status: planned +sleep_hours: +energy: +symptom_count: +symptom_severity: +--- +# Health log — {{date}} + +## Sleep + +- Hours: +- Quality 1–5: +- Notes: + +## Food + +| Meal | What | Notes | +|---|---|---| +| Breakfast | | | +| Lunch | | | +| Dinner | | | +| Snacks / alcohol | | | + +## Exercise / movement + +- Walk/cardio: +- Strength: +- Mobility: + +## Energy / focus / mood + +- Energy 1–5: +- Focus 1–5: +- Mood/stress notes: + +## Symptoms + +| Time | Symptom | Severity | Context | Action | +|---|---|---:|---|---| +| | | | | | + +## Medical/admin + +- [ ] One Medical follow-up +- [ ] Dentist reschedule/cancellation list diff --git a/templates/health/medical_visit.md b/templates/health/medical_visit.md new file mode 100644 index 0000000..a141edb --- /dev/null +++ b/templates/health/medical_visit.md @@ -0,0 +1,15 @@ +--- +kind: medical-visit +date: {{date}} +provider: "" +status: planned +--- +# Medical visit — {{date}} + +## Questions + +- [ ] + +## Follow-up tasks + +- [ ] diff --git a/templates/health/symptom_log.md b/templates/health/symptom_log.md new file mode 100644 index 0000000..7d74313 --- /dev/null +++ b/templates/health/symptom_log.md @@ -0,0 +1,9 @@ +--- +kind: symptom-log +date: {{date}} +symptom: "" +--- +# Symptom log — {{date}} + +| Time | What happened | Severity 1–10 | Duration | Context | What helped | +|---|---|---:|---|---|---| diff --git a/templates/health/weekly_health_review.md b/templates/health/weekly_health_review.md new file mode 100644 index 0000000..779bfe3 --- /dev/null +++ b/templates/health/weekly_health_review.md @@ -0,0 +1,31 @@ +--- +kind: weekly-health-review +week: {{week}} +status: planned +sleep_hours: +energy: +symptom_count: +symptom_severity: +--- +# Weekly health review — {{week}} + +## Summary + +- Energy: +- Sleep: +- Exercise: +- Diet: +- Symptoms: +- Medical admin: + +## What worked + +- + +## What did not work + +- + +## Next experiment + +- diff --git a/templates/health/workout_log.md b/templates/health/workout_log.md new file mode 100644 index 0000000..0cfcb11 --- /dev/null +++ b/templates/health/workout_log.md @@ -0,0 +1,9 @@ +--- +kind: workout-log +date: {{date}} +--- +# Workout — {{date}} + +- Type: +- Duration: +- RPE 1–10: diff --git a/templates/notes/literature_note.md b/templates/notes/literature_note.md new file mode 100644 index 0000000..83624da --- /dev/null +++ b/templates/notes/literature_note.md @@ -0,0 +1,12 @@ +--- +kind: literature-note +title: "" +authors: "" +year: "" +project: "" +--- +# Literature note + +## Core idea + +- diff --git a/templates/notes/meeting_note.md b/templates/notes/meeting_note.md new file mode 100644 index 0000000..333e11a --- /dev/null +++ b/templates/notes/meeting_note.md @@ -0,0 +1,16 @@ +--- +kind: meeting-note +title: "" +project: "" +date: {{date}} +attendees: [] +--- +# Meeting note + +## Decisions + +- + +## Action items + +- [ ] diff --git a/templates/notes/research_note.md b/templates/notes/research_note.md new file mode 100644 index 0000000..c1862d7 --- /dev/null +++ b/templates/notes/research_note.md @@ -0,0 +1,15 @@ +--- +kind: research-note +title: "" +project: "" +date: {{date}} +--- +# Research note + +## Claim / idea + +- + +## Next action + +- [ ] diff --git a/templates/project/README_template.md b/templates/project/README_template.md new file mode 100644 index 0000000..1231d6f --- /dev/null +++ b/templates/project/README_template.md @@ -0,0 +1,18 @@ +# Project title + +## Goal + +## Current state + +## Local paths + +- Repo: +- Shared files: +- Manuscript source: +- Data: + +## Next action + +## Blockers + +## Source log diff --git a/templates/project/progress_template.md b/templates/project/progress_template.md new file mode 100644 index 0000000..4f28977 --- /dev/null +++ b/templates/project/progress_template.md @@ -0,0 +1,7 @@ +# Progress log + +## YYYY-MM-DD + +- What changed: +- Evidence: +- Next: diff --git a/templates/ra/ra_status.md b/templates/ra/ra_status.md new file mode 100644 index 0000000..37f6f96 --- /dev/null +++ b/templates/ra/ra_status.md @@ -0,0 +1,25 @@ +--- +kind: ra-status +project: {{project}} +title: "{{title}}" +date: {{date}} +status: active +assignee: "" +manager: "owner" +private: false +--- +# {{title}} + +## Assignment + +## Current status + +## Deliverables + +## Dependencies + +## Risks + +## Next check-in questions + +## Communication log diff --git a/templates/review/weekly_review.md b/templates/review/weekly_review.md new file mode 100644 index 0000000..7200c9f --- /dev/null +++ b/templates/review/weekly_review.md @@ -0,0 +1,15 @@ +# Weekly review — YYYY-MM-DD + +## Wins + +## Urgent deadlines + +## Active deep-work projects + +## Waiting on others + +## Local audits needed + +## Codex tasks + +## Next week plan diff --git a/templates/weekly_review.md b/templates/weekly_review.md new file mode 100644 index 0000000..6b2011b --- /dev/null +++ b/templates/weekly_review.md @@ -0,0 +1,27 @@ +--- +kind: weekly-review +week: {{week}} +status: planned +--- +# Weekly review — {{week}} + +## Summary + +- Energy: +- Sleep: +- Exercise: +- Diet: +- Symptoms: +- Medical admin: + +## What worked + +- + +## What did not work + +- + +## Next experiment + +- diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 780a7bd..104507a 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -1,3 +1,4 @@ +from datetime import date from pathlib import Path import pytest @@ -8,11 +9,11 @@ def test_bootstrap_copies_example_without_overwriting(tmp_path: Path) -> None: source = tmp_path / "source" source.mkdir() - (source / "START_HERE.md").write_text("# Start\n", encoding="utf-8") + (source / "START_HERE.md").write_text("# Start {{TODAY_PLUS_7}}\n", encoding="utf-8") target = tmp_path / "target" - assert bootstrap(source, target) == 1 - assert (target / "START_HERE.md").read_text(encoding="utf-8") == "# Start\n" + assert bootstrap(source, target, date(2026, 1, 1)) == 1 + assert (target / "START_HERE.md").read_text(encoding="utf-8") == "# Start 2026-01-08\n" with pytest.raises(SystemExit, match="Refusing to overwrite"): bootstrap(source, target) diff --git a/tests/test_calendar_feed.py b/tests/test_calendar_feed.py new file mode 100644 index 0000000..03563c2 --- /dev/null +++ b/tests/test_calendar_feed.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +import json +from datetime import date +from pathlib import Path + +from scripts.calendar_feed import _CACHE, load_calendar_events_by_day + + +def write_ics(path: Path, body: str) -> None: + path.write_text( + "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//Research Workbench Test//EN\n" + + body.strip() + + "\nEND:VCALENDAR\n", + encoding="utf-8", + ) + + +def configure_calendar(monkeypatch, path: Path, *, scope: str = "mixed") -> None: + _CACHE.clear() + monkeypatch.setenv( + "TIMEPLAN_CALENDAR_ICS_URLS", + json.dumps([{"label": "Work", "url": path.as_uri(), "scope": scope}]), + ) + + +def test_calendar_feed_disabled_without_env(tmp_path: Path, monkeypatch) -> None: + _CACHE.clear() + monkeypatch.delenv("TIMEPLAN_CALENDAR_ICS_URLS", raising=False) + + result = load_calendar_events_by_day(tmp_path, date(2026, 5, 11), date(2026, 5, 12)) + + assert result["status"]["enabled"] is False + assert result["events_by_day"] == {} + + +def test_calendar_feed_uses_private_local_cache_without_env(tmp_path: Path, monkeypatch) -> None: + _CACHE.clear() + monkeypatch.delenv("TIMEPLAN_CALENDAR_ICS_URLS", raising=False) + cache_path = tmp_path / "local_data/calendar/timeplan_events.json" + cache_path.parent.mkdir(parents=True) + cache_path.write_text( + json.dumps( + { + "generated_at": "2026-05-11T18:00:00-04:00", + "source_label": "Google Calendar snapshot", + "events": [ + { + "title": "Shipping meeting https://secret.example/token", + "date": "2026-05-11", + "start": "09:00", + "end": "10:00", + "work": True, + }, + { + "title": "Spatial Inequality Conference", + "date": "2026-05-14", + "end_date": "2026-05-15", + "all_day": True, + "work": True, + }, + { + "title": "Sushi reservation", + "date": "2026-05-11", + "start": "12:00", + "end": "13:00", + "work": True, + }, + ], + } + ), + encoding="utf-8", + ) + + result = load_calendar_events_by_day(tmp_path, date(2026, 5, 11), date(2026, 5, 16)) + + assert result["status"]["enabled"] is True + assert result["status"]["event_count"] == 3 + assert result["status"]["source_labels"] == ["Google Calendar snapshot"] + assert result["events_by_day"][date(2026, 5, 11)][0]["title"] == "Shipping meeting" + assert "https://" not in result["events_by_day"][date(2026, 5, 11)][0]["title"] + assert [event["date"] for event in result["events_by_day"][date(2026, 5, 14)]] == ["2026-05-14"] + assert [event["date"] for event in result["events_by_day"][date(2026, 5, 15)]] == ["2026-05-15"] + assert date(2026, 5, 12) not in result["events_by_day"] + + +def test_calendar_feed_expands_recurring_events_and_sanitizes_titles(tmp_path: Path, monkeypatch) -> None: + ics_path = tmp_path / "work.ics" + write_ics( + ics_path, + """ +BEGIN:VEVENT +UID:recurring-meeting +SUMMARY:Research meeting https://token.example/private +DTSTART:20260511T100000 +DTEND:20260511T110000 +RRULE:FREQ=DAILY;COUNT=2 +TRANSP:OPAQUE +END:VEVENT +""", + ) + configure_calendar(monkeypatch, ics_path) + + result = load_calendar_events_by_day(tmp_path, date(2026, 5, 11), date(2026, 5, 14)) + + assert result["status"]["enabled"] is True + assert result["status"]["event_count"] == 2 + assert [event["date"] for events in result["events_by_day"].values() for event in events] == [ + "2026-05-11", + "2026-05-12", + ] + first = result["events_by_day"][date(2026, 5, 11)][0] + assert first["title"] == "Research meeting" + assert first["start"] == "10:00" + assert first["end"] == "11:00" + assert "https://" not in first["title"] + + +def test_calendar_feed_filters_cancelled_declined_personal_and_transparent_nonwork(tmp_path: Path, monkeypatch) -> None: + ics_path = tmp_path / "work.ics" + write_ics( + ics_path, + """ +BEGIN:VEVENT +UID:cancelled +SUMMARY:Research meeting cancelled +DTSTART:20260511T090000 +DTEND:20260511T100000 +STATUS:CANCELLED +END:VEVENT +BEGIN:VEVENT +UID:declined +SUMMARY:Research meeting declined +DTSTART:20260511T100000 +DTEND:20260511T110000 +ATTENDEE;PARTSTAT=DECLINED:mailto:owner@example.com +END:VEVENT +BEGIN:VEVENT +UID:personal +SUMMARY:Sushi reservation +DTSTART:20260511T120000 +DTEND:20260511T130000 +END:VEVENT +BEGIN:VEVENT +UID:transparent +SUMMARY:Research sync +DTSTART:20260511T140000 +DTEND:20260511T150000 +TRANSP:TRANSPARENT +END:VEVENT +BEGIN:VEVENT +UID:included +SUMMARY:Policy seminar +DTSTART:20260511T150000 +DTEND:20260511T160000 +TRANSP:OPAQUE +END:VEVENT +""", + ) + configure_calendar(monkeypatch, ics_path) + monkeypatch.setenv("TIMEPLAN_CALENDAR_USER_EMAILS", "owner@example.com") + + result = load_calendar_events_by_day(tmp_path, date(2026, 5, 11), date(2026, 5, 12)) + + assert [event["title"] for event in result["events_by_day"][date(2026, 5, 11)]] == ["Policy seminar"] + + +def test_calendar_feed_includes_transparent_all_day_work_conference(tmp_path: Path, monkeypatch) -> None: + ics_path = tmp_path / "work.ics" + write_ics( + ics_path, + """ +BEGIN:VEVENT +UID:spatial-inequality +SUMMARY:Spatial Inequality Conference +DTSTART;VALUE=DATE:20260514 +DTEND;VALUE=DATE:20260516 +TRANSP:TRANSPARENT +END:VEVENT +""", + ) + configure_calendar(monkeypatch, ics_path) + + result = load_calendar_events_by_day(tmp_path, date(2026, 5, 14), date(2026, 5, 17)) + + assert [event["date"] for events in result["events_by_day"].values() for event in events] == [ + "2026-05-14", + "2026-05-15", + ] + assert all(event["all_day"] for events in result["events_by_day"].values() for event in events) diff --git a/tests/test_check_links.py b/tests/test_check_links.py new file mode 100644 index 0000000..3663af9 --- /dev/null +++ b/tests/test_check_links.py @@ -0,0 +1,19 @@ +from pathlib import Path + +from scripts.check_links import check, local_target + + +def test_local_target_ignores_remote_and_anchor_links() -> None: + assert local_target("https://example.com/page") is None + assert local_target("#section") is None + assert local_target("docs/CONFIGURATION.md#modules") == "docs/CONFIGURATION.md" + + +def test_check_reports_missing_local_links(tmp_path: Path) -> None: + (tmp_path / "README.md").write_text("[good](docs/ok.md) [bad](docs/missing.md)\n", encoding="utf-8") + (tmp_path / "docs").mkdir() + (tmp_path / "docs/ok.md").write_text("# OK\n", encoding="utf-8") + + broken = check(tmp_path) + + assert [(item.source, item.target) for item in broken] == [("README.md", "docs/missing.md")] diff --git a/tests/test_codex_instructions.py b/tests/test_codex_instructions.py new file mode 100644 index 0000000..d02ddc1 --- /dev/null +++ b/tests/test_codex_instructions.py @@ -0,0 +1,44 @@ +from pathlib import Path + +from scripts.markdown_reader import extract_codex_instructions as extract_static_instructions +from server.vault import VaultService, extract_codex_instructions as extract_live_instructions + + +BODY = """# Task + +## Codex instructions + +- [ ] queued | 2026-05-26 | Review the sources and update the task. +- [!] blocked | 2026-05-27 | Needs source record evidence. +- [x] processed | 2026-05-28 | Added the draft. +- [-] cancelled | Obsolete after consolidation. + +## Log + +- Done. +""" + + +def test_extract_codex_instructions_from_task_body() -> None: + static = extract_static_instructions(BODY) + live = extract_live_instructions(BODY) + + assert static == live + assert [item["status"] for item in static] == ["queued", "blocked", "processed", "cancelled"] + assert static[0]["date"] == "2026-05-26" + assert static[0]["text"] == "Review the sources and update the task." + assert static[-1]["date"] is None + assert static[-1]["text"] == "Obsolete after consolidation." + + +def test_vault_scan_exposes_codex_instructions(tmp_path: Path) -> None: + task = tmp_path / "tasks" / "active" / "t-demo.md" + task.parent.mkdir(parents=True) + task.write_text( + "---\nkind: task\nstatus: open\ntitle: Demo\n---\n" + BODY, + encoding="utf-8", + ) + + entries = VaultService(tmp_path).scan_entries() + assert entries[0]["codex_instructions"][0]["status"] == "queued" + assert entries[0]["codex_instructions"][1]["text"] == "Needs source record evidence." diff --git a/tests/test_distribution.py b/tests/test_distribution.py new file mode 100644 index 0000000..3df373d --- /dev/null +++ b/tests/test_distribution.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import hashlib +import json +import os +import subprocess +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def run(*args: str, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, *args], + cwd=ROOT, + env={**os.environ, **(env or {})}, + check=True, + text=True, + capture_output=True, + ) + + +def tree_digest(root: Path) -> str: + digest = hashlib.sha256() + for path in sorted(root.rglob("*")): + if path.is_file(): + digest.update(path.relative_to(root).as_posix().encode("utf-8")) + digest.update(path.read_bytes()) + return digest.hexdigest() + + +def test_initialized_synthetic_vault_exercises_all_data_families(tmp_path: Path) -> None: + vault = tmp_path / "vault" + output = vault / ".generated" + env = { + "PM_VAULT_ROOT": str(vault), + "PM_OUTPUT_ROOT": str(output), + "PM_RENDER_TIMESTAMP": "2026-07-30T12:00:00Z", + } + + run("scripts/pm.py", "init", "--vault", str(vault), "--today", "2026-07-30") + combined = "\n".join( + path.read_text(encoding="utf-8") + for path in vault.rglob("*") + if path.is_file() and path.suffix in {".md", ".json", ".yml", ".yaml"} + ) + assert "{{TODAY" not in combined + + doctor = run("scripts/pm.py", "doctor", env=env) + assert "Doctor found no blocking" in doctor.stdout + run("scripts/validate_vault_schema.py", "--root", str(vault)) + run("scripts/sync_markdown.py", env=env) + + data = output / "data" + assert len(json.loads((data / "projects.json").read_text(encoding="utf-8"))) == 1 + assert len(json.loads((data / "tasks.json").read_text(encoding="utf-8"))) == 7 + assert len(json.loads((data / "events.json").read_text(encoding="utf-8"))) == 2 + assert len(json.loads((data / "notes.json").read_text(encoding="utf-8"))) == 11 + + run("scripts/render_static.py", env=env) + first = tree_digest(output / "dashboard") + run("scripts/render_static.py", env=env) + assert tree_digest(output / "dashboard") == first + + +def test_init_refuses_to_overwrite_nonempty_directory(tmp_path: Path) -> None: + vault = tmp_path / "vault" + vault.mkdir() + (vault / "keep.md").write_text("# Keep\n", encoding="utf-8") + + result = subprocess.run( + [sys.executable, "scripts/pm.py", "init", "--vault", str(vault)], + cwd=ROOT, + text=True, + capture_output=True, + ) + + assert result.returncode != 0 + assert "Refusing to overwrite" in result.stderr + assert (vault / "keep.md").read_text(encoding="utf-8") == "# Keep\n" diff --git a/tests/test_github_sync.py b/tests/test_github_sync.py new file mode 100644 index 0000000..7223457 --- /dev/null +++ b/tests/test_github_sync.py @@ -0,0 +1,263 @@ +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from server.app import app, get_service +from server.github_sync import GitHubSyncConfig, GitHubSyncService, sync_files +from server.vault import VaultService + + +def write(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + +def git_available() -> bool: + return shutil.which("git") is not None + + +def git(*args: str, cwd: Path) -> str: + result = subprocess.run(["git", *args], cwd=cwd, text=True, capture_output=True) + assert result.returncode == 0, result.stderr + return result.stdout.strip() + + +def init_remote_with_main(tmp_path: Path) -> Path: + remote = tmp_path / "remote.git" + seed = tmp_path / "seed" + remote.mkdir() + seed.mkdir() + git("init", "--bare", cwd=remote) + git("init", "-b", "main", cwd=seed) + git("config", "user.name", "Test User", cwd=seed) + git("config", "user.email", "test@example.com", cwd=seed) + write(seed / "tasks/active/base.md", "# Base\n") + write(seed / "projects/alpha/README.md", "# Alpha\n") + git("add", "-A", cwd=seed) + git("commit", "-m", "Seed", cwd=seed) + git("remote", "add", "origin", str(remote), cwd=seed) + git("push", "origin", "main", cwd=seed) + return remote + + +@pytest.mark.skipif(not git_available(), reason="git executable is required") +def test_github_sync_push_uses_temp_clone_and_excludes_private(tmp_path: Path) -> None: + remote = init_remote_with_main(tmp_path) + vault = tmp_path / "vault" + write(vault / "tasks/active/base.md", "# Base edited on hosted vault\n") + write(vault / "tasks/active/new.md", "# New hosted task\n") + write(vault / "projects/alpha/README.md", "# Alpha\n") + write(vault / "private/secret.md", "# Do not sync\n") + + config = GitHubSyncConfig( + enabled=True, + repo="", + branch="main", + token="", + author_name="Research Workbench Automation", + author_email="render@example.invalid", + remote_url_override=str(remote), + sparse_dirs=("projects", "tasks"), + ) + result = GitHubSyncService(vault, config).push("Sync hosted vault") + + assert result["ok"] is True + assert result["pushed"] is True + paths = {item["path"] for item in result["changed"]} + assert "tasks/active/base.md" in paths + assert "tasks/active/new.md" in paths + + check = tmp_path / "check" + git("clone", "-b", "main", str(remote), str(check), cwd=tmp_path) + assert (check / "tasks/active/base.md").read_text(encoding="utf-8") == "# Base edited on hosted vault\n" + assert (check / "tasks/active/new.md").exists() + assert not (check / "private/secret.md").exists() + + +def _reset_config(remote: Path) -> GitHubSyncConfig: + return GitHubSyncConfig( + enabled=True, + repo="", + branch="main", + token="", + author_name="Research Workbench Automation", + author_email="render@example.invalid", + remote_url_override=str(remote), + sparse_dirs=("projects", "tasks"), + ) + + +@pytest.mark.skipif(not git_available(), reason="git executable is required") +def test_reset_from_github_dry_run_reports_diff_without_writing(tmp_path: Path) -> None: + remote = init_remote_with_main(tmp_path) # remote has tasks/active/base.md, projects/alpha/README.md + vault = tmp_path / "vault" + write(vault / "tasks/active/base.md", "# Locally edited\n") # differs from remote + write(vault / "tasks/active/extra.md", "# Vault only\n") # not on remote + + result = GitHubSyncService(vault, _reset_config(remote)).reset_from_github(dry_run=True) + + assert result["dry_run"] is True + assert "tasks/active/base.md" in result["changed"] + assert "tasks/active/extra.md" in result["extra"] + assert "projects/alpha/README.md" in result["missing"] + assert result["copied"] == 0 and result["deleted"] == 0 + # Nothing on disk was touched. + assert (vault / "tasks/active/base.md").read_text(encoding="utf-8") == "# Locally edited\n" + assert (vault / "tasks/active/extra.md").exists() + assert not (vault / ".backups").exists() + + +@pytest.mark.skipif(not git_available(), reason="git executable is required") +def test_reset_from_github_overwrites_with_backup_and_never_touches_private(tmp_path: Path) -> None: + remote = init_remote_with_main(tmp_path) + vault = tmp_path / "vault" + write(vault / "tasks/active/base.md", "# Locally edited\n") + write(vault / "tasks/active/extra.md", "# Vault only\n") + write(vault / "private/secret.md", "# Never sync or delete\n") + + result = GitHubSyncService(vault, _reset_config(remote)).reset_from_github( + backup=True, delete_extra=True, dry_run=False + ) + + assert result["dry_run"] is False + # Remote content overwrote the local edit. + assert (vault / "tasks/active/base.md").read_text(encoding="utf-8") == "# Base\n" + # Vault-only file removed because delete_extra=True. + assert not (vault / "tasks/active/extra.md").exists() + # private/ is excluded from sync_files AND backup_vault: it must survive untouched. + assert (vault / "private/secret.md").read_text(encoding="utf-8") == "# Never sync or delete\n" + # A backup snapshot was taken and captured the pre-reset content. + backup_dir = vault / result["backup"] + assert backup_dir.is_dir() + assert (backup_dir / "tasks/active/base.md").read_text(encoding="utf-8") == "# Locally edited\n" + assert not (backup_dir / "private").exists() # backup also skips private/ + + +def test_sync_files_excludes_private_and_large_local_paths(tmp_path: Path) -> None: + write(tmp_path / "tasks/active/a.md", "# A\n") + write(tmp_path / "dashboard/index.html", "

Dashboard

\n") + write(tmp_path / "config/render_vault_force_sync.json", "{}\n") + write(tmp_path / "private/secret.md", "# Secret\n") + write(tmp_path / "projects/alpha/private/receipt.md", "# Receipt\n") + write(tmp_path / "local_data/raw.md", "# Raw\n") + + files = sync_files(tmp_path) + + assert "tasks/active/a.md" in files + assert "config/render_vault_force_sync.json" not in files + assert "dashboard/index.html" not in files + assert "private/secret.md" not in files + assert "projects/alpha/private/receipt.md" not in files + assert "local_data/raw.md" not in files + + +@pytest.mark.skipif(not git_available(), reason="git executable is required") +def test_github_sync_status_includes_sanitized_actions(tmp_path: Path, monkeypatch) -> None: + remote = init_remote_with_main(tmp_path) + vault = tmp_path / "vault" + write(vault / "tasks/active/base.md", "# Base\n") + + def fake_github_api(self: GitHubSyncService, path: str, query: dict[str, str]) -> dict: + assert path == "/repos/example-org/private-vault/actions/runs" + assert query["branch"] == "main" + return { + "workflow_runs": [ + { + "id": 42, + "name": "Vault QA", + "workflow_name": "Vault QA", + "status": "completed", + "conclusion": "success", + "event": "push", + "head_branch": "main", + "head_sha": "abcdef1234567890", + "created_at": "2026-06-06T12:00:00Z", + "updated_at": "2026-06-06T12:04:00Z", + "html_url": "https://github.com/example-org/private-vault/actions/runs/42", + }, + { + "id": 43, + "name": "Unsafe link", + "html_url": "https://example.com/leak", + }, + ] + } + + monkeypatch.setattr(GitHubSyncService, "github_api_json", fake_github_api) + config = GitHubSyncConfig( + enabled=True, + repo="example-org/private-vault", + branch="main", + token="super-secret-token", + author_name="Research Workbench Automation", + author_email="render@example.invalid", + remote_url_override=str(remote), + sparse_dirs=("projects", "tasks"), + ) + + status = GitHubSyncService(vault, config).status() + + assert status["actions"]["configured"] is True + assert status["actions"]["latest"]["workflow_name"] == "Vault QA" + assert status["actions"]["latest"]["conclusion"] == "success" + assert status["actions"]["latest"]["html_url"].endswith("/actions/runs/42") + assert status["actions"]["runs"][1]["html_url"] == "" + assert "super-secret-token" not in str(status) + + +def client_for(root: Path, monkeypatch) -> TestClient: + app.dependency_overrides[get_service] = lambda: VaultService(root) + monkeypatch.setenv("PM_APP_TOKEN", "test-token") + monkeypatch.setenv("PM_REQUIRE_LOCAL_AUTH", "true") + return TestClient(app) + + +def teardown_client() -> None: + app.dependency_overrides.clear() + + +def test_github_sync_status_is_authenticated_and_redacted(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setenv("PM_GITHUB_SYNC_ENABLED", "true") + monkeypatch.setenv("PM_GITHUB_REPO", "example-org/private-vault") + monkeypatch.delenv("PM_GITHUB_TOKEN", raising=False) + client = client_for(tmp_path, monkeypatch) + try: + unauthorized = client.get("/api/github-sync/status") + response = client.get("/api/github-sync/status", headers={"Authorization": "Bearer test-token"}) + finally: + teardown_client() + + assert unauthorized.status_code == 401 + assert response.status_code == 200 + body = response.json() + assert body["enabled"] is True + assert body["repo"] == "example-org/private-vault" + assert body["token_configured"] is False + assert "super-secret" not in str(body) + + +def test_github_sync_mutating_endpoints_require_auth(tmp_path: Path, monkeypatch) -> None: + # push and reset-from-github can overwrite/delete the vault, so the auth gate + # must fire before the handler ever runs. + monkeypatch.setenv("PM_GITHUB_SYNC_ENABLED", "true") + monkeypatch.setenv("PM_GITHUB_REPO", "example-org/private-vault") + client = client_for(tmp_path, monkeypatch) + try: + push = client.post("/api/github-sync/push", json={}) + reset = client.post("/api/github-sync/reset-from-github", json={}) + ok = client.post( + "/api/github-sync/reset-from-github", + json={"dry_run": True}, + headers={"Authorization": "Bearer wrong-token"}, + ) + finally: + teardown_client() + + assert push.status_code == 401 + assert reset.status_code == 401 + assert ok.status_code == 401 # a wrong token is rejected too diff --git a/tests/test_pm_cli.py b/tests/test_pm_cli.py new file mode 100644 index 0000000..fea2eb9 --- /dev/null +++ b/tests/test_pm_cli.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import subprocess +import sys +import os +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def test_ai_plan_context_cli_emits_copyable_prompt() -> None: + result = subprocess.run( + [ + sys.executable, + "scripts/pm.py", + "ai-plan-context", + "--no-sync", + "--date", + "2026-05-06", + "--week", + "2026-05-06", + "--ad-hoc", + "protect writing", + ], + cwd=ROOT, + text=True, + capture_output=True, + check=True, + ) + + assert "# Codex Agent Planning Brief" in result.stdout + assert "protect writing" in result.stdout + assert "Do not mark tasks complete" in result.stdout + + +def test_vault_context_loads_application_code_for_an_external_vault(tmp_path: Path) -> None: + vault = tmp_path / "starter-vault" + subprocess.run( + [ + sys.executable, + "scripts/pm.py", + "init", + "--vault", + str(vault), + "--today", + "2030-01-15", + ], + cwd=ROOT, + text=True, + capture_output=True, + check=True, + ) + + env = os.environ.copy() + env["PM_VAULT_ROOT"] = str(vault) + result = subprocess.run( + [sys.executable, "scripts/pm.py", "vault-context"], + cwd=ROOT, + env=env, + text=True, + capture_output=True, + check=True, + ) + + assert f'"vaultPath": "{vault}"' in result.stdout + assert '"noteCount":' in result.stdout diff --git a/tests/test_privacy_scan.py b/tests/test_privacy_scan.py index 4393f72..e19a063 100644 --- a/tests/test_privacy_scan.py +++ b/tests/test_privacy_scan.py @@ -1,21 +1,56 @@ +from __future__ import annotations + from pathlib import Path -from scripts.privacy_scan import scan +from scripts.privacy_scan import scan_root -def test_privacy_scan_flags_credentials_and_machine_paths(tmp_path: Path) -> None: - (tmp_path / "unsafe.md").write_text( - "key: ghp_" + "a" * 30 + "\n" - "path: /" + "Users/example/private.txt\n", - encoding="utf-8", - ) - rules = {hit.rule for hit in scan(tmp_path, tracked_only=False)} - assert {"github_token", "absolute_user_path"} <= rules +def write(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") -def test_privacy_scan_accepts_fictional_content(tmp_path: Path) -> None: - (tmp_path / "safe.md").write_text( - "# Example\n\nAssigned to Example Collaborator.\n", - encoding="utf-8", +def test_privacy_scan_flags_gmail_urls_and_tokenized_links(tmp_path: Path) -> None: + gmail_url = "https://mail." + "google.com/mail/#all/19abc" + tokenized_url = "https://example.com/cart?" + "token=secret" + write( + tmp_path / "docs/source.md", + f"Open in source record: {gmail_url}\n" + f"Booking cart: {tokenized_url}\n", ) - assert scan(tmp_path, tracked_only=False) == [] + + hits = scan_root(tmp_path, tracked_only=False) + rules = {hit.rule for hit in hits} + + assert {"gmail_url", "tokenized_url"} <= rules + + +def test_privacy_scan_allows_documented_env_var_names(tmp_path: Path) -> None: + write(tmp_path / "docs/render.md", "Set PM_GITHUB_TOKEN in Render, but do not commit its value.\n") + + assert scan_root(tmp_path, tracked_only=False) == [] + + +def test_privacy_scan_skips_private_folders(tmp_path: Path) -> None: + gmail_url = "https://mail." + "google.com/mail/#all/19abc" + write(tmp_path / "projects/operations/private/receipt.md", f"{gmail_url}\n") + + assert scan_root(tmp_path, tracked_only=False) == [] + + +def test_privacy_scan_flags_absolute_user_paths_and_real_email_domains(tmp_path: Path) -> None: + absolute_path = "/" + "Users/example/private" + write(tmp_path / "docs/leak.md", "Owner: person@" + f"real-domain.test\nPath: {absolute_path}\n") + + rules = {hit.rule for hit in scan_root(tmp_path, tracked_only=False)} + + assert {"absolute_user_path", "real_email_domain"} <= rules + + +def test_privacy_scan_flags_binary_documents_and_disallowed_roots(tmp_path: Path) -> None: + write(tmp_path / "projects/private-project/README.md", "# Not public\n") + write(tmp_path / "docs/manuscript.pdf", "not really a PDF") + + rules = {hit.rule for hit in scan_root(tmp_path, tracked_only=False)} + + assert {"disallowed_public_root", "binary_document"} <= rules diff --git a/tests/test_public_export_privacy.py b/tests/test_public_export_privacy.py new file mode 100644 index 0000000..b34fab5 --- /dev/null +++ b/tests/test_public_export_privacy.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from pathlib import Path + +from scripts.render_static import visible + + +def test_public_display_excludes_private_entries() -> None: + entries = [ + {"title": "Shareable result", "private": False}, + {"title": "Internal note", "private": True}, + ] + + assert visible(entries, include_private=False) == [entries[0]] + assert visible(entries, include_private=True) == entries + + +def test_generated_outputs_are_ignored() -> None: + root = Path(__file__).resolve().parents[1] + gitignore = (root / ".gitignore").read_text(encoding="utf-8") + + assert ".generated/" in gitignore + assert "vault/" in gitignore diff --git a/tests/test_public_tree.py b/tests/test_public_tree.py index c6030e1..8681bc1 100644 --- a/tests/test_public_tree.py +++ b/tests/test_public_tree.py @@ -24,5 +24,6 @@ def test_example_vault_contains_only_fictional_identity_labels() -> None: path.read_text(encoding="utf-8") for path in (ROOT / "example-vault").rglob("*.md") ) - assert "Example User" in text - assert "Example Collaborator" in text + assert "Avery Example" in text + assert "Fictional Research Studio" not in text + assert "All amounts and coverage rules are invented." in text diff --git a/tests/test_render_static_dashboard.py b/tests/test_render_static_dashboard.py new file mode 100644 index 0000000..402186c --- /dev/null +++ b/tests/test_render_static_dashboard.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +import hashlib +import os +import subprocess +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def write(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + +def render(vault: Path, output: Path) -> None: + env = { + **os.environ, + "PM_VAULT_ROOT": str(vault), + "PM_OUTPUT_ROOT": str(output), + "PM_RENDER_TIMESTAMP": "2026-01-15T12:00:00Z", + } + subprocess.run( + [sys.executable, "scripts/sync_markdown.py"], + cwd=ROOT, + env=env, + check=True, + capture_output=True, + text=True, + ) + subprocess.run( + [sys.executable, "scripts/render_static.py"], + cwd=ROOT, + env=env, + check=True, + capture_output=True, + text=True, + ) + + +def fixture_vault(root: Path) -> None: + write( + root / "projects/harbor-model/README.md", + """--- +kind: project +id: harbor-model +title: Harbor Model +domain: research +status: active +--- +# Harbor Model +""", + ) + write( + root / "tasks/active/estimate.md", + """--- +kind: task +id: t-estimate +title: Estimate baseline +project: harbor-model +domain: research +status: open +due: 2026-01-20 +--- +# Estimate baseline +""", + ) + write( + root / "notes/private-memo.md", + """--- +kind: note +title: Confidential planning memo +project: harbor-model +domain: research +private: true +--- +# Confidential planning memo +""", + ) + + +def digest_tree(root: Path) -> str: + digest = hashlib.sha256() + for path in sorted(root.rglob("*")): + if path.is_file(): + digest.update(path.relative_to(root).as_posix().encode()) + digest.update(path.read_bytes()) + return digest.hexdigest() + + +def test_static_views_render_to_configured_generated_root(tmp_path: Path) -> None: + vault = tmp_path / "vault" + output = tmp_path / "output" + fixture_vault(vault) + + render(vault, output) + + dashboard = output / "dashboard" + for name in ("index.html", "today.html", "calendar.html", "boards.html", "status.html", "graph.html", "public.html"): + assert (dashboard / name).is_file() + assert (dashboard / "projects/harbor-model.html").is_file() + assert (dashboard / "collaborators/index.html").is_file() + assert not (ROOT / "dashboard").exists() + + +def test_public_display_filters_private_entries_and_warns_that_filter_is_not_encryption(tmp_path: Path) -> None: + vault = tmp_path / "vault" + output = tmp_path / "output" + fixture_vault(vault) + + render(vault, output) + html = (output / "dashboard/public.html").read_text(encoding="utf-8") + + assert "Harbor Model" in html + assert "Estimate baseline" in html + assert "Confidential planning memo" not in html + assert "display filter, not encryption" in html + + +def test_static_render_is_deterministic_for_pinned_inputs(tmp_path: Path) -> None: + vault = tmp_path / "vault" + output = tmp_path / "output" + fixture_vault(vault) + + render(vault, output) + first = digest_tree(output) + render(vault, output) + + assert digest_tree(output) == first diff --git a/tests/test_scholar_api.py b/tests/test_scholar_api.py new file mode 100644 index 0000000..6e3c4fe --- /dev/null +++ b/tests/test_scholar_api.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from fastapi.testclient import TestClient + +from server.app import app, get_service +from server.vault import VaultService + + +def configure_snapshot(root: Path, relative_path: str = "config/scholar_stats.json") -> Path: + settings = root / "settings" + settings.mkdir(parents=True, exist_ok=True) + (settings / "workbench.yml").write_text( + f"profile:\n scholar_statistics_source: {relative_path}\n", + encoding="utf-8", + ) + return root / relative_path + + +def client_for(root: Path, monkeypatch) -> TestClient: + app.dependency_overrides[get_service] = lambda: VaultService(root) + monkeypatch.setenv("PM_APP_TOKEN", "test-token") + monkeypatch.setenv("PM_REQUIRE_LOCAL_AUTH", "true") + return TestClient(app) + + +def teardown_client() -> None: + app.dependency_overrides.clear() + + +def test_scholar_endpoint_requires_auth(tmp_path: Path, monkeypatch) -> None: + client = client_for(tmp_path, monkeypatch) + try: + unauthorized = client.get("/api/scholar") + finally: + teardown_client() + assert unauthorized.status_code == 401 + + +def test_scholar_endpoint_returns_unavailable_when_snapshot_missing(tmp_path: Path, monkeypatch) -> None: + client = client_for(tmp_path, monkeypatch) + try: + response = client.get("/api/scholar", headers={"Authorization": "Bearer test-token"}) + finally: + teardown_client() + assert response.status_code == 200 + assert response.json() == {"available": False} + + +def test_scholar_endpoint_serves_stored_snapshot(tmp_path: Path, monkeypatch) -> None: + snapshot = { + "name": "Example Researcher", + "metrics": {"citations": {"all": 124, "recent": 120}, "h_index": {"all": 4, "recent": 4}}, + "top_publications": [{"title": "Urban welfare: Tourism in Harbor City", "year": 2021, "citations": 61}], + } + snapshot_path = configure_snapshot(tmp_path) + snapshot_path.parent.mkdir(parents=True, exist_ok=True) + snapshot_path.write_text(json.dumps(snapshot), encoding="utf-8") + + client = client_for(tmp_path, monkeypatch) + try: + response = client.get("/api/scholar", headers={"Authorization": "Bearer test-token"}) + finally: + teardown_client() + + assert response.status_code == 200 + body = response.json() + assert body["available"] is True + assert body["metrics"]["citations"]["all"] == 124 + assert body["top_publications"][0]["citations"] == 61 + + +def test_scholar_endpoint_handles_corrupt_snapshot(tmp_path: Path, monkeypatch) -> None: + snapshot_path = configure_snapshot(tmp_path) + snapshot_path.parent.mkdir(parents=True, exist_ok=True) + snapshot_path.write_text("{not valid json", encoding="utf-8") + + client = client_for(tmp_path, monkeypatch) + try: + response = client.get("/api/scholar", headers={"Authorization": "Bearer test-token"}) + finally: + teardown_client() + + assert response.status_code == 200 + assert response.json() == {"available": False} + + +def test_scholar_endpoint_rejects_source_outside_vault(tmp_path: Path, monkeypatch) -> None: + configure_snapshot(tmp_path, "../outside.json") + (tmp_path.parent / "outside.json").write_text("{}", encoding="utf-8") + + client = client_for(tmp_path, monkeypatch) + try: + response = client.get("/api/scholar", headers={"Authorization": "Bearer test-token"}) + finally: + teardown_client() + + assert response.status_code == 200 + assert response.json() == {"available": False} diff --git a/tests/test_seed_render_vault.py b/tests/test_seed_render_vault.py new file mode 100644 index 0000000..02b814c --- /dev/null +++ b/tests/test_seed_render_vault.py @@ -0,0 +1,207 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from scripts.seed_render_vault import BACKUP_RETENTION, backup_vault, calculate_drift, copy_missing, force_sync_paths, main, prune_backups, reset_from_seed, vault_manifest_path, write_manifest + + +def write(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + +def test_dry_run_drift_reports_missing_changed_and_extra_without_writing(tmp_path: Path) -> None: + source = tmp_path / "source" + target = tmp_path / "target" + write(source / "tasks/active/missing.md", "# Missing\n") + write(source / "tasks/active/changed.md", "# Source\n") + write(target / "tasks/active/changed.md", "# Target\n") + write(target / "tasks/active/extra.md", "# Extra\n") + + drift = calculate_drift(source, target, include_paths=True) + + assert drift["counts"]["missing"] == 1 + assert drift["counts"]["changed"] == 1 + assert drift["counts"]["extra"] == 1 + assert drift["paths"]["missing"] == ["tasks/active/missing.md"] + assert not vault_manifest_path(target).exists() + + +def test_copy_missing_does_not_overwrite_existing_files(tmp_path: Path) -> None: + source = tmp_path / "source" + target = tmp_path / "target" + write(source / "tasks/active/new.md", "# New\n") + write(source / "tasks/active/existing.md", "# Source\n") + write(target / "tasks/active/existing.md", "# Hosted Edit\n") + + result = copy_missing(source, target) + + assert result == {"copied": 1, "skipped": 1} + assert (target / "tasks/active/new.md").read_text(encoding="utf-8") == "# New\n" + assert (target / "tasks/active/existing.md").read_text(encoding="utf-8") == "# Hosted Edit\n" + + +def test_force_sync_paths_overwrites_named_task_and_removes_stale_lifecycle_duplicate(tmp_path: Path) -> None: + source = tmp_path / "source" + target = tmp_path / "target" + done_task = """--- +kind: task +id: t-example +status: done +--- +# Done +""" + stale_task = """--- +kind: task +id: t-example +status: open +--- +# Stale +""" + write(source / "tasks/done/t-example.md", done_task) + write(target / "tasks/active/t-example.md", stale_task) + write(target / "tasks/someday/t-example.md", stale_task) + write(target / "tasks/cancelled/t-example.md", stale_task) + write(target / "tasks/active/unrelated.md", "# Hosted edit\n") + + result = force_sync_paths(source, target, ["tasks/done/t-example.md"]) + + assert result == {"force_synced": 1, "force_removed_stale": 3, "force_missing": 0, "force_skipped": 0} + assert (target / "tasks/done/t-example.md").read_text(encoding="utf-8") == done_task + assert not (target / "tasks/active/t-example.md").exists() + assert not (target / "tasks/someday/t-example.md").exists() + assert not (target / "tasks/cancelled/t-example.md").exists() + assert (target / "tasks/active/unrelated.md").read_text(encoding="utf-8") == "# Hosted edit\n" + + +def test_force_sync_paths_removes_completed_task_copies(tmp_path: Path) -> None: + source = tmp_path / "source" + target = tmp_path / "target" + annual_form_done = """--- +kind: task +id: t-annual-form +status: done +--- +# Complete annual example form +""" + travel_approval_done = """--- +kind: task +id: t-travel-approval +status: done +--- +# Record example travel approval +""" + stale_active = """--- +kind: task +status: open +urgent: true +--- +# Stale focus copy +""" + write(source / "tasks/done/t-annual-form.md", annual_form_done) + write(source / "tasks/done/t-travel-approval.md", travel_approval_done) + write(target / "tasks/active/t-annual-form.md", stale_active.replace("status: open", "id: t-annual-form\nstatus: open")) + write(target / "tasks/active/t-travel-approval.md", stale_active.replace("status: open", "id: t-travel-approval\nstatus: open")) + + result = force_sync_paths( + source, + target, + ["tasks/done/t-annual-form.md", "tasks/done/t-travel-approval.md"], + ) + + assert result == {"force_synced": 2, "force_removed_stale": 2, "force_missing": 0, "force_skipped": 0} + assert (target / "tasks/done/t-annual-form.md").read_text(encoding="utf-8") == annual_form_done + assert (target / "tasks/done/t-travel-approval.md").read_text(encoding="utf-8") == travel_approval_done + assert not (target / "tasks/active/t-annual-form.md").exists() + assert not (target / "tasks/active/t-travel-approval.md").exists() + + +def test_reset_from_seed_requires_backup_and_restores_repo_managed_files(tmp_path: Path) -> None: + source = tmp_path / "source" + target = tmp_path / "target" + write(source / "tasks/active/changed.md", "# Source\n") + write(target / "tasks/active/changed.md", "# Hosted Edit\n") + write(target / "tasks/active/extra.md", "# Extra\n") + + with pytest.raises(SystemExit): + reset_from_seed(source, target, backup=False) + + result = reset_from_seed(source, target, backup=True) + + assert result["restored"] == 1 + assert (target / "tasks/active/changed.md").read_text(encoding="utf-8") == "# Source\n" + assert not (target / "tasks/active/extra.md").exists() + assert (result["backup_path"] / "tasks/active/changed.md").exists() + assert (result["backup_path"] / "tasks/active/extra.md").exists() + + +def test_env_seed_mode_reset_realigns_vault_with_backup(tmp_path: Path, monkeypatch) -> None: + source = tmp_path / "source" + target = tmp_path / "target" + write(source / "tasks/active/keep.md", "# Source\n") + write(target / "tasks/active/keep.md", "# Hosted stale edit\n") + write(target / "tasks/active/ghost.md", "# Hosted-only ghost\n") + + monkeypatch.setenv("PM_VAULT_SEED_SOURCE", str(source)) + monkeypatch.setenv("PM_VAULT_ROOT", str(target)) + monkeypatch.setenv("PM_VAULT_SEED_MODE", "reset-from-seed") + monkeypatch.setattr("sys.argv", ["seed_render_vault.py"]) + + # reset-from-seed auto-enables backup even without --backup, so this must not raise. + assert main() == 0 + + # Vault realigned to the deployed source, ghost removed, manifest records the mode. + assert (target / "tasks/active/keep.md").read_text(encoding="utf-8") == "# Source\n" + assert not (target / "tasks/active/ghost.md").exists() + manifest = vault_manifest_path(target).read_text(encoding="utf-8") + assert '"mode": "reset-from-seed"' in manifest + backups = list((target / ".backups").glob("vault-*/tasks/active/ghost.md")) + assert backups, "prior vault should be backed up before reset" + + +def test_backup_vault_prunes_old_snapshots_and_skips_excluded(tmp_path: Path) -> None: + target = tmp_path / "target" + # Pre-existing snapshots well over the retention limit (old timestamps so the + # freshly written one always sorts newest and is kept). + for i in range(BACKUP_RETENTION + 4): + write(target / ".backups" / f"vault-2000010{i}T000000Z" / "tasks/active/old.md", "# old\n") + # Canonical content plus things that must never be archived (these are what + # previously bloated the disk: the git working tree, generated dashboards/data). + write(target / "tasks/active/keep.md", "# keep\n") + write(target / ".git" / "objects" / "blob", "x" * 10_000) + write(target / "dashboard" / "today.html", "") + write(target / "data" / "tasks.json", "[]") + + backup_root = backup_vault(target) + + snapshots = sorted(p for p in (target / ".backups").iterdir() if p.name.startswith("vault-")) + assert len(snapshots) == BACKUP_RETENTION # pruned to retention, including the new snapshot + assert backup_root == snapshots[-1] + # The new snapshot archives canonical vault content... + assert (backup_root / "tasks/active/keep.md").read_text(encoding="utf-8") == "# keep\n" + # ...but excludes the git tree, dashboards, and generated data. + assert not (backup_root / ".git").exists() + assert not (backup_root / "dashboard").exists() + assert not (backup_root / "data").exists() + + +def test_prune_backups_keeps_newest(tmp_path: Path) -> None: + target = tmp_path / "target" + for i in range(6): + write(target / ".backups" / f"vault-2000010{i}T000000Z" / "marker", "x") + removed = prune_backups(target, keep=2) + remaining = sorted(p.name for p in (target / ".backups").iterdir()) + assert remaining == ["vault-20000104T000000Z", "vault-20000105T000000Z"] + assert len(removed) == 4 + + +def test_write_manifest_records_seed_state(tmp_path: Path) -> None: + target = tmp_path / "target" + source = tmp_path / "source" + write_manifest(target, source=source, mode="copy-missing", counts={"copied": 1}) + + manifest = vault_manifest_path(target).read_text(encoding="utf-8") + assert '"mode": "copy-missing"' in manifest + assert '"copied": 1' in manifest diff --git a/tests/test_server_vault.py b/tests/test_server_vault.py new file mode 100644 index 0000000..bf3bfe2 --- /dev/null +++ b/tests/test_server_vault.py @@ -0,0 +1,452 @@ +from __future__ import annotations + +import time +from pathlib import Path + +import pytest + +from server.vault import SaveConflictError, VaultError, VaultService + + +def write(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + +def test_scan_extracts_frontmatter_title_links_and_backlinks(tmp_path: Path) -> None: + write( + tmp_path / "task.md", + """--- +type: Type +template: | + --- + kind: task + --- + # New Task +--- +# Task +""", + ) + write( + tmp_path / "tasks/active/t-one.md", + """--- +kind: task +status: open +project: alpha +priority: 1 +urgent: false +due: 2026-05-10 +related_to: + - "[[Alpha Project]]" +--- +# First Task + +See [[Alpha Project]]. +""", + ) + write( + tmp_path / "projects/alpha/README.md", + """--- +kind: project +id: alpha +title: Alpha Project +status: active +--- +# Alpha Project +""", + ) + + entries = VaultService(tmp_path).scan_entries() + task = next(e for e in entries if e["path"] == "tasks/active/t-one.md") + project = next(e for e in entries if e["path"] == "projects/alpha/README.md") + + assert task["title"] == "First Task" + assert task["kind"] == "task" + assert task["priority"] == "1" + assert task["relationships"]["related_to"] == ["Alpha Project"] + assert task["outgoing_links"] == ["Alpha Project"] + assert project["backlinks"] == ["tasks/active/t-one.md"] + + +def test_scan_entries_reuses_unchanged_files_and_reparses_changed_files(tmp_path: Path, monkeypatch) -> None: + write(tmp_path / "notes/a.md", "# A\n") + write(tmp_path / "notes/b.md", "# B\n") + service = VaultService(tmp_path) + original_parse = VaultService.parse_file + parsed: list[str] = [] + + def counting_parse(self: VaultService, path: Path) -> dict: + parsed.append(self.relpath(path)) + return original_parse(self, path) + + monkeypatch.setattr(VaultService, "parse_file", counting_parse) + + service.scan_entries(force=True) + assert sorted(parsed) == ["notes/a.md", "notes/b.md"] + + parsed.clear() + service.scan_entries() + assert parsed == [] + + time.sleep(0.01) + write(tmp_path / "notes/b.md", "# B changed\n") + parsed.clear() + entries = service.scan_entries() + + assert parsed == ["notes/b.md"] + assert {entry["title"] for entry in entries} == {"A", "B changed"} + + +def test_scan_entries_returns_copies_of_cached_entries(tmp_path: Path) -> None: + write(tmp_path / "notes/a.md", "# A\n\nSee [[B]].\n") + write(tmp_path / "notes/b.md", "# B\n") + service = VaultService(tmp_path) + + first = service.scan_entries(force=True) + first_by_path = {entry["path"]: entry for entry in first} + first_by_path["notes/a.md"]["title"] = "Mutated A" + first_by_path["notes/a.md"]["outgoing_links"].append("Injected") + first_by_path["notes/b.md"]["backlinks"].append("Injected") + first_by_path["notes/a.md"]["properties"]["custom"] = "mutated" + + second = {entry["path"]: entry for entry in service.scan_entries()} + + assert second["notes/a.md"]["title"] == "A" + assert second["notes/a.md"]["outgoing_links"] == ["B"] + assert second["notes/b.md"]["backlinks"] == ["notes/a.md"] + assert "custom" not in second["notes/a.md"]["properties"] + + +def test_scan_entries_preserves_safe_structured_ledger_properties(tmp_path: Path) -> None: + write( + tmp_path / "projects/operations/travel_ledger.md", + """--- +kind: travel-ledger +project: operations +ledger: + - trip: Westport + item: Airport ride + actual: 38.50 + currency: EUR + actual_usd: 41.08 + reimbursed_amount_usd: 0 + status: submitted +unsafe_nested: + token: hidden +--- +# Travel ledger +""", + ) + service = VaultService(tmp_path) + + first = service.scan_entries(force=True) + entry = next(e for e in first if e["path"] == "projects/operations/travel_ledger.md") + entry["properties"]["ledger"][0]["item"] = "Mutated" + + second = next( + e for e in service.scan_entries() + if e["path"] == "projects/operations/travel_ledger.md" + ) + + assert second["properties"]["ledger"][0]["item"] == "Airport ride" + assert second["properties"]["ledger"][0]["actual_usd"] == 41.08 + assert "unsafe_nested" not in second["properties"] + + +def test_scan_cache_invalidates_after_writes_moves_and_creates(tmp_path: Path, monkeypatch) -> None: + write(tmp_path / "notes/a.md", "# A\n") + write(tmp_path / "tasks/active/t-one.md", "---\nkind: task\nstatus: open\n---\n# One\n") + service = VaultService(tmp_path) + original_parse = VaultService.parse_file + parsed: list[str] = [] + + def counting_parse(self: VaultService, path: Path) -> dict: + parsed.append(self.relpath(path)) + return original_parse(self, path) + + monkeypatch.setattr(VaultService, "parse_file", counting_parse) + + service.scan_entries(force=True) + parsed.clear() + service.save_file("notes/a.md", "# A edited\n") + service.scan_entries() + assert sorted(parsed) == ["notes/a.md", "tasks/active/t-one.md"] + + parsed.clear() + service.patch_metadata("notes/a.md", {"project": "alpha"}) + parsed.clear() + service.scan_entries() + assert sorted(parsed) == ["notes/a.md", "tasks/active/t-one.md"] + + parsed.clear() + service.patch_task_metadata("tasks/active/t-one.md", {"status": "done"}) + parsed.clear() + service.scan_entries() + assert sorted(parsed) == ["notes/a.md", "tasks/done/t-one.md"] + + parsed.clear() + service.create_file("note", "New Capture") + service.scan_entries() + assert "notes/a.md" in parsed + assert "tasks/done/t-one.md" in parsed + assert any(path.startswith("_inbox/") and path.endswith("new-capture.md") for path in parsed) + + +def test_scan_infers_organization_from_paths_without_editing_frontmatter(tmp_path: Path) -> None: + write(tmp_path / "projects/alpha/next.md", "# Next\n") + write(tmp_path / "projects/alpha/modeling/2026-05-06-demand.md", "# Demand model\n") + write(tmp_path / "areas/health/notes/plan.md", "# Health plan\n") + write(tmp_path / "docs/runbook.md", "# Runbook\n") + + entries = {e["path"]: e for e in VaultService(tmp_path).scan_entries()} + + assert entries["projects/alpha/next.md"]["kind"] == "project-note" + assert entries["projects/alpha/next.md"]["note_role"] == "next-actions" + assert entries["projects/alpha/next.md"]["project"] == "alpha" + assert entries["projects/alpha/next.md"]["project_source"] == "path" + assert entries["projects/alpha/modeling/2026-05-06-demand.md"]["kind"] == "model-note" + assert entries["projects/alpha/modeling/2026-05-06-demand.md"]["note_role"] == "modeling" + assert entries["areas/health/notes/plan.md"]["collection"] == "area" + assert entries["areas/health/notes/plan.md"]["area"] == "health" + assert entries["docs/runbook.md"]["collection"] == "docs" + assert entries["docs/runbook.md"]["kind"] == "documentation" + + +def test_excludes_private_generated_and_hidden_paths(tmp_path: Path) -> None: + write(tmp_path / "notes/open.md", "# Open") + write(tmp_path / "private/secret.md", "# Secret") + write(tmp_path / "data/cache.md", "# Cache") + write(tmp_path / "dashboard/index.md", "# Dashboard") + write(tmp_path / ".github/workflow.md", "# Hidden") + + paths = {e["path"] for e in VaultService(tmp_path).scan_entries()} + + assert paths == {"notes/open.md"} + + +def test_rejects_path_traversal_and_non_markdown(tmp_path: Path) -> None: + service = VaultService(tmp_path) + with pytest.raises(VaultError): + service.get_file("../outside.md") + with pytest.raises(VaultError): + service.save_file("notes/data.json", "{}") + + +def test_save_file_detects_modified_at_conflict(tmp_path: Path) -> None: + path = tmp_path / "notes/test.md" + write(path, "# Original\n") + service = VaultService(tmp_path) + original = service.get_file("notes/test.md") + time.sleep(0.01) + path.write_text("# External edit\n", encoding="utf-8") + + with pytest.raises(SaveConflictError): + service.save_file("notes/test.md", "# My edit\n", original["modified_at"]) + + +def test_save_file_can_move_task_lifecycle_path(tmp_path: Path) -> None: + write(tmp_path / "tasks/active/t-lifecycle.md", "---\nkind: task\nstatus: open\n---\n# Lifecycle\n") + service = VaultService(tmp_path) + original = service.get_file("tasks/active/t-lifecycle.md") + + saved = service.save_file( + "tasks/active/t-lifecycle.md", + "---\nkind: task\nstatus: waiting\n---\n# Lifecycle\n\nWaiting on reply.\n", + original["modified_at"], + "tasks/waiting/t-lifecycle.md", + ) + + assert saved["path"] == "tasks/waiting/t-lifecycle.md" + assert not (tmp_path / "tasks/active/t-lifecycle.md").exists() + assert (tmp_path / "tasks/waiting/t-lifecycle.md").read_text(encoding="utf-8").endswith("\n") + + +def test_save_file_move_rejects_invalid_lifecycle_destinations(tmp_path: Path) -> None: + write(tmp_path / "tasks/active/t-lifecycle.md", "---\nkind: task\nstatus: open\n---\n# Lifecycle\n") + write(tmp_path / "notes/plain.md", "# Plain\n") + service = VaultService(tmp_path) + + with pytest.raises(VaultError): + service.save_file("tasks/active/t-lifecycle.md", "# Bad\n", move_to="../outside.md") + with pytest.raises(VaultError): + service.save_file("tasks/active/t-lifecycle.md", "# Bad\n", move_to="tasks/done/t-lifecycle.txt") + with pytest.raises(VaultError): + service.save_file("tasks/active/t-lifecycle.md", "# Bad\n", move_to="notes/t-lifecycle.md") + with pytest.raises(VaultError): + service.save_file("notes/plain.md", "# Bad\n", move_to="tasks/done/plain.md") + + +def test_save_file_move_detects_conflict_before_move(tmp_path: Path) -> None: + path = tmp_path / "tasks/active/t-lifecycle.md" + write(path, "---\nkind: task\nstatus: open\n---\n# Lifecycle\n") + service = VaultService(tmp_path) + original = service.get_file("tasks/active/t-lifecycle.md") + time.sleep(0.01) + path.write_text("---\nkind: task\nstatus: active\n---\n# External Edit\n", encoding="utf-8") + + with pytest.raises(SaveConflictError): + service.save_file( + "tasks/active/t-lifecycle.md", + "---\nkind: task\nstatus: done\n---\n# Lifecycle\n", + original["modified_at"], + "tasks/done/t-lifecycle.md", + ) + assert path.exists() + assert not (tmp_path / "tasks/done/t-lifecycle.md").exists() + + +def test_save_file_move_uses_unique_destination_on_collision(tmp_path: Path) -> None: + write(tmp_path / "tasks/active/t-collision.md", "---\nkind: task\nstatus: open\n---\n# Source\n") + write(tmp_path / "tasks/done/t-collision.md", "---\nkind: task\nstatus: done\n---\n# Existing\n") + service = VaultService(tmp_path) + + saved = service.save_file( + "tasks/active/t-collision.md", + "---\nkind: task\nstatus: done\ncompleted: 2026-05-08\n---\n# Source\n", + move_to="tasks/done/t-collision.md", + ) + + assert saved["path"] == "tasks/done/t-collision-2.md" + assert (tmp_path / "tasks/done/t-collision.md").read_text(encoding="utf-8").endswith("# Existing\n") + assert "completed: 2026-05-08" in (tmp_path / "tasks/done/t-collision-2.md").read_text(encoding="utf-8") + assert not (tmp_path / "tasks/active/t-collision.md").exists() + + +def test_patch_task_metadata_updates_and_clears_allowed_fields(tmp_path: Path) -> None: + write( + tmp_path / "tasks/active/t-edit.md", + """--- +kind: task +status: open +project: alpha +priority: 1 +due: 2026-05-10 +estimate_minutes: 90 +assignee: Owner +--- +# Editable Task +""", + ) + service = VaultService(tmp_path) + + updated = service.patch_task_metadata( + "tasks/active/t-edit.md", + { + "status": "waiting", + "priority": "", + "urgent": "true", + "due": "2026-05-12", + "project": "beta", + "estimate_minutes": "", + "assignee": "Avery", + }, + ) + + assert updated["frontmatter"]["status"] == "waiting" + assert updated["frontmatter"]["urgent"] is True + assert updated["path"] == "tasks/waiting/t-edit.md" + assert updated["frontmatter"]["due"] == "2026-05-12" + assert updated["frontmatter"]["project"] == "beta" + assert updated["frontmatter"]["assignee"] == "Avery" + assert "priority" not in updated["frontmatter"] + assert "estimate_minutes" not in updated["frontmatter"] + assert not (tmp_path / "tasks/active/t-edit.md").exists() + rescanned = {entry["path"]: entry for entry in service.scan_entries()} + assert rescanned["tasks/waiting/t-edit.md"]["status"] == "waiting" + assert rescanned["tasks/waiting/t-edit.md"]["urgent"] is True + assert rescanned["tasks/waiting/t-edit.md"]["priority"] is None + assert rescanned["tasks/waiting/t-edit.md"]["assignee"] == "Avery" + + +def test_patch_task_metadata_moves_done_task_and_sets_completed(tmp_path: Path) -> None: + write(tmp_path / "tasks/waiting/t-finish.md", "---\nkind: task\nstatus: waiting\n---\n# Finish Task\n") + service = VaultService(tmp_path) + + updated = service.patch_task_metadata("tasks/waiting/t-finish.md", {"status": "done"}) + + assert updated["path"] == "tasks/done/t-finish.md" + assert updated["frontmatter"]["status"] == "done" + assert updated["frontmatter"]["completed"] + assert not (tmp_path / "tasks/waiting/t-finish.md").exists() + text = (tmp_path / "tasks/done/t-finish.md").read_text(encoding="utf-8") + assert "status: done" in text + assert "completed:" in text + + +def test_patch_task_metadata_preserves_existing_completed_date(tmp_path: Path) -> None: + write( + tmp_path / "tasks/waiting/t-finished-before.md", + "---\nkind: task\nstatus: waiting\ncompleted: 2026-05-01\n---\n# Finished Before\n", + ) + service = VaultService(tmp_path) + + updated = service.patch_task_metadata("tasks/waiting/t-finished-before.md", {"status": "done"}) + + assert updated["path"] == "tasks/done/t-finished-before.md" + assert updated["frontmatter"]["completed"] == "2026-05-01" + assert "completed: 2026-05-01" in (tmp_path / "tasks/done/t-finished-before.md").read_text(encoding="utf-8") + + +def test_patch_task_metadata_moves_waiting_task_back_to_active(tmp_path: Path) -> None: + write(tmp_path / "tasks/waiting/t-reactivate.md", "---\nkind: task\nstatus: waiting\n---\n# Reactivate\n") + service = VaultService(tmp_path) + + updated = service.patch_task_metadata("tasks/waiting/t-reactivate.md", {"status": "active"}) + + assert updated["path"] == "tasks/active/t-reactivate.md" + assert updated["frontmatter"]["status"] == "active" + assert not (tmp_path / "tasks/waiting/t-reactivate.md").exists() + + +def test_patch_task_metadata_rejects_invalid_fields_and_non_tasks(tmp_path: Path) -> None: + write(tmp_path / "tasks/active/t-edit.md", "---\nkind: task\nstatus: open\n---\n# Editable Task\n") + write(tmp_path / "notes/plain.md", "# Plain Note\n") + service = VaultService(tmp_path) + + with pytest.raises(VaultError): + service.patch_task_metadata("tasks/active/t-edit.md", {"owner": "someone"}) + with pytest.raises(VaultError): + service.patch_task_metadata("tasks/active/t-edit.md", {"status": ""}) + with pytest.raises(VaultError): + service.patch_task_metadata("tasks/active/t-edit.md", {"priority": "9"}) + with pytest.raises(VaultError): + service.patch_task_metadata("tasks/active/t-edit.md", {"due": "tomorrow"}) + with pytest.raises(VaultError): + service.patch_task_metadata("notes/plain.md", {"status": "open"}) + + +def test_patch_task_metadata_detects_modified_at_conflict(tmp_path: Path) -> None: + path = tmp_path / "tasks/active/t-edit.md" + write(path, "---\nkind: task\nstatus: open\n---\n# Editable Task\n") + service = VaultService(tmp_path) + original = service.get_file("tasks/active/t-edit.md") + time.sleep(0.01) + path.write_text("---\nkind: task\nstatus: active\n---\n# External Edit\n", encoding="utf-8") + + with pytest.raises(SaveConflictError): + service.patch_task_metadata("tasks/active/t-edit.md", {"status": "waiting"}, original["modified_at"]) + + +def test_create_uses_type_template_and_unique_path(tmp_path: Path) -> None: + write( + tmp_path / "task.md", + """--- +type: Type +template: | + --- + kind: task + status: active + --- + # New Task +--- +# Task +""", + ) + + service = VaultService(tmp_path) + created = service.create_file("task", "Draft Summer Conference plan") + created_again = service.create_file("task", "Draft Summer Conference plan") + + assert created["path"] == "tasks/active/t-draft-summer-conference-plan.md" + assert "# Draft Summer Conference plan" in created["content"] + assert created_again["path"] == "tasks/active/t-draft-summer-conference-plan-2.md" diff --git a/tests/test_task_lifecycle_consistency.py b/tests/test_task_lifecycle_consistency.py new file mode 100644 index 0000000..d5830bd --- /dev/null +++ b/tests/test_task_lifecycle_consistency.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from collections import defaultdict +from pathlib import Path + +from scripts.markdown_reader import split_frontmatter + + +ROOT = Path(__file__).resolve().parents[1] +CLOSED_STATUSES = {"done", "complete", "completed", "cancelled", "canceled", "dropped", "archived", "archive"} +DONE_STATUSES = {"done", "complete", "completed", "archived", "archive"} +CANCELLED_STATUSES = {"cancelled", "canceled", "dropped"} + + +def task_frontmatter() -> list[tuple[Path, dict]]: + records: list[tuple[Path, dict]] = [] + for path in sorted((ROOT / "tasks").glob("**/*.md")): + frontmatter, _body = split_frontmatter(path.read_text(encoding="utf-8", errors="replace")) + if str(frontmatter.get("kind") or "").lower() == "task": + records.append((path.relative_to(ROOT), frontmatter)) + return records + + +def test_task_ids_are_unique_across_lifecycle_directories() -> None: + by_id: dict[str, list[str]] = defaultdict(list) + for path, frontmatter in task_frontmatter(): + task_id = str(frontmatter.get("id") or "").strip() + if task_id: + by_id[task_id].append(str(path)) + + duplicates = {task_id: paths for task_id, paths in by_id.items() if len(paths) > 1} + assert duplicates == {} + + +def test_task_status_matches_lifecycle_directory() -> None: + problems: list[str] = [] + for path, frontmatter in task_frontmatter(): + status = str(frontmatter.get("status") or "").strip().lower() + path_text = str(path) + if path_text.startswith("tasks/done/") and status not in DONE_STATUSES: + problems.append(f"{path_text} has status {status!r}") + if path_text.startswith("tasks/cancelled/") and status not in CANCELLED_STATUSES: + problems.append(f"{path_text} has status {status!r}") + if path_text.startswith(("tasks/active/", "tasks/waiting/")) and status in CLOSED_STATUSES: + problems.append(f"{path_text} is closed but still in active/waiting") + + assert problems == [] diff --git a/tests/test_task_lifecycle_duplicate_report.py b/tests/test_task_lifecycle_duplicate_report.py new file mode 100644 index 0000000..44b2573 --- /dev/null +++ b/tests/test_task_lifecycle_duplicate_report.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from pathlib import Path + +from scripts.report_task_lifecycle_duplicates import duplicate_task_ids + + +def write(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + +def task(task_id: str, status: str = "active") -> str: + return f"""--- +id: {task_id} +kind: task +status: {status} +--- +# {task_id} +""" + + +def test_duplicate_report_flags_cross_lifecycle_task_ids(tmp_path: Path) -> None: + write(tmp_path / "tasks/active/t-shared.md", task("t-shared", "active")) + write(tmp_path / "tasks/done/t-shared.md", task("t-shared", "done")) + write(tmp_path / "tasks/active/t-other.md", task("t-other", "active")) + + duplicates = duplicate_task_ids(tmp_path) + + assert len(duplicates) == 1 + assert duplicates[0].task_id == "t-shared" + assert duplicates[0].folders == ["active", "done"] + assert duplicates[0].paths == ["tasks/active/t-shared.md", "tasks/done/t-shared.md"] + + +def test_duplicate_report_ignores_readmes_and_same_lifecycle_names(tmp_path: Path) -> None: + write(tmp_path / "tasks/active/README.md", "# Active\n") + write(tmp_path / "tasks/active/t-same.md", task("t-same", "active")) + write(tmp_path / "tasks/active/t-same-copy.md", task("t-same-copy", "active")) + + assert duplicate_task_ids(tmp_path) == [] diff --git a/tests/test_task_metadata_api.py b/tests/test_task_metadata_api.py new file mode 100644 index 0000000..fdf3396 --- /dev/null +++ b/tests/test_task_metadata_api.py @@ -0,0 +1,307 @@ +from __future__ import annotations + +from pathlib import Path + +from fastapi.testclient import TestClient + +import server.app as server_app +from server.app import app, get_service +from server.vault import VaultService + + +def write(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + +def client_for(root: Path, monkeypatch) -> TestClient: + app.dependency_overrides[get_service] = lambda: VaultService(root) + monkeypatch.setenv("PM_APP_TOKEN", "test-token") + return TestClient(app) + + +def teardown_client() -> None: + app.dependency_overrides.clear() + + +def test_health_endpoint_does_not_scan_vault(monkeypatch) -> None: + def fail_scan(*_args, **_kwargs) -> list[dict]: + raise AssertionError("health should not scan the vault") + + monkeypatch.setattr(VaultService, "scan_entries", fail_scan) + client = TestClient(app) + + response = client.get("/api/health") + + assert response.status_code == 200 + body = response.json() + assert "vault_root_exists" in body + assert "entry_count" not in body + assert "task_count" not in body + + +def test_diagnostics_endpoint_reports_vault_hash_and_cache(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setattr(server_app, "LAST_MARKDOWN_SAVE_AT", None) + write(tmp_path / "tasks/active/t-api.md", "---\nkind: task\nstatus: open\n---\n# API Task\n") + write(tmp_path / "projects/demo/README.md", "---\nkind: project\nid: demo\ntitle: Demo\n---\n# Demo\n") + write( + tmp_path / ".vault_seed_manifest.json", + '{"generated_at":"2026-06-16T12:00:00+00:00","mode":"copy-missing","seed_source":"/app","vault_root":"/app/vault","app_commit":"feedface","counts":{"copied":1}}\n', + ) + monkeypatch.setenv("PM_GITHUB_SYNC_ENABLED", "false") + client = client_for(tmp_path, monkeypatch) + try: + response = client.get("/api/diagnostics", headers={"Authorization": "Bearer test-token"}) + finally: + teardown_client() + + assert response.status_code == 200 + body = response.json() + assert body["vault_hash"]["algorithm"] == "sha256" + assert body["vault_hash"]["file_count"] >= 1 + assert len(body["vault_hash"]["value"]) == 64 + assert body["entry_counts"]["entries"] >= 2 + assert body["entry_counts"]["tasks"] == 1 + assert body["entry_counts"]["projects"] == 1 + assert body["last_vault_scan_at"] + assert body["last_markdown_save_at"] is None + assert body["seed"]["exists"] is True + assert body["seed"]["mode"] == "copy-missing" + assert body["seed"]["app_commit"] == "feedface" + assert "generated_cache" in body + cache_files = {item["path"] for item in body["generated_cache"]["files"]} + assert {"data/tasks.json", "data/projects.json", "dashboard/index.html", "dashboard/status.html"} <= cache_files + assert body["hosted_service"]["configured"] is False + assert "github_sync" in body + + +def test_diagnostics_endpoint_reports_hosted_service_check(tmp_path: Path, monkeypatch) -> None: + write(tmp_path / "tasks/active/t-api.md", "---\nkind: task\nstatus: open\n---\n# API Task\n") + monkeypatch.setenv("PM_GITHUB_SYNC_ENABLED", "false") + monkeypatch.setenv("PM_PUBLIC_DASHBOARD_URL", "https://example.test/dashboard/index.html") + monkeypatch.setattr(server_app, "LAST_HOSTED_SERVICE_SUCCESS_AT", None) + + class FakeResponse: + def __enter__(self): + return self + + def __exit__(self, *_args): + return None + + def getcode(self) -> int: + return 200 + + def fake_urlopen(request, timeout): + assert request.full_url == "https://example.test/dashboard/index.html" + assert timeout == 2 + return FakeResponse() + + monkeypatch.setattr(server_app.urllib.request, "urlopen", fake_urlopen) + client = client_for(tmp_path, monkeypatch) + try: + response = client.get("/api/diagnostics", headers={"Authorization": "Bearer test-token"}) + finally: + teardown_client() + + assert response.status_code == 200 + hosted = response.json()["hosted_service"] + assert hosted["configured"] is True + assert hosted["url"] == "https://example.test/dashboard/index.html" + assert hosted["status_code"] == 200 + assert hosted["ok"] is True + assert hosted["last_success_at"] + + +def test_save_file_endpoint_can_move_task_lifecycle_path(tmp_path: Path, monkeypatch) -> None: + write(tmp_path / "tasks/active/t-api-move.md", "---\nkind: task\nstatus: open\n---\n# Move Me\n") + client = client_for(tmp_path, monkeypatch) + try: + response = client.put( + "/api/vault/file", + headers={"Authorization": "Bearer test-token"}, + json={ + "path": "tasks/active/t-api-move.md", + "content": "---\nkind: task\nstatus: done\ncompleted: 2026-05-08\n---\n# Move Me\n", + "move_to": "tasks/done/t-api-move.md", + }, + ) + finally: + teardown_client() + + assert response.status_code == 200 + assert response.json()["path"] == "tasks/done/t-api-move.md" + assert not (tmp_path / "tasks/active/t-api-move.md").exists() + assert "completed: 2026-05-08" in (tmp_path / "tasks/done/t-api-move.md").read_text(encoding="utf-8") + + +def test_patch_task_metadata_endpoint_uses_auth_and_atomic_save(tmp_path: Path, monkeypatch) -> None: + write( + tmp_path / "tasks/active/t-api.md", + """--- +kind: task +status: open +priority: 1 +--- +# API Task +""", + ) + client = client_for(tmp_path, monkeypatch) + try: + response = client.patch( + "/api/vault/task-metadata", + headers={"Authorization": "Bearer test-token"}, + json={ + "path": "tasks/active/t-api.md", + "updates": {"status": "done", "priority": "", "deadline_type": "hard", "assignee": "Nikhil"}, + }, + ) + finally: + teardown_client() + + assert response.status_code == 200 + body = response.json() + assert body["path"] == "tasks/done/t-api.md" + assert body["frontmatter"]["status"] == "done" + assert body["frontmatter"]["deadline_type"] == "hard" + assert body["frontmatter"]["assignee"] == "Nikhil" + assert body["frontmatter"]["completed"] + assert "priority" not in body["frontmatter"] + assert not (tmp_path / "tasks/active/t-api.md").exists() + assert "status: done" in (tmp_path / "tasks/done/t-api.md").read_text(encoding="utf-8") + + +def test_patch_task_metadata_endpoint_rejects_non_task(tmp_path: Path, monkeypatch) -> None: + write(tmp_path / "notes/plain.md", "# Plain Note\n") + client = client_for(tmp_path, monkeypatch) + try: + response = client.patch( + "/api/vault/task-metadata", + headers={"Authorization": "Bearer test-token"}, + json={"path": "notes/plain.md", "updates": {"status": "done"}}, + ) + finally: + teardown_client() + + assert response.status_code == 400 + assert "Only task" in response.json()["detail"] + + +def test_patch_metadata_endpoint_allows_task_cockpit_fields(tmp_path: Path, monkeypatch) -> None: + write( + tmp_path / "tasks/active/t-cockpit.md", + """--- +kind: task +status: open +--- +# Cockpit Task +""", + ) + client = client_for(tmp_path, monkeypatch) + try: + response = client.patch( + "/api/vault/metadata", + headers={"Authorization": "Bearer test-token"}, + json={ + "path": "tasks/active/t-cockpit.md", + "updates": { + "next": "Do the next concrete action", + "block_day": "2026-05-08", + "block_week": "2026-05", + "estimate_minutes": "45", + "deadline_type": "soft", + "assignee": "Avery", + "related_to": "[[Project X]]", + }, + }, + ) + finally: + teardown_client() + + assert response.status_code == 200 + body = response.json()["frontmatter"] + assert body["next"] == "Do the next concrete action" + assert body["block_day"] == "2026-05-08" + assert body["block_week"] == "2026-05-" + assert body["estimate_minutes"] == 45 + assert body["deadline_type"] == "soft" + assert body["assignee"] == "Avery" + assert body["related_to"] == "[[Project X]]" + + +def test_patch_metadata_endpoint_allows_project_cockpit_fields(tmp_path: Path, monkeypatch) -> None: + write( + tmp_path / "projects/sample/README.md", + """--- +kind: project +title: Sample +status: active +--- +# Sample +""", + ) + client = client_for(tmp_path, monkeypatch) + try: + response = client.patch( + "/api/vault/metadata", + headers={"Authorization": "Bearer test-token"}, + json={ + "path": "projects/sample/README.md", + "updates": { + "deadline": "2026-06-03", + "deadline_type": "hard", + "next_action": "Review table", + "dashboard": "true", + }, + }, + ) + finally: + teardown_client() + + assert response.status_code == 200 + body = response.json()["frontmatter"] + assert body["deadline"] == "2026-06-03" + assert body["deadline_type"] == "hard" + assert body["next_action"] == "Review table" + assert body["dashboard"] is True + + +def test_patch_metadata_endpoint_rejects_wrong_kind_field(tmp_path: Path, monkeypatch) -> None: + write(tmp_path / "notes/plain.md", "---\nkind: note\n---\n# Plain Note\n") + client = client_for(tmp_path, monkeypatch) + try: + response = client.patch( + "/api/vault/metadata", + headers={"Authorization": "Bearer test-token"}, + json={"path": "notes/plain.md", "updates": {"deadline": "2026-06-03"}}, + ) + finally: + teardown_client() + + assert response.status_code == 400 + assert "field is not editable" in response.json()["detail"] + + +def test_reload_endpoint_forces_vault_cache_refresh(tmp_path: Path, monkeypatch) -> None: + write(tmp_path / "notes/a.md", "# A\n") + original_parse = VaultService.parse_file + parsed: list[str] = [] + + def counting_parse(self: VaultService, path: Path) -> dict: + parsed.append(self.relpath(path)) + return original_parse(self, path) + + monkeypatch.setattr(VaultService, "parse_file", counting_parse) + client = client_for(tmp_path, monkeypatch) + try: + first = client.get("/api/vault/entries", headers={"Authorization": "Bearer test-token"}) + parsed.clear() + second = client.get("/api/vault/entries", headers={"Authorization": "Bearer test-token"}) + assert second.status_code == 200 + assert parsed == [] + reload_response = client.post("/api/vault/reload", headers={"Authorization": "Bearer test-token"}) + finally: + teardown_client() + + assert first.status_code == 200 + assert reload_response.status_code == 200 + assert parsed == ["notes/a.md"] diff --git a/tests/test_time_blocks.py b/tests/test_time_blocks.py new file mode 100644 index 0000000..8126424 --- /dev/null +++ b/tests/test_time_blocks.py @@ -0,0 +1,595 @@ +from __future__ import annotations + +from datetime import date + +from scripts.time_blocks import allocate_daily_plan, allocate_weekly_plan, should_auto_schedule, task_time_category + + +def task( + title: str, + *, + due: str = "", + priority: int = 3, + estimate: int = 60, + status: str = "open", + category: str = "research", + deadline_type: str = "hard", + urgent: bool = False, + focus_rank: int | None = None, + focus_manual: bool = False, + schedule_policy: str = "", +) -> dict: + return { + "id": title, + "path": f"tasks/active/{title}.md", + "title": title, + "due": due, + "priority": priority, + "estimate_minutes": estimate, + "status": status, + "project": "alpha", + "domain": "admin" if category == "admin" else category, + "time_category": category, + "deadline_type": deadline_type, + "urgent": urgent, + "focus_rank": focus_rank, + "focus_manual": focus_manual, + "schedule_policy": schedule_policy, + } + + +def test_daily_allocator_prioritizes_overdue_today_and_p1_tasks() -> None: + scheduled, overflow = allocate_daily_plan( + [ + task("later", due="2026-05-12", priority=3), + task("p1", due="2026-05-10", priority=1), + task("today", due="2026-05-06", priority=2), + task("overdue", due="2026-05-05", priority=3), + ], + date(2026, 5, 6), + capacity_minutes=180, + ) + + assert [item.task["title"] for item in scheduled] == ["overdue", "today", "p1"] + assert [item.reason for item in scheduled] == ["overdue", "due today", "priority 1"] + assert [item["title"] for item in overflow] == ["later"] + + +def test_soft_deadlines_have_lighter_auto_scheduling_and_reason_labels() -> None: + scheduled, overflow = allocate_daily_plan( + [ + task("hard-today", due="2026-05-06", priority=3, deadline_type="hard"), + task("soft-today", due="2026-05-06", priority=3, deadline_type="soft"), + task("soft-far", due="2026-05-12", priority=3, deadline_type="soft"), + ], + date(2026, 5, 6), + capacity_minutes=120, + horizon_days=14, + ) + + assert [(item.task["title"], item.reason) for item in scheduled] == [ + ("hard-today", "due today"), + ("soft-today", "soft target today"), + ] + assert overflow == [] + assert not should_auto_schedule(task("soft-far", due="2026-05-12", priority=3, deadline_type="soft"), date(2026, 5, 6), 14) + + +def test_schedule_policy_overrides_auto_scheduling_and_category() -> None: + target_day = date(2026, 5, 6) + anchor = task("anchor", priority=4, category="", schedule_policy="research-anchor") + deferred = task("deferred", due="2026-05-06", priority=1, schedule_policy="defer") + + assert should_auto_schedule(anchor, target_day, 14) + assert not should_auto_schedule(deferred, target_day, 14) + assert task_time_category(anchor) == "research" + + scheduled, overflow = allocate_daily_plan([deferred, anchor], target_day, capacity_minutes=60) + + assert [(item.task["title"], item.reason) for item in scheduled] == [("anchor", "policy: research anchor")] + assert overflow == [] + + +def test_daily_allocator_respects_manual_block_day() -> None: + scheduled, _ = allocate_daily_plan( + [ + {**task("manual", due="2026-06-01", priority=4), "block_day": "2026-05-06"}, + task("today", due="2026-05-06", priority=1), + ], + date(2026, 5, 6), + capacity_minutes=120, + ) + + assert scheduled[0].task["title"] == "manual" + assert scheduled[0].reason == "manual day" + + +def test_p4_hard_deadline_stays_deferred_until_explicitly_focused_or_blocked() -> None: + parked = task("parked-hard", due="2026-05-08", priority=4, deadline_type="hard") + focused = task("focused-hard", due="2026-05-20", priority=4, deadline_type="hard", focus_rank=1) + blocked = {**task("blocked-hard", due="2026-05-20", priority=4, deadline_type="hard"), "block_day": "2026-05-06"} + + scheduled, overflow = allocate_daily_plan( + [parked, focused, blocked], + date(2026, 5, 6), + capacity_minutes=180, + horizon_days=14, + ) + + assert [(item.task["title"], item.reason) for item in scheduled] == [ + ("focused-hard", "focus #1"), + ("blocked-hard", "manual day"), + ] + assert overflow == [] + assert not should_auto_schedule(parked, date(2026, 5, 6), 14) + + +def test_weekly_allocator_spreads_without_duplicates() -> None: + scheduled_by_day, overflow = allocate_weekly_plan( + [ + task("due-today", due="2026-05-06", priority=2), + task("due-friday", due="2026-05-08", priority=2), + task("p1-undated", priority=1), + ], + date(2026, 5, 6), + capacity_minutes=60, + weekdays=3, + ) + + scheduled_titles = [item.task["title"] for items in scheduled_by_day.values() for item in items] + assert scheduled_titles == ["due-today", "due-friday", "p1-undated"] + assert len(scheduled_titles) == len(set(scheduled_titles)) + assert overflow == [] + + +def test_manual_urgent_tasks_dominate_daily_schedule() -> None: + scheduled, overflow = allocate_daily_plan( + [ + task("ordinary-overdue", due="2026-05-05", priority=1, estimate=60), + task("manual-urgent", due="2026-05-20", priority=3, estimate=60, urgent=True), + task("ordinary-today", due="2026-05-06", priority=1, estimate=60), + ], + date(2026, 5, 6), + capacity_minutes=120, + ) + + assert [(item.task["title"], item.reason) for item in scheduled] == [ + ("manual-urgent", "manual urgent"), + ("ordinary-overdue", "overdue"), + ] + assert [item["title"] for item in overflow] == ["ordinary-today"] + + +def test_manual_urgent_tasks_are_included_without_due_or_high_priority() -> None: + scheduled, overflow = allocate_daily_plan( + [ + task("manual-urgent", priority=4, estimate=60, urgent=True), + task("ordinary-low", priority=4, estimate=60), + ], + date(2026, 5, 6), + capacity_minutes=60, + ) + + assert [(item.task["title"], item.reason) for item in scheduled] == [("manual-urgent", "manual urgent")] + assert overflow == [] + + +def test_focus_rank_tasks_are_scheduled_before_regular_urgent_tasks() -> None: + scheduled, overflow = allocate_daily_plan( + [ + task("manual-urgent", due="2026-05-20", priority=3, estimate=60, urgent=True), + task("rank-two", priority=4, estimate=60, focus_rank=2), + task("rank-one", priority=4, estimate=60, focus_rank=1), + ], + date(2026, 5, 6), + capacity_minutes=120, + ) + + assert [(item.task["title"], item.reason) for item in scheduled] == [ + ("rank-one", "focus #1"), + ("rank-two", "focus #2"), + ] + assert [item["title"] for item in overflow] == ["manual-urgent"] + + +def test_manual_urgent_admin_task_overrides_admin_cap() -> None: + scheduled, overflow = allocate_daily_plan( + [ + task("manual-urgent-admin", priority=3, estimate=120, category="admin", urgent=True), + task("research-anchor", due="2026-05-06", priority=2, estimate=120), + task("ordinary-admin", due="2026-05-06", priority=1, estimate=120, category="admin"), + ], + date(2026, 5, 6), + capacity_minutes=240, + ) + + assert [(item.task["title"], item.reason, item.segment_index, item.segment_count) for item in scheduled] == [ + ("manual-urgent-admin", "manual urgent", 1, 1), + ("research-anchor", "due today", 1, 2), + ("research-anchor", "due today", 2, 2), + ] + assert [item["title"] for item in overflow] == ["ordinary-admin"] + + +def test_completed_and_cancelled_tasks_are_excluded() -> None: + scheduled, _ = allocate_daily_plan( + [ + task("done", due="2026-05-06", priority=1, status="done"), + task("cancelled", due="2026-05-06", priority=1, status="cancelled"), + task("open", due="2026-05-06", priority=1), + ], + date(2026, 5, 6), + capacity_minutes=180, + ) + + assert [item.task["title"] for item in scheduled] == ["open"] + + +def test_waiting_and_blocked_tasks_are_excluded_unless_manually_forced() -> None: + scheduled, overflow = allocate_daily_plan( + [ + task("waiting", due="2026-05-06", priority=1, status="waiting"), + task("blocked", due="2026-05-06", priority=1, status="blocked"), + {**task("forced", due="2026-06-01", priority=4, status="waiting"), "block_day": "2026-05-06"}, + task("open", due="2026-05-06", priority=1), + ], + date(2026, 5, 6), + capacity_minutes=270, + ) + + assert [item.task["title"] for item in scheduled] == ["forced", "open"] + assert [item.task["title"] for item in scheduled if item.reason == "manual day"] == ["forced"] + assert overflow == [] + + +def test_daily_allocator_skips_default_lunch_break() -> None: + scheduled, _ = allocate_daily_plan( + [ + task("one", due="2026-05-06", priority=1), + task("two", due="2026-05-06", priority=1), + task("three", due="2026-05-06", priority=1), + task("four", due="2026-05-06", priority=1), + ], + date(2026, 5, 6), + capacity_minutes=270, + ) + + assert [(item.start, item.end) for item in scheduled] == [ + ("09:00", "10:00"), + ("10:00", "11:00"), + ("11:00", "12:00"), + ("13:00", "14:00"), + ] + + +def test_daily_allocator_splits_tasks_around_lunch_without_crossing_or_extending_day() -> None: + scheduled, overflow = allocate_daily_plan( + [ + task("deep", due="2026-05-06", priority=1, estimate=240), + task("follow", due="2026-05-06", priority=1, estimate=180), + ], + date(2026, 5, 6), + capacity_minutes=420, + ) + + assert [(item.task["title"], item.start, item.end, item.segment_index, item.segment_count) for item in scheduled] == [ + ("deep", "09:00", "12:00", 1, 2), + ("deep", "13:00", "14:00", 2, 2), + ("follow", "14:00", "17:00", 1, 1), + ] + assert overflow == [] + + +def test_daily_allocator_does_not_partially_schedule_task_that_cannot_fit() -> None: + scheduled, overflow = allocate_daily_plan( + [ + task("too-large", due="2026-05-06", priority=1, estimate=500), + task("small", due="2026-05-06", priority=1, estimate=60), + ], + date(2026, 5, 6), + capacity_minutes=420, + ) + + assert [item.task["title"] for item in scheduled] == ["small"] + assert [item["title"] for item in overflow] == ["too-large"] + + +def test_daily_allocator_limits_admin_tasks_by_default() -> None: + scheduled, overflow = allocate_daily_plan( + [ + task("research-anchor", due="2026-05-06", priority=2, estimate=180), + task("admin-a", due="2026-05-15", priority=3, estimate=30, category="admin"), + task("admin-b", due="2026-05-15", priority=3, estimate=30, category="admin"), + task("admin-c", due="2026-05-15", priority=3, estimate=30, category="admin"), + ], + date(2026, 5, 6), + capacity_minutes=300, + horizon_days=14, + ) + + assert [item.task["title"] for item in scheduled] == ["research-anchor", "admin-a"] + assert [item["title"] for item in overflow] == ["admin-b", "admin-c"] + assert {item["_overflow_reason"] for item in overflow} == {"admin cap"} + + +def test_daily_allocator_allows_second_admin_when_due_or_p1() -> None: + scheduled, overflow = allocate_daily_plan( + [ + task("research-anchor", due="2026-05-06", priority=2, estimate=180), + task("admin-p1", priority=1, estimate=30, category="admin"), + task("admin-due", due="2026-05-06", priority=3, estimate=30, category="admin"), + task("admin-extra", due="2026-05-15", priority=3, estimate=30, category="admin"), + ], + date(2026, 5, 6), + capacity_minutes=300, + horizon_days=14, + ) + + assert [item.task["title"] for item in scheduled] == ["research-anchor", "admin-due", "admin-p1"] + assert [item["title"] for item in overflow] == ["admin-extra"] + assert overflow[0]["_overflow_reason"] == "admin cap" + + +def test_daily_allocator_batches_admin_at_day_edge_after_research_fill() -> None: + scheduled, overflow = allocate_daily_plan( + [ + task("research-anchor", due="2026-05-06", priority=2, estimate=180), + task("research-fill", due="2026-05-07", priority=2, estimate=210), + task("admin-slot", due="2026-05-06", priority=1, estimate=30, category="admin"), + ], + date(2026, 5, 6), + capacity_minutes=420, + ) + + assert [item.task["title"] for item in scheduled] == [ + "research-anchor", + "research-fill", + "admin-slot", + ] + assert [(item.task["title"], item.start, item.end) for item in scheduled] == [ + ("research-anchor", "09:00", "12:00"), + ("research-fill", "13:00", "16:30"), + ("admin-slot", "16:30", "17:00"), + ] + assert overflow == [] + + +def test_daily_allocator_keeps_admin_batch_contiguous_and_capped() -> None: + scheduled, overflow = allocate_daily_plan( + [ + task("research-anchor", due="2026-05-06", priority=2, estimate=180), + task("research-fill", due="2026-05-07", priority=2, estimate=90), + task("admin-due", due="2026-05-06", priority=3, estimate=45, category="admin"), + task("admin-p1", priority=1, estimate=45, category="admin"), + task("admin-extra", due="2026-05-15", priority=3, estimate=30, category="admin"), + ], + date(2026, 5, 6), + capacity_minutes=420, + ) + + assert [(item.task["title"], item.start, item.end) for item in scheduled] == [ + ("research-anchor", "09:00", "12:00"), + ("research-fill", "13:00", "14:30"), + ("admin-due", "15:30", "16:15"), + ("admin-p1", "16:15", "17:00"), + ] + assert [item["title"] for item in overflow] == ["admin-extra"] + assert overflow[0]["_overflow_reason"] == "admin cap" + + +def test_daily_allocator_keeps_research_as_largest_block() -> None: + scheduled, overflow = allocate_daily_plan( + [ + task("research-anchor", due="2026-05-06", priority=2, estimate=120), + task("large-admin", due="2026-05-06", priority=1, estimate=120, category="admin"), + task("small-admin", due="2026-05-06", priority=1, estimate=30, category="admin"), + ], + date(2026, 5, 6), + capacity_minutes=300, + ) + + assert [item.task["title"] for item in scheduled] == ["research-anchor", "small-admin"] + assert [item["title"] for item in overflow] == ["large-admin"] + assert overflow[0]["_overflow_reason"] == "research anchor protected" + + +def test_daily_allocator_defers_admin_when_no_research_block_fits() -> None: + scheduled, overflow = allocate_daily_plan( + [ + task("too-large-research", due="2026-05-06", priority=1, estimate=500), + task("small-admin", due="2026-05-06", priority=1, estimate=30, category="admin"), + ], + date(2026, 5, 6), + capacity_minutes=120, + ) + + assert scheduled == [] + assert [item["title"] for item in overflow] == ["too-large-research", "small-admin"] + assert overflow[1]["_overflow_reason"] == "no research block fits" + + +def test_weekly_allocator_applies_admin_cap_per_day() -> None: + scheduled_by_day, overflow = allocate_weekly_plan( + [ + task("research-day-one", due="2026-05-06", priority=2, estimate=120), + task("admin-day-one", due="2026-05-06", priority=3, estimate=30, category="admin"), + task("research-day-two", due="2026-05-07", priority=2, estimate=120), + task("admin-day-two", due="2026-05-07", priority=3, estimate=30, category="admin"), + ], + date(2026, 5, 6), + capacity_minutes=150, + weekdays=2, + ) + + assert [item.task["title"] for item in scheduled_by_day[date(2026, 5, 6)]] == ["research-day-one", "admin-day-one"] + assert [item.task["title"] for item in scheduled_by_day[date(2026, 5, 7)]] == ["research-day-two", "admin-day-two"] + assert overflow == [] + + +def test_weekly_allocator_backfills_weekend_deadlines_before_weekend_or_overflows() -> None: + scheduled_by_day, overflow = allocate_weekly_plan( + [ + task("friday-full", due="2026-05-08", priority=1, estimate=420), + task("sunday-deadline", due="2026-05-10", priority=1, estimate=60), + ], + date(2026, 5, 8), + capacity_minutes=420, + weekdays=3, + ) + + assert [item.task["title"] for item in scheduled_by_day[date(2026, 5, 8)]] == ["friday-full", "friday-full"] + assert [item.task["title"] for item in scheduled_by_day[date(2026, 5, 11)]] == [] + assert [item.task["title"] for item in scheduled_by_day[date(2026, 5, 12)]] == [] + assert [item["title"] for item in overflow] == ["sunday-deadline"] + assert overflow[0]["_overflow_reason"] == "missed weekend deadline" + + +def test_no_work_before_blocks_daily_and_rolls_weekly_start() -> None: + daily, daily_overflow = allocate_daily_plan( + [task("urgent", due="2026-05-08", priority=1)], + date(2026, 5, 8), + capacity_minutes=60, + no_work_before=date(2026, 5, 11), + ) + weekly, _weekly_overflow = allocate_weekly_plan( + [task("urgent", due="2026-05-08", priority=1)], + date(2026, 5, 8), + capacity_minutes=60, + weekdays=2, + no_work_before=date(2026, 5, 11), + ) + + assert daily == [] + assert [item["title"] for item in daily_overflow] == ["urgent"] + assert daily_overflow[0]["_overflow_reason"] == "non-working day" + assert list(weekly) == [date(2026, 5, 11), date(2026, 5, 12)] + + +def test_calendar_events_block_task_windows_without_becoming_admin_tasks() -> None: + scheduled, overflow = allocate_daily_plan( + [ + task("research-anchor", due="2026-05-06", priority=1, estimate=180), + task("admin-slot", due="2026-05-06", priority=1, estimate=30, category="admin"), + ], + date(2026, 5, 6), + capacity_minutes=270, + calendar_events=[ + { + "id": "meeting-1", + "date": "2026-05-06", + "title": "Research meeting", + "start": "10:00", + "end": "11:00", + "source_label": "Work", + } + ], + ) + + assert [(item.task.get("kind", "task"), item.task["title"], item.start, item.end) for item in scheduled] == [ + ("task", "research-anchor", "09:00", "10:00"), + ("calendar", "Research meeting", "10:00", "11:00"), + ("task", "research-anchor", "11:00", "12:00"), + ("task", "research-anchor", "13:00", "14:00"), + ("task", "admin-slot", "14:00", "14:30"), + ] + assert overflow == [] + + +def test_calendar_conflict_explains_overflow_when_meetings_consume_capacity() -> None: + scheduled, overflow = allocate_daily_plan( + [task("research-anchor", due="2026-05-06", priority=1, estimate=180)], + date(2026, 5, 6), + capacity_minutes=180, + calendar_events=[ + { + "id": "meeting-1", + "date": "2026-05-06", + "title": "Morning meeting", + "start": "09:00", + "end": "11:00", + "source_label": "Work", + } + ], + ) + + assert [item.task["title"] for item in scheduled] == ["Morning meeting"] + assert [item["title"] for item in overflow] == ["research-anchor"] + assert overflow[0]["_overflow_reason"] == "calendar conflict" + + +def test_all_day_calendar_event_blocks_full_workday() -> None: + scheduled, overflow = allocate_daily_plan( + [task("research-anchor", due="2026-05-06", priority=1, estimate=60)], + date(2026, 5, 6), + capacity_minutes=420, + calendar_events=[ + { + "id": "conference", + "date": "2026-05-06", + "title": "Spatial Inequality Conference", + "all_day": True, + "source_label": "Work", + } + ], + ) + + assert [(item.task.get("kind", "task"), item.task["title"], item.start, item.end, item.minutes) for item in scheduled] == [ + ("calendar", "Spatial Inequality Conference", "09:00", "17:00", 420) + ] + assert [item["title"] for item in overflow] == ["research-anchor"] + assert overflow[0]["_overflow_reason"] == "calendar conflict" + + +def test_overlapping_all_day_calendar_events_do_not_create_zero_minute_blocks() -> None: + scheduled, overflow = allocate_daily_plan( + [task("research-anchor", due="2026-05-06", priority=1, estimate=60)], + date(2026, 5, 6), + capacity_minutes=420, + calendar_events=[ + { + "id": "conference", + "date": "2026-05-06", + "title": "Spatial Inequality Conference", + "all_day": True, + "source_label": "Work", + }, + { + "id": "markdown-conference", + "date": "2026-05-06", + "title": "Spatial Inequality Conference begins", + "all_day": True, + "source_label": "Markdown dates", + }, + ], + ) + + assert [(item.task["title"], item.minutes) for item in scheduled] == [("Spatial Inequality Conference", 420)] + assert [item["title"] for item in overflow] == ["research-anchor"] + + +def test_weekly_allocator_applies_calendar_blocks_per_day() -> None: + scheduled_by_day, overflow = allocate_weekly_plan( + [ + task("research-day-one", due="2026-05-06", priority=1, estimate=60), + task("research-day-two", due="2026-05-07", priority=1, estimate=60), + ], + date(2026, 5, 6), + capacity_minutes=120, + weekdays=2, + calendar_events_by_day={ + date(2026, 5, 6): [ + { + "id": "meeting-1", + "date": "2026-05-06", + "title": "Work meeting", + "start": "09:00", + "end": "10:00", + "source_label": "Work", + } + ], + }, + ) + + assert [item.task["title"] for item in scheduled_by_day[date(2026, 5, 6)]] == ["Work meeting", "research-day-one"] + assert [item.task["title"] for item in scheduled_by_day[date(2026, 5, 7)]] == ["research-day-two"] + assert overflow == [] diff --git a/tests/test_time_plan_api.py b/tests/test_time_plan_api.py new file mode 100644 index 0000000..5f782f9 --- /dev/null +++ b/tests/test_time_plan_api.py @@ -0,0 +1,411 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from fastapi.testclient import TestClient + +from server.app import app, get_service +from server.vault import VaultService + + +def write(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + +def task_file( + root: Path, + name: str, + *, + title: str, + due: str = "2026-05-06", + status: str = "open", + priority: int = 1, + estimate: int = 60, + deadline_type: str = "hard", + extra: str = "", +) -> None: + write( + root / f"tasks/active/{name}.md", + f"""--- +kind: task +status: {status} +project: alpha +domain: research +priority: {priority} +due: {due} +deadline_type: {deadline_type} +estimate_minutes: {estimate} +next: "Do {title}" +{extra}--- +# {title} +""", + ) + + +def date_event_file( + root: Path, + name: str, + *, + title: str, + event_date: str, + event_type: str, + end_date: str | None = None, +) -> None: + end_line = f'end_date: "{end_date}"\n' if end_date else "" + write( + root / f"dates/{name}.md", + f"""--- +kind: event +id: {name} +date: "{event_date}" +{end_line}title: "{title}" +project: alpha +type: "{event_type}" +--- +# {title} +""", + ) + + +def client_for(root: Path, monkeypatch) -> TestClient: + app.dependency_overrides[get_service] = lambda: VaultService(root) + monkeypatch.setenv("PM_APP_TOKEN", "test-token") + monkeypatch.setenv("PM_REQUIRE_LOCAL_AUTH", "true") + monkeypatch.delenv("TIMEPLAN_CALENDAR_ICS_URLS", raising=False) + monkeypatch.delenv("TIMEPLAN_CALENDAR_USER_EMAILS", raising=False) + return TestClient(app) + + +def teardown_client() -> None: + app.dependency_overrides.clear() + + +def test_time_plan_endpoint_allocates_daily_and_weekly_plan(tmp_path: Path, monkeypatch) -> None: + task_file(tmp_path, "manual", title="Manual", due="2026-06-01", priority=4, extra="block_day: 2026-05-06\n") + task_file(tmp_path, "today", title="Today") + task_file(tmp_path, "done", title="Done", status="done") + + client = client_for(tmp_path, monkeypatch) + try: + response = client.get( + "/api/time-plan?date=2026-05-06&week_start=2026-05-06&capacity_minutes=120", + headers={"Authorization": "Bearer test-token"}, + ) + finally: + teardown_client() + + assert response.status_code == 200 + body = response.json() + assert body["settings"]["date"] == "2026-05-06" + assert body["settings"]["week_start"] == "2026-05-06" + assert len(body["weekly"]) == 5 + assert [block["title"] for block in body["daily"]["blocks"]] == ["Manual", "Today"] + assert [block["reason"] for block in body["daily"]["blocks"]] == ["manual day", "due today"] + assert "Done" not in [block["title"] for block in body["daily"]["blocks"]] + + +def test_time_plan_endpoint_respects_lunch_and_overflow(tmp_path: Path, monkeypatch) -> None: + for title in ["Alpha", "Beta", "Gamma", "Example Air", "Overflow"]: + task_file(tmp_path, title.lower(), title=title) + + client = client_for(tmp_path, monkeypatch) + try: + response = client.get( + "/api/time-plan?date=2026-05-06&capacity_minutes=240", + headers={"Authorization": "Bearer test-token"}, + ) + finally: + teardown_client() + + assert response.status_code == 200 + body = response.json() + assert [(block["start"], block["end"]) for block in body["daily"]["blocks"]] == [ + ("09:00", "10:00"), + ("10:00", "11:00"), + ("11:00", "12:00"), + ("13:00", "14:00"), + ] + assert [task["title"] for task in body["daily_overflow"]] == ["Overflow"] + + +def test_time_plan_endpoint_excludes_waiting_and_exposes_split_segments(tmp_path: Path, monkeypatch) -> None: + task_file(tmp_path, "waiting", title="Waiting", status="waiting", estimate=60) + task_file(tmp_path, "deep", title="Deep", estimate=240) + task_file(tmp_path, "follow", title="Follow", estimate=180) + + client = client_for(tmp_path, monkeypatch) + try: + response = client.get( + "/api/time-plan?date=2026-05-06&capacity_minutes=420", + headers={"Authorization": "Bearer test-token"}, + ) + finally: + teardown_client() + + assert response.status_code == 200 + body = response.json() + assert [(block["title"], block["start"], block["end"], block["segment_index"], block["segment_count"]) for block in body["daily"]["blocks"]] == [ + ("Deep", "09:00", "12:00", 1, 2), + ("Deep", "13:00", "14:00", 2, 2), + ("Follow", "14:00", "17:00", 1, 1), + ] + assert "Waiting" not in [block["title"] for block in body["daily"]["blocks"]] + + +def test_time_plan_endpoint_respects_no_work_before_setting(tmp_path: Path, monkeypatch) -> None: + write( + tmp_path / "settings/time_planning.md", + "---\nprivate: true\nno_work_before: 2026-05-11\n---\n# Time preferences\n", + ) + task_file(tmp_path, "urgent", title="Urgent", due="2026-05-08", priority=1) + + client = client_for(tmp_path, monkeypatch) + try: + response = client.get( + "/api/time-plan?date=2026-05-08&week_start=2026-05-08&capacity_minutes=60&weekdays=2", + headers={"Authorization": "Bearer test-token"}, + ) + finally: + teardown_client() + + assert response.status_code == 200 + body = response.json() + assert body["settings"]["no_work_before"] == "2026-05-11" + assert body["daily"]["blocks"] == [] + assert [task["reason"] for task in body["daily_overflow"]] == ["non-working day"] + assert [day["date"] for day in body["weekly"]] == ["2026-05-11", "2026-05-12"] + + +def test_time_plan_endpoint_imports_calendar_blocks_and_keeps_feed_private(tmp_path: Path, monkeypatch) -> None: + task_file(tmp_path, "today", title="Today", estimate=120) + ics_path = tmp_path / "work.ics" + write( + ics_path, + """BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +UID:meeting-1 +SUMMARY:Research meeting https://secret.example/token +DTSTART:20260506T100000 +DTEND:20260506T110000 +TRANSP:OPAQUE +END:VEVENT +END:VCALENDAR +""", + ) + + client = client_for(tmp_path, monkeypatch) + monkeypatch.setenv( + "TIMEPLAN_CALENDAR_ICS_URLS", + json.dumps([{"label": "Work", "url": ics_path.as_uri(), "scope": "mixed"}]), + ) + try: + response = client.get( + "/api/time-plan?date=2026-05-06&week_start=2026-05-06&capacity_minutes=240", + headers={"Authorization": "Bearer test-token"}, + ) + agent_response = client.get( + "/api/time-plan/agent-context?date=2026-05-06&week_start=2026-05-06&capacity_minutes=240", + headers={"Authorization": "Bearer test-token"}, + ) + finally: + teardown_client() + + assert response.status_code == 200 + body = response.json() + assert body["calendar"]["enabled"] is True + assert body["calendar"]["source_labels"] == ["Work"] + assert "file://" not in str(body) + assert [(block["kind"], block["title"], block["start"], block["end"]) for block in body["daily"]["blocks"]] == [ + ("task", "Today", "09:00", "10:00"), + ("calendar", "Research meeting", "10:00", "11:00"), + ("task", "Today", "11:00", "12:00"), + ] + calendar_block = body["daily"]["blocks"][1] + assert calendar_block["readonly"] is True + assert calendar_block["sourceLabel"] == "Work" + assert "https://" not in calendar_block["title"] + assert agent_response.status_code == 200 + agent_body = agent_response.json() + assert agent_body["calendar"]["event_count"] == 1 + assert "Research meeting" in agent_body["agent_prompt"] + assert "file://" not in agent_body["agent_prompt"] + + +def test_time_plan_endpoint_uses_private_local_calendar_snapshot(tmp_path: Path, monkeypatch) -> None: + task_file(tmp_path, "today", title="Today", estimate=120) + write( + tmp_path / "local_data/calendar/timeplan_events.json", + json.dumps( + { + "generated_at": "2026-05-11T18:00:00-04:00", + "source_label": "Google Calendar snapshot", + "events": [ + { + "title": "Research meeting https://secret.example/token", + "date": "2026-05-06", + "start": "10:00", + "end": "11:00", + "work": True, + } + ], + } + ), + ) + + client = client_for(tmp_path, monkeypatch) + try: + response = client.get( + "/api/time-plan?date=2026-05-06&week_start=2026-05-06&capacity_minutes=240", + headers={"Authorization": "Bearer test-token"}, + ) + finally: + teardown_client() + + assert response.status_code == 200 + body = response.json() + assert body["calendar"]["enabled"] is True + assert body["calendar"]["source_labels"] == ["Google Calendar snapshot"] + assert "local_data" not in str(body) + assert [(block["kind"], block["title"], block["start"], block["end"]) for block in body["daily"]["blocks"]] == [ + ("task", "Today", "09:00", "10:00"), + ("calendar", "Research meeting", "10:00", "11:00"), + ("task", "Today", "11:00", "12:00"), + ] + assert body["daily"]["blocks"][1]["private"] is False + assert "https://" not in body["daily"]["blocks"][1]["title"] + + +def test_time_plan_endpoint_blocks_markdown_conference_and_travel_dates(tmp_path: Path, monkeypatch) -> None: + task_file(tmp_path, "today", title="Today", estimate=60) + date_event_file( + tmp_path, + "research-conference", + title="Research conference", + event_date="2026-05-06", + end_date="2026-05-07", + event_type="conference", + ) + date_event_file( + tmp_path, + "travel-day", + title="Return travel", + event_date="2026-05-08", + event_type="travel", + ) + + client = client_for(tmp_path, monkeypatch) + try: + response = client.get( + "/api/time-plan?date=2026-05-06&week_start=2026-05-06&capacity_minutes=120&weekdays=3", + headers={"Authorization": "Bearer test-token"}, + ) + finally: + teardown_client() + + assert response.status_code == 200 + body = response.json() + assert body["calendar"]["enabled"] is True + assert body["calendar"]["event_count"] == 3 + assert body["calendar"]["source_labels"] == ["Markdown dates"] + assert [(block["kind"], block["title"], block["allDay"], block["start"], block["end"]) for block in body["daily"]["blocks"]] == [ + ("calendar", "Research conference", True, "09:00", "11:00") + ] + assert [task["title"] for task in body["daily_overflow"]] == ["Today"] + assert body["daily_overflow"][0]["reason"] == "calendar conflict" + assert [ + [(block["kind"], block["title"]) for block in day["blocks"]] + for day in body["weekly"] + ] == [ + [("calendar", "Research conference")], + [("calendar", "Research conference")], + [("calendar", "Return travel")], + ] + + +def test_time_plan_endpoint_uses_same_auth_boundary(tmp_path: Path, monkeypatch) -> None: + task_file(tmp_path, "today", title="Today") + app.dependency_overrides[get_service] = lambda: VaultService(tmp_path) + monkeypatch.delenv("PM_APP_TOKEN", raising=False) + monkeypatch.setenv("PM_REQUIRE_LOCAL_AUTH", "true") + client = TestClient(app) + try: + response = client.get("/api/time-plan?date=2026-05-06") + finally: + teardown_client() + + assert response.status_code == 503 + assert "PM_APP_TOKEN" in response.json()["detail"] + + +def test_vault_drift_endpoint_reports_counts_and_respects_auth(tmp_path: Path, monkeypatch) -> None: + source = tmp_path / "source" + target = tmp_path / "target" + write(source / "tasks/active/source-only.md", "# Source Only\n") + write(source / "tasks/active/changed.md", "# Source Version\n") + write(target / "tasks/active/changed.md", "# Target Version\n") + write(target / "tasks/active/extra.md", "# Extra\n") + monkeypatch.setenv("PM_VAULT_SEED_SOURCE", str(source)) + + client = client_for(target, monkeypatch) + try: + unauthorized = client.get("/api/vault/drift") + response = client.get("/api/vault/drift?include_paths=true", headers={"Authorization": "Bearer test-token"}) + finally: + teardown_client() + + assert unauthorized.status_code == 401 + assert response.status_code == 200 + body = response.json() + assert body["counts"]["missing"] == 1 + assert body["counts"]["changed"] == 1 + assert body["counts"]["extra"] == 1 + assert body["paths"]["missing"] == ["tasks/active/source-only.md"] + + +def test_time_plan_agent_context_endpoint_returns_prompt_and_respects_private_filter(tmp_path: Path, monkeypatch) -> None: + write( + tmp_path / "settings/time_planning.md", + """--- +private: true +--- +# Preferences + +- Batch admin late. +""", + ) + task_file(tmp_path, "today", title="Today") + task_file(tmp_path, "private", title="Private", extra="private: true\n") + write( + tmp_path / "projects/alpha/README.md", + """--- +kind: project +id: alpha +title: Alpha +status: active +priority: 1 +--- +# Alpha +""", + ) + + client = client_for(tmp_path, monkeypatch) + try: + response = client.get( + "/api/time-plan/agent-context?date=2026-05-06&week_start=2026-05-06&include_private=false&ad_hoc=protect%20writing", + headers={"Authorization": "Bearer test-token"}, + ) + finally: + teardown_client() + + assert response.status_code == 200 + body = response.json() + assert body["preferences"]["exists"] is True + assert "Batch admin late" in body["preferences"]["summary"] + assert body["ad_hoc"] == "protect writing" + assert [task["title"] for task in body["pressing_tasks"]] == ["Today"] + assert "Private" not in body["agent_prompt"] + assert "protect writing" in body["agent_prompt"] + assert body["pressing_projects"][0]["title"] == "Alpha" diff --git a/tests/test_time_plan_context.py b/tests/test_time_plan_context.py new file mode 100644 index 0000000..ca28fa4 --- /dev/null +++ b/tests/test_time_plan_context.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from datetime import date +from pathlib import Path + +from scripts.time_plan_context import build_agent_plan_context + + +def write(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + +def test_agent_plan_context_includes_preferences_ad_hoc_and_prompt(tmp_path: Path) -> None: + write( + tmp_path / "settings/time_planning.md", + """--- +private: true +--- +# Preferences + +- Protect morning deep work. +""", + ) + tasks = [ + { + "path": "tasks/active/t-one.md", + "title": "Urgent task", + "project": "alpha", + "status": "open", + "priority": 1, + "due": "2026-05-06", + "estimate_minutes": 60, + }, + { + "path": "tasks/active/t-private.md", + "title": "Private task", + "project": "alpha", + "status": "open", + "priority": 1, + "due": "2026-05-06", + "estimate_minutes": 60, + "private": True, + }, + ] + projects = [{"id": "alpha", "name": "Alpha", "priority": 1, "status": "active", "path": "projects/alpha/README.md"}] + + context = build_agent_plan_context( + root=tmp_path, + tasks=tasks, + projects=projects, + target_day=date(2026, 5, 6), + week_start_day=date(2026, 5, 6), + start="09:00", + capacity_minutes=120, + weekdays=5, + horizon_days=14, + week_prefix="2026-05-", + ad_hoc="Low energy today.", + include_private=False, + ) + + assert context["preferences"]["exists"] is True + assert "Protect morning deep work" in context["preferences"]["summary"] + assert context["ad_hoc"] == "Low energy today." + assert [task["title"] for task in context["pressing_tasks"]] == ["Urgent task"] + assert "Private task" not in context["agent_prompt"] + assert "Low energy today." in context["agent_prompt"] + assert "Do not mark tasks complete" in context["agent_prompt"] + + +def test_agent_plan_context_handles_missing_preferences(tmp_path: Path) -> None: + context = build_agent_plan_context( + root=tmp_path, + tasks=[], + projects=[], + target_day=date(2026, 5, 6), + week_start_day=date(2026, 5, 6), + start="09:00", + capacity_minutes=120, + weekdays=5, + horizon_days=14, + week_prefix="2026-05-", + ) + + assert context["preferences"]["exists"] is False + assert "No stored time-planning preference profile found" in context["preferences"]["summary"] + assert "No deterministic daily allocation" in context["agent_prompt"] diff --git a/tests/test_vault_parsing.py b/tests/test_vault_parsing.py new file mode 100644 index 0000000..005762f --- /dev/null +++ b/tests/test_vault_parsing.py @@ -0,0 +1,64 @@ +"""Characterization tests for the shared vault-parsing primitives. + +These pin the behavior that both server/vault.py and scripts/markdown_reader.py +now share, including the frontmatter edge cases the parser feeds the entire +JSON cache from. +""" +from scripts import vault_parsing as vp +from server import vault as server_vault +from scripts import markdown_reader + + +def test_split_frontmatter_happy_path(): + fm, body = vp.split_frontmatter("---\nkind: task\nstatus: open\n---\n# Title\nbody\n") + assert fm == {"kind": "task", "status": "open"} + assert body == "# Title\nbody\n" + + +def test_split_frontmatter_edge_cases(): + # No frontmatter at all + assert vp.split_frontmatter("# Just a body") == ({}, "# Just a body") + # Opening fence but no closing fence -> treated as no frontmatter + assert vp.split_frontmatter("---\nkind: task\n# never closed") == ({}, "---\nkind: task\n# never closed") + # Empty frontmatter block + assert vp.split_frontmatter("---\n---\nbody") == ({}, "body") + # CRLF line endings + fm, _ = vp.split_frontmatter("---\r\nkind: task\r\n---\r\nbody") + assert fm == {"kind": "task"} + # Non-dict YAML (a list) degrades to {} rather than corrupting downstream + assert vp.split_frontmatter("---\n- a\n- b\n---\nbody") == ({}, "body") + # Malformed YAML is swallowed, not raised + assert vp.split_frontmatter("---\nkey: : :\n---\nbody")[0] == {} + + +def test_scalar_and_listify(): + assert vp.scalar(None) is None + assert vp.scalar(["a"]) is None + assert vp.scalar(3) == "3" + assert vp.listify(None) == [] + assert vp.listify(" ") == [] + assert vp.listify("one") == ["one"] + assert vp.listify(["a", None, "b"]) == ["a", "b"] + + +def test_snippet_and_word_count(): + # word_count strips code spans and markdown links but not heading markers. + assert vp.word_count("# Heading\nsome `code` and [a](http://x) words") == 4 + long = "x " * 200 + out = vp.snippet(long) + assert out.endswith("…") + assert len(out) <= 221 + + +def test_both_consumers_use_the_same_shared_helpers(): + # The whole point of the consolidation: one implementation, no drift. + assert server_vault.split_frontmatter is markdown_reader.split_frontmatter is vp.split_frontmatter + assert server_vault.snippet is markdown_reader.snippet is vp.snippet + assert server_vault.extract_codex_instructions is markdown_reader.extract_codex_instructions + + +def test_core_fields_stay_consumer_specific(): + # CORE_FIELDS is intentionally NOT shared: the server promotes assignee to a + # top-level field; the reader exposes it via `properties`. Guard the divergence. + assert "assignee" in server_vault.CORE_FIELDS + assert "assignee" not in markdown_reader.CORE_FIELDS diff --git a/tests/test_vault_schema.py b/tests/test_vault_schema.py new file mode 100644 index 0000000..b50e952 --- /dev/null +++ b/tests/test_vault_schema.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +from pathlib import Path + +from scripts.validate_vault_schema import validate_root + + +def write(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + +def test_schema_validator_accepts_canonical_task_and_travel_ledger(tmp_path: Path) -> None: + write( + tmp_path / "tasks/active/t-good.md", + """--- +id: t-good +kind: task +status: open +priority: 1 +deadline_type: hard +due: 2026-05-28 +urgent: true +--- +# Good task +""", + ) + write( + tmp_path / "projects/operations/travel_ledger.md", + """--- +kind: travel-ledger +ledger: + - id: test-trip-flight + trip_key: test-trip + item: Flight estimate + category: flight + estimate: 500 + currency: USD + status: approval_needed + reimbursable: true +--- +# Travel ledger +""", + ) + + assert validate_root(tmp_path) == [] + + +def test_schema_validator_reports_invalid_task_fields(tmp_path: Path) -> None: + write( + tmp_path / "tasks/active/t-bad.md", + """--- +id: wrong-id +kind: task +status: urgent-ish +priority: high +deadline_type: approximate +due: soon +urgent: maybe +--- +# Bad task +""", + ) + + issues = validate_root(tmp_path) + fields = {issue.field for issue in issues} + + assert {"id", "status", "priority", "deadline_type", "due", "urgent"} <= fields + + +def test_schema_validator_reports_deprecated_status_and_ownership_fields(tmp_path: Path) -> None: + write( + tmp_path / "tasks/active/t-deprecated.md", + """--- +id: t-deprecated +kind: task +status: completed +priority: 2 +deadline_type: soft +due: 2026-05-28 +lead: Owner +assignee: Owner +owner: Team +--- +# Deprecated task +""", + ) + + issues = validate_root(tmp_path) + messages = "\n".join(f"{issue.field}: {issue.message}" for issue in issues) + + assert "deprecated task status 'completed'; use 'done'" in messages + assert "deprecated task ownership field; use 'assignee'" in messages + assert "use only one task ownership field" in messages + + +def test_schema_validator_requires_deadline_type_for_open_dated_tasks(tmp_path: Path) -> None: + write( + tmp_path / "tasks/active/t-dated.md", + """--- +id: t-dated +kind: task +status: open +priority: 2 +due: 2026-05-28 +--- +# Dated task +""", + ) + + issues = validate_root(tmp_path) + + assert any( + issue.field == "deadline_type" and "open dated tasks" in issue.message + for issue in issues + ) + + +def test_schema_validator_reports_travel_ledger_shape_errors(tmp_path: Path) -> None: + write( + tmp_path / "projects/operations/travel_ledger.md", + """--- +kind: travel-ledger +ledger: + - id: bad-row + trip_key: bad + item: Broken row + category: flight + status: mystery + estimate: lots + booking_url: https://example.com +--- +# Travel ledger +""", + ) + + issues = validate_root(tmp_path) + messages = "\n".join(f"{issue.field}: {issue.message}" for issue in issues) + + assert "unknown ledger field(s): booking_url" in messages + assert "unsupported ledger status 'mystery'" in messages + assert "amount fields must be numeric" in messages diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..5f4731c --- /dev/null +++ b/uv.lock @@ -0,0 +1,959 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/b5/001890774a9552aff22502b8da382593109ce0c95314abaebbb116567545/anyio-4.14.0.tar.gz", hash = "sha256:b47c1f9ccf73e67021df785332508f99379c68fa7d0684e8e3492cb1d4b23f89", size = 253586, upload-time = "2026-06-15T22:00:49.021Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/16/9826f089383c593cdfc4a6e5aca94d9e91ae1692c57af82c3b2aa5e810f7/anyio-4.14.0-py3-none-any.whl", hash = "sha256:dd9b7a2a9799ed6552fde617b2c5df02b7fdd7d88392fc48101e51bae46164d9", size = 123506, upload-time = "2026-06-15T22:00:47.595Z" }, +] + +[[package]] +name = "beautifulsoup4" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[package]] +name = "click" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "fastapi" +version = "0.137.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/b1/e5b92c59d2c37817e77c1a8c2fc1f79cdcc04c68253e5406b43e3204cba7/fastapi-0.137.1.tar.gz", hash = "sha256:822360704230d9533d8d9475399613525968aa2f0b5bd2a3ccc9f18c88fd541c", size = 408293, upload-time = "2026-06-15T11:28:20.79Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/380b9a5922f4340e51c309cde09e5bd32e62f02302971bee30dc15aa0624/fastapi-0.137.1-py3-none-any.whl", hash = "sha256:64f6983c59e45c4b9fdc44e57cb8035c2451ee91ea8e8ec042aca37de7cf6b69", size = 121877, upload-time = "2026-06-15T11:28:19.523Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpcore2" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/fe/6a3f9f1a8bb8733326140737446aaf72fddb8b54b8f202302f5c84960613/httpcore2-2.7.0.tar.gz", hash = "sha256:6dc0fedf329a52a990930a5579edfebaea81118ea700ea0dd7de2b5e5be49efc", size = 65593, upload-time = "2026-07-14T20:40:01.111Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/6c/62e2e279e63fc4f7a5ee841ef13175a8bbc613f258e9dcc186e9de803a42/httpcore2-2.7.0-py3-none-any.whl", hash = "sha256:1452f589fe23f55b44546cd884294c41a29330af902bc0b71a761fd52d18f92b", size = 81506, upload-time = "2026-07-14T20:39:58.053Z" }, +] + +[[package]] +name = "httptools" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/b9/be66eb0decd730d89b9c94f930e4b8d87787b05724bb84af98bfd825f72c/httptools-0.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:bf3b6f807c8541503cecfbb8a8dffb385640d0d96102f3d112aa8740f9b7c826", size = 208805, upload-time = "2026-05-25T22:16:50.434Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f7/b4d41eaae2869d31356bc4bbf546f44fae83ff298af0a043ca0625b06773/httptools-0.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da684f2e1aa2ee9bdcb083f3f3a68c5956750b375bc5df864d3a5f0c42a40b77", size = 113527, upload-time = "2026-05-25T22:16:51.672Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e4/77487e14fc7be47180fd0eb4267c7486d0cc59b74031839a3daf8650136b/httptools-0.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6f21e2a3b0067bbe7f67e34cfd16276af556e5e52f4c7503be0cb5f90e905e4", size = 450035, upload-time = "2026-05-25T22:16:53.313Z" }, + { url = "https://files.pythonhosted.org/packages/da/72/5a8f787e323f56fbd86c32a4be92a86776e4cfe8b4317db999f452028362/httptools-0.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea897f0c729581ebf72131a438a7932d9b14efef72d75ada966700cac3caaeb", size = 451101, upload-time = "2026-05-25T22:16:54.696Z" }, + { url = "https://files.pythonhosted.org/packages/ed/41/b44a25560955197674b6744cb903664300e239235a5eaa69df0890d87054/httptools-0.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c0d726cc107fceb7d45f978483b4b70dd8caa836f5914d3434bb18628eb73813", size = 436140, upload-time = "2026-05-25T22:16:56.239Z" }, + { url = "https://files.pythonhosted.org/packages/74/b0/054aac84c03d7e097bf4c605fb7e74eec3d65c0276adf64ee97f3a103ff5/httptools-0.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9878eb2785ba5eb70631ad269b37976f73d647955e26c91d490eb8a4edfda4ba", size = 437041, upload-time = "2026-05-25T22:16:57.716Z" }, + { url = "https://files.pythonhosted.org/packages/bb/e8/86b85bbc0ac7892232f1a99ab96a9aa71936984fa06adfc0afc83ca7789e/httptools-0.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:b205e5f5523fa039679da0dfe5a10132b2a4abeae6a86fdd1ddc035f7f836557", size = 90454, upload-time = "2026-05-25T22:16:58.871Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d2/c3eedaef57de65c3cc5f8dc244cf12d09c84ad258a479055aad6db23206c/httptools-0.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ed377e64805bdba4943c82717333f8f8603a13b09aff9cead2717c6c817fb168", size = 208428, upload-time = "2026-05-25T22:16:59.717Z" }, + { url = "https://files.pythonhosted.org/packages/f1/94/dfe435d90d0ef61ec0f2cc3d480eef78c59727c6c2ce039f433882f6131a/httptools-0.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9518c406d7b310f05adb1a37f80acabac40504a575d7c0da6d3e365c695ac20d", size = 113366, upload-time = "2026-05-25T22:17:00.795Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d4/13025f1a56e615dcb331e0bbe2d9a1143212b58c263385fc5d2e558f5bac/httptools-0.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:57278e6fa0424c42a8a3e454828ab4f0aff27b40cddf9679579b98c6dce6a376", size = 464676, upload-time = "2026-05-25T22:17:02.014Z" }, + { url = "https://files.pythonhosted.org/packages/bf/95/4c1c26c0b985f8a3331682d802598f14e32dc41bf7509266eb2c04ad4801/httptools-0.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbb8caadb2b742d293169d2b458b5c001ef70e3158704aa3d3ef9597624c5d1d", size = 464235, upload-time = "2026-05-25T22:17:03.109Z" }, + { url = "https://files.pythonhosted.org/packages/a2/82/6735be2b0ca527718c431cdb8e5f70c3862c0844a687df0f572c51e11497/httptools-0.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:52dd695b865fe96d9d2b16b64a895f3f57bf3cb064e8383cd3b5713a069e8085", size = 449809, upload-time = "2026-05-25T22:17:04.443Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f9/5811c74f37a758c8a4aa3dc430375119d335947e883efc4664d8f3559a41/httptools-0.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:20b4aac66ff65f7db06a375808b78f42a94970aa22e826b3cb2b43eb09174124", size = 452174, upload-time = "2026-05-25T22:17:05.476Z" }, + { url = "https://files.pythonhosted.org/packages/cc/94/97b75870dea07b71e3ec535cebe525b08d723152e4c7d13fa887e51f4de2/httptools-0.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1b4c8e7a489a0d750d91894e9a8cdc295838f1924c0ca903ae993456fddec07", size = 90991, upload-time = "2026-05-25T22:17:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" }, + { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, + { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, + { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, + { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, + { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, + { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx2" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpcore2" }, + { name = "idna" }, + { name = "truststore" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/4a/129b2e21b90ac2985d3928d96792bccc39bc6dfe796c5eee2d8ec06d4105/httpx2-2.7.0.tar.gz", hash = "sha256:8b30709aed5c8465b0dd3b95c09ce301c8f79e7e7a2d00ab0af551e0d0375b07", size = 94487, upload-time = "2026-07-14T20:40:02.318Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/b8/c341bba6411bdfda786020343c47a75ef472f6085caf82391b142b1a3ad9/httpx2-2.7.0-py3-none-any.whl", hash = "sha256:ed2a2719c696789e09493bd8e2bec3d8bd925cc6e26b68389ec25ade132f7bf4", size = 90234, upload-time = "2026-07-14T20:39:59.531Z" }, +] + +[[package]] +name = "icalendar" +version = "7.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/86/16051e4f00db105d76bfd79f87cc5685c55d562d335f97c71bf128914c56/icalendar-7.1.3.tar.gz", hash = "sha256:eb03a0e215f30db689a72c49f18f998aabf17522eb0858a293c393510850727c", size = 472734, upload-time = "2026-06-15T14:06:13.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/57/aa44e7af1244856d92a700dca5089777a334fecd328f82d5faa5c2696e2e/icalendar-7.1.3-py3-none-any.whl", hash = "sha256:690f30aa50a76cbf854db5ad52654705db9c5cd0e1b152222f5d4b7854b60667", size = 475481, upload-time = "2026-06-15T14:06:11.605Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "recurring-ical-events" +version = "3.8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "icalendar" }, + { name = "python-dateutil" }, + { name = "tzdata" }, + { name = "x-wr-timezone" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/f5/898abd8fbb25766ec6cb17b244730ebf192f47963c71eeacd61aadd903a1/recurring_ical_events-3.8.2.tar.gz", hash = "sha256:e731af31d0b7dec5cd47a1defacd8549e2f36fab1c1995e8b9f042822a0acf8e", size = 604992, upload-time = "2026-04-30T19:21:46.2Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/f7/d3eb4f545baf5d6daaa5881694517626fb7ad04036d94168197a4f021384/recurring_ical_events-3.8.2-py3-none-any.whl", hash = "sha256:9ad605e27b4fbeb70ee1c66205ade32550be15a57cedc579606522f1c67aef6d", size = 241358, upload-time = "2026-04-30T19:21:44.079Z" }, +] + +[[package]] +name = "research-workbench" +version = "1.1.0" +source = { virtual = "." } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "fastapi" }, + { name = "icalendar" }, + { name = "pyyaml" }, + { name = "recurring-ical-events" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.dev-dependencies] +dev = [ + { name = "httpx" }, + { name = "httpx2" }, + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "beautifulsoup4", specifier = ">=4.12" }, + { name = "fastapi", specifier = ">=0.115" }, + { name = "icalendar", specifier = ">=5.0" }, + { name = "pyyaml", specifier = ">=6.0" }, + { name = "recurring-ical-events", specifier = ">=2.3" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.32" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "httpx", specifier = ">=0.27" }, + { name = "httpx2", specifier = ">=2.0" }, + { name = "pytest", specifier = ">=8.3" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.8.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" }, +] + +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.49.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/14/ecceb239b65adaaf7fde510aa8bd534075695d1e5f8dadfa32b5723d9cfb/uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c", size = 1343335, upload-time = "2025-10-16T22:16:11.43Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ae/6f6f9af7f590b319c94532b9567409ba11f4fa71af1148cab1bf48a07048/uvloop-0.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792", size = 742903, upload-time = "2025-10-16T22:16:12.979Z" }, + { url = "https://files.pythonhosted.org/packages/09/bd/3667151ad0702282a1f4d5d29288fce8a13c8b6858bf0978c219cd52b231/uvloop-0.22.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac33ed96229b7790eb729702751c0e93ac5bc3bcf52ae9eccbff30da09194b86", size = 3648499, upload-time = "2025-10-16T22:16:14.451Z" }, + { url = "https://files.pythonhosted.org/packages/b3/f6/21657bb3beb5f8c57ce8be3b83f653dd7933c2fd00545ed1b092d464799a/uvloop-0.22.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:481c990a7abe2c6f4fc3d98781cc9426ebd7f03a9aaa7eb03d3bfc68ac2a46bd", size = 3700133, upload-time = "2025-10-16T22:16:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/09/e0/604f61d004ded805f24974c87ddd8374ef675644f476f01f1df90e4cdf72/uvloop-0.22.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a592b043a47ad17911add5fbd087c76716d7c9ccc1d64ec9249ceafd735f03c2", size = 3512681, upload-time = "2025-10-16T22:16:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ce/8491fd370b0230deb5eac69c7aae35b3be527e25a911c0acdffb922dc1cd/uvloop-0.22.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1489cf791aa7b6e8c8be1c5a080bae3a672791fcb4e9e12249b05862a2ca9cec", size = 3615261, upload-time = "2025-10-16T22:16:19.596Z" }, + { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, + { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, + { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/5a/2bf22ecb24916983bf1cc0095e7dea2741d14d6553b0d6a2ac8bc96eca93/watchfiles-1.2.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:bb68bf4df85abebe5efddc53cf2075520f243a59868d9b3973278b23e76962a9", size = 400471, upload-time = "2026-05-18T04:31:08.908Z" }, + { url = "https://files.pythonhosted.org/packages/55/70/dea1f6a0e76607841a60fb51af150e70124864673f61704abb62b90cdcc7/watchfiles-1.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c16cb06dd17d43b9d185094268459eac92c9538356f050e55b54e82cf700e1d4", size = 394599, upload-time = "2026-05-18T04:30:19.845Z" }, + { url = "https://files.pythonhosted.org/packages/18/52/752dcc7dc817baef5e89518732925795ce52e36a683a9a3c9fb68b21504e/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a0feab9af4c021c581f695258c642b3d10c5fd4c676e33a0d8606425d82631", size = 455458, upload-time = "2026-05-18T04:30:29.126Z" }, + { url = "https://files.pythonhosted.org/packages/12/48/366ebbb22fcc504c2f72b45f0b7e72f40a18795cc01752c16066d597b67a/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a16ffe19bf5cf9f5edaa1ad1dd830c5a816e8feec430c522302ab55483a4b994", size = 460513, upload-time = "2026-05-18T04:31:40.85Z" }, + { url = "https://files.pythonhosted.org/packages/ad/44/1f9e1b15e7a729062e0d0c3d0d7225ea4ab98b2267ef87287153be2495fc/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:204f299afcbd65918ab78dbc52626b0ae45e9d8cef403fdbf33ecf9e40eac66e", size = 493616, upload-time = "2026-05-18T04:30:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/7e/55/8b1086dcc8a1d6a697a62767bd7ea368e74c61c6fd171683cfe24a3fe5d2/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:11743adfa510bfffebe97659fb280182b5c9b238708f667e866f308c3430dc19", size = 573154, upload-time = "2026-05-18T04:30:37.903Z" }, + { url = "https://files.pythonhosted.org/packages/14/7a/242f400cc77fafa7b18d53d19d9cb64fc6a6f61f28c55913bae7c674d92a/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eb72919d93e3a16fc451d3aa3d4b1698423daca1b382d3d959c9ac51297c12a8", size = 467046, upload-time = "2026-05-18T04:30:41.869Z" }, + { url = "https://files.pythonhosted.org/packages/02/c8/79eee650c62d2c186598489814468e389b5def0ebe755399ff645b35b1b2/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62f042afde2dde21ec1d2c1a74361e804673df86f51e418a999c9acfe671b07", size = 457100, upload-time = "2026-05-18T04:31:13.064Z" }, + { url = "https://files.pythonhosted.org/packages/81/36/519f6dbb7a95e4fe7c1513ed25b1520295ef9905a27f1f2226a73892bfb7/watchfiles-1.2.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:027ae72bfdfd254862065d8b3e2a815c6ab9b1853ce41e6648ece84afd34a551", size = 467038, upload-time = "2026-05-18T04:30:32.915Z" }, + { url = "https://files.pythonhosted.org/packages/2f/12/951af6b9f89097e02511122258402cb3578443021930b70cf968d6310dc0/watchfiles-1.2.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e1cfd51e97e13ff3bd047c140764d277fc9b95b7cb5da59e46a47d167adab310", size = 632563, upload-time = "2026-05-18T04:30:11.539Z" }, + { url = "https://files.pythonhosted.org/packages/28/cc/0cba1f0a6117b7ec117271bdc3cb3a5a252005959755a2c09a745e0942cc/watchfiles-1.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:24b2405c0a46738dd9e1cf7135aa5dbdb9d42d024628651b3b13d5117e99f8df", size = 660851, upload-time = "2026-05-18T04:31:53.186Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f2/26347558cc8bf6877845e66b315f644d03c173906aa09e233a3f4fd23928/watchfiles-1.2.0-cp310-cp310-win32.whl", hash = "sha256:8c520725602756229f045b032a1ff33d7ef0f7404189d62f6c2438cb6d8ef6a1", size = 277023, upload-time = "2026-05-18T04:30:18.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/68/a5e67b6b68e94f4c1511d61c46c55eba0737583620b6febf194c7b9cc23f/watchfiles-1.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:03b14855c6f35539e2d95c442ae9530a75762f1e26567152b9ed05f96534a74d", size = 290107, upload-time = "2026-05-18T04:32:09.677Z" }, + { url = "https://files.pythonhosted.org/packages/fc/3d/8024c801df84d1587740d0359e7fdd80afeae3d159011f3d5376dd82f18e/watchfiles-1.2.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:704fd259e332e01f9b9c178f4bce9e49027e5587cc2600eeeaf8e76e1c846201", size = 400242, upload-time = "2026-05-18T04:31:19.014Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/f4dfd45323e949984a3a7f9dc31d1cbb049921e7d98253488dda72ccdaa9/watchfiles-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6543cf55d170003296d185c0af981f3e1311564907e1f4e08671fc7693a890a5", size = 394562, upload-time = "2026-05-18T04:30:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/98/d8/19483ef075d601c409bce8bcbb5c0f81a10876fff870400568f08ce484a1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a", size = 456611, upload-time = "2026-05-18T04:30:45.723Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6a/cc81fbe7ee42f2f22e661a6e12def7807e01b14b2f39e0ff83fd373fd307/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:772b80df316480d894a0e3165fdd19cf77f5d17f9a787f94029465ad0e3529d1", size = 461379, upload-time = "2026-05-18T04:31:29.292Z" }, + { url = "https://files.pythonhosted.org/packages/b1/57/7e669002082c0a0f4fb5113bb70125f7110124b846b0a11bc5ae8e90eac1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d158cd89df6053823533e06fb1d73c549133bff5f0396170c0e53d9559340717", size = 493556, upload-time = "2026-05-18T04:30:05.44Z" }, + { url = "https://files.pythonhosted.org/packages/45/7d/f60a2b19807b21fe8281f3a8da4f59eef0d5f96825ac4680ba2d4f2ebf91/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d516b3283a758e087841aedb8031549fb41ced08f3db10aa6d2bf32dc042525b", size = 575255, upload-time = "2026-05-18T04:30:40.568Z" }, + { url = "https://files.pythonhosted.org/packages/bd/49/77f5b5e6efbcd57482f74948ebb1b97e5c0046d6b61475042d830c84b3ff/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53b2290c92e0506d102cd448fbc610d87079553f86caa39d67440856a8b8bba5", size = 467052, upload-time = "2026-05-18T04:31:17.942Z" }, + { url = "https://files.pythonhosted.org/packages/ee/5a/73e2959af1b97fd5d556f9a8bdba017be23ceeef731869d5eaa0a753d5a3/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a711b51aec4370d0dcda5b6c09463206f133a5759341d7744b953a7b62e1100e", size = 456858, upload-time = "2026-05-18T04:30:30.182Z" }, + { url = "https://files.pythonhosted.org/packages/50/57/1bc8c27fad7e6c19bddee15d276dbb6ab72480ec01c127afff1673aee417/watchfiles-1.2.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:e2ca07fa7d89195ec0865d3d285666286740bfa83d83e5cee204043a31ecc165", size = 467579, upload-time = "2026-05-18T04:32:15.897Z" }, + { url = "https://files.pythonhosted.org/packages/09/6c/3c2e44edba3553c5e3c3b8c8a2a6dee6b9e12ae2cf4bd2378bebf9dc3038/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e0618518f282c4ebff60f5e5b1247b6d91bb8b9f4476947563a1e74acc66f3c6", size = 633253, upload-time = "2026-05-18T04:31:37.123Z" }, + { url = "https://files.pythonhosted.org/packages/30/c2/d8c84a882ab39bbefcc4915ab3e91830b7a7e990c5570b0b69075aba3faf/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d191c054d0715c3c95c99df9b8dbf6fd096d8c1e021e8f212e1bd8bc444ccb5", size = 660713, upload-time = "2026-05-18T04:31:24.62Z" }, + { url = "https://files.pythonhosted.org/packages/a9/07/f97736a5fc605364fe67b25e9fa4a6965dfd4840d50c406ada507e9d735f/watchfiles-1.2.0-cp311-cp311-win32.whl", hash = "sha256:9342472aff9b093c5acd4f6d8f70ae0937964ab56542502bcf5579782da69ae8", size = 277222, upload-time = "2026-05-18T04:31:21.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/99/2b04981977fc2608afd60360d928c6aecf6b950292ca221d98f4005f6694/watchfiles-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:dbd6c97045dad81227c8d040173da044c1de08de64a5ea8b555da4aee1d5fa22", size = 290274, upload-time = "2026-05-18T04:31:45.966Z" }, + { url = "https://files.pythonhosted.org/packages/3c/74/f7f58a7075ee9cf612b0cfcddb78b8cd8234f0742d6f0075cf0da2dde1c6/watchfiles-1.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:57a2d9fa4fb4c2ecae57b13dfff2c7ab53e21a2ba674fe9f05506680fcdcc0d7", size = 283460, upload-time = "2026-05-18T04:31:39.126Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/23/f4/7513ef1e85fc4c6331b59479d6d72661fc391fbe543678052ac72c8b6c19/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4674d49eb94706dfe666c069fc0a1b646ffcf920473492e209f6d5f60d3f0cc2", size = 403050, upload-time = "2026-05-18T04:30:36.753Z" }, + { url = "https://files.pythonhosted.org/packages/27/0b/a54103cfd732bb703c7a749222011a0483ef3705948dae3b203158601119/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:094b9b70103d4e963499bdea001ee3c2697b144cd9ae6218a62c0f89ec9e31db", size = 396629, upload-time = "2026-05-18T04:32:03.268Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2c/73f31a3b893886206c3f54d73e8ad8dee58cdb2f69ad2622e0a8a9e07f4e/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7", size = 457318, upload-time = "2026-05-18T04:31:01.932Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f9/45d021e4a5cc7b9dd567f7cbb06d3b75f751a690063fb6cc7ec60f4e46b7/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0", size = 457771, upload-time = "2026-05-18T04:30:56.331Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/74/221f58decd852f4b59cc3354cccaf87e8ef695fede361d03dc9a7396573b/websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a", size = 177343, upload-time = "2026-01-10T09:22:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/22ef6107ee52ab7f0b710d55d36f5a5d3ef19e8a205541a6d7ffa7994e5a/websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0", size = 175021, upload-time = "2026-01-10T09:22:22.696Z" }, + { url = "https://files.pythonhosted.org/packages/10/40/904a4cb30d9b61c0e278899bf36342e9b0208eb3c470324a9ecbaac2a30f/websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957", size = 175320, upload-time = "2026-01-10T09:22:23.94Z" }, + { url = "https://files.pythonhosted.org/packages/9d/2f/4b3ca7e106bc608744b1cdae041e005e446124bebb037b18799c2d356864/websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72", size = 183815, upload-time = "2026-01-10T09:22:25.469Z" }, + { url = "https://files.pythonhosted.org/packages/86/26/d40eaa2a46d4302becec8d15b0fc5e45bdde05191e7628405a19cf491ccd/websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde", size = 185054, upload-time = "2026-01-10T09:22:27.101Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ba/6500a0efc94f7373ee8fefa8c271acdfd4dca8bd49a90d4be7ccabfc397e/websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3", size = 184565, upload-time = "2026-01-10T09:22:28.293Z" }, + { url = "https://files.pythonhosted.org/packages/04/b4/96bf2cee7c8d8102389374a2616200574f5f01128d1082f44102140344cc/websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3", size = 183848, upload-time = "2026-01-10T09:22:30.394Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/81f40fb00fd125357814e8c3025738fc4ffc3da4b6b4a4472a82ba304b41/websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9", size = 178249, upload-time = "2026-01-10T09:22:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5f/7e40efe8df57db9b91c88a43690ac66f7b7aa73a11aa6a66b927e44f26fa/websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35", size = 178685, upload-time = "2026-01-10T09:22:33.345Z" }, + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + +[[package]] +name = "x-wr-timezone" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "icalendar" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/2b/8ae5f59ab852c8fe32dd37c1aa058eb98aca118fec2d3af5c3cd56fffb7b/x_wr_timezone-2.0.1.tar.gz", hash = "sha256:9166c40e6ffd4c0edebabc354e1a1e2cffc1bb473f88007694793757685cc8c3", size = 18212, upload-time = "2025-02-06T17:10:40.913Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/b7/4bac35b4079b76c07d8faddf89467e9891b1610cfe8d03b0ebb5610e4423/x_wr_timezone-2.0.1-py3-none-any.whl", hash = "sha256:e74a53b9f4f7def8138455c240e65e47c224778bce3c024fcd6da2cbe91ca038", size = 11102, upload-time = "2025-02-06T17:10:39.192Z" }, +] diff --git a/web/e2e/hosted-app.spec.ts b/web/e2e/hosted-app.spec.ts new file mode 100644 index 0000000..9468279 --- /dev/null +++ b/web/e2e/hosted-app.spec.ts @@ -0,0 +1,142 @@ +import { expect, test } from '@playwright/test'; + +const syntheticTaskPath = 'tasks/active/t-draft-method-note.md'; + +test('exercises every configured module with the synthetic vault', async ({ page }) => { + const browserErrors: string[] = []; + page.on('console', (message) => { + if (message.type() === 'error') browserErrors.push(message.text()); + }); + page.on('pageerror', (error) => browserErrors.push(error.message)); + + await page.setViewportSize({ width: 1280, height: 820 }); + await page.goto('/'); + await expect(page).toHaveTitle('Research Workbench'); + await expect(page.getByRole('heading', { name: 'Today' })).toBeVisible(); + + const configResponse = await page.request.get('/api/config'); + expect(configResponse.ok()).toBeTruthy(); + const config = await configResponse.json() as { + application_name: string; + owner_aliases: string[]; + modules: Record; + }; + expect(config.application_name).toBe('Research Workbench'); + expect(config.owner_aliases).toEqual(['Owner', 'me', 'self']); + expect(config.modules.github_sync.enabled).toBe(false); + + const sidebar = page.locator('.sidebar'); + const modules = [ + { button: 'Tasks', heading: 'Open Tasks', expected: 'Draft the example methods note' }, + { button: 'Projects', heading: 'Projects', expected: 'Harbor Flows' }, + { button: 'Calendar', heading: 'Calendar', expected: 'Agenda' }, + { button: 'Time Plan', heading: 'Time Plan', expected: 'Daily Plan' }, + { button: 'Admin Center', heading: 'Admin Center', expected: 'Submit the fictional annual form' }, + { button: 'Performance Review', heading: 'Performance Evaluation', expected: 'Harbor Flows methods note' }, + { button: 'Travel Center', heading: 'Travel Center', expected: 'Methods Summit' }, + { button: 'Collaborators', heading: 'Collaborators', expected: 'Avery Example' }, + { button: 'Wellness', heading: 'Wellness', expected: 'Redacted wellness summary is on.' }, + { button: 'Library', heading: 'Library', expected: 'Avery Example assignment' }, + ]; + + for (const module of modules) { + const button = sidebar.getByRole('button', { name: module.button, exact: true }); + await button.scrollIntoViewIfNeeded(); + await button.click(); + await expect(page.getByRole('heading', { name: module.heading, exact: true })).toBeVisible(); + await expect(page.getByText(module.expected, { exact: false }).first()).toBeVisible(); + } + + const staticResponse = await page.request.get('/dashboard/projects/harbor-flows.html'); + expect(staticResponse.ok()).toBeTruthy(); + expect(await staticResponse.text()).toContain('Harbor Flows'); + const collaboratorResponse = await page.request.get('/dashboard/collaborators/avery-example.html'); + expect(collaboratorResponse.ok()).toBeTruthy(); + expect(await collaboratorResponse.text()).toContain('Avery Example'); + + const manifestResponse = await page.request.get('/manifest.webmanifest'); + expect(manifestResponse.ok()).toBeTruthy(); + const manifest = await manifestResponse.json() as { name: string; display: string }; + expect(manifest).toMatchObject({ name: 'Research Workbench', display: 'standalone' }); + expect(browserErrors).toEqual([]); +}); + +test('edits a Markdown task and reloads the persisted content', async ({ page }) => { + await page.setViewportSize({ width: 1180, height: 800 }); + await page.goto('/'); + await page.locator('.sidebar').getByRole('button', { name: 'Tasks', exact: true }).click(); + await page.getByRole('button', { name: 'List', exact: true }).click(); + await page.locator('.task-open-button').filter({ hasText: 'Draft the example methods note' }).click(); + await expect(page.locator('header').getByRole('heading', { name: 'Draft the example methods note' })).toBeVisible(); + + const originalResponse = await page.request.get(`/api/vault/file?path=${syntheticTaskPath}`); + expect(originalResponse.ok()).toBeTruthy(); + const original = await originalResponse.json() as { content: string }; + + await page.getByRole('button', { name: 'Split', exact: true }).click(); + const editor = page.locator('.cm-content'); + await editor.fill(`${original.content}\n\nPlaywright persisted this synthetic edit.\n`); + await page.getByRole('button', { name: /^Save$/ }).click(); + await expect(page.getByRole('button', { name: 'Saved' })).toBeVisible(); + + await page.reload(); + const savedResponse = await page.request.get(`/api/vault/file?path=${syntheticTaskPath}`); + const saved = await savedResponse.json() as { content: string }; + expect(saved.content).toContain('Playwright persisted this synthetic edit.'); +}); + +test('keeps private wellness content redacted until the display filter is changed', async ({ page }) => { + await page.goto('/'); + await page.locator('.sidebar').getByRole('button', { name: 'Wellness', exact: true }).click(); + await expect(page.getByText('Redacted wellness summary is on.')).toBeVisible(); + await expect(page.getByText('Complete the weekly wellness review')).toHaveCount(0); + + await page.getByRole('button', { name: /Private hidden in this view/ }).click(); + await expect(page.getByText('Private wellness details are visible.')).toBeVisible(); + await expect(page.getByText('Complete the weekly wellness review').first()).toBeVisible(); +}); + +test('has labeled controls and no horizontal overflow on mobile', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto('/'); + await expect(page.getByRole('heading', { name: 'Today' })).toBeVisible(); + for (const label of ['Overview', 'Tasks', 'Time', 'New', 'More']) { + await expect(page.locator('.mobile-bottom-bar').getByRole('button', { name: label })).toBeVisible(); + } + + const unlabeledControls = await page.evaluate(() => ( + [...document.querySelectorAll('button, a, input, select, textarea')] + .filter((element) => { + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden'; + }) + .filter((element) => { + const text = (element.textContent || '').trim(); + const aria = element.getAttribute('aria-label') || element.getAttribute('aria-labelledby') || ''; + const title = element.getAttribute('title') || ''; + const placeholder = element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement ? element.placeholder : ''; + return !(text || aria || title || placeholder); + }) + .map((element) => element.outerHTML) + .slice(0, 10) + )); + expect(unlabeledControls).toEqual([]); + expect(await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 2)).toBe(false); +}); + +test('renders useful empty states against an empty vault', async ({ page }) => { + await page.goto('http://127.0.0.1:18766/'); + await expect(page).toHaveTitle('Research Workbench'); + await expect(page.getByRole('heading', { name: 'Today' })).toBeVisible(); + await expect(page.getByText('No focus tasks.')).toBeVisible(); + + await page.locator('.sidebar').getByRole('button', { name: 'Tasks', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'Open Tasks' })).toBeVisible(); + await expect(page.getByText('No open tasks match these filters.').first()).toBeVisible(); + + await page.locator('.sidebar').getByRole('button', { name: 'Projects', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'Projects' })).toBeVisible(); + await page.locator('.sidebar').getByRole('button', { name: 'Calendar', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'Calendar' })).toBeVisible(); +}); diff --git a/web/e2e/workbench.spec.ts b/web/e2e/workbench.spec.ts deleted file mode 100644 index a1b86e9..0000000 --- a/web/e2e/workbench.spec.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { expect, test } from '@playwright/test'; - -test('opens the fictional vault and follows a wiki link', async ({ page }) => { - await page.goto('/'); - await expect(page.getByRole('heading', { name: 'Your work, in plain files.' })).toBeVisible(); - await expect(page.getByText('2', { exact: true }).first()).toBeVisible(); - - await page.getByRole('button', { name: /Example research project/ }).first().click(); - await expect(page.getByRole('heading', { name: 'Example research project' })).toBeVisible(); - await page.getByRole('link', { name: 't-draft-method-note' }).click(); - await expect(page.getByRole('heading', { name: 'Draft the example methods note' })).toBeVisible(); -}); - -test('edits and saves a Markdown note', async ({ page }) => { - await page.goto('/'); - if (await page.getByRole('button', { name: 'Open navigation' }).isVisible()) { - await page.getByRole('button', { name: 'Open navigation' }).click(); - } - await page.getByRole('button', { name: 'Library' }).click(); - await page.getByRole('button', { name: /Example design note/ }).click(); - await page.getByRole('button', { name: 'Edit' }).click(); - const editor = page.getByLabel('Markdown source'); - await editor.fill(`${await editor.inputValue()}\n\nSaved by the fictional E2E test.\n`); - await page.getByRole('button', { name: 'Save' }).click(); - await expect(page.getByRole('button', { name: 'Saved' })).toBeDisabled(); -}); - -test('mobile layout does not create page-level horizontal overflow', async ({ page }, testInfo) => { - test.skip(testInfo.project.name !== 'mobile'); - await page.goto('/'); - const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); - expect(overflow).toBeLessThanOrEqual(1); - await page.getByRole('button', { name: 'Open navigation' }).click(); - await expect(page.getByRole('navigation', { name: 'Main navigation' })).toBeVisible(); -}); diff --git a/web/index.html b/web/index.html index 740c9d8..24111c4 100644 --- a/web/index.html +++ b/web/index.html @@ -2,11 +2,19 @@ - - - - - Markdown Task Manager + + + + + + + + + + + + + Research Workbench
diff --git a/web/package-lock.json b/web/package-lock.json index 8a875b7..e1c9fab 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,59 +1,108 @@ { - "name": "markdown-task-manager-web", - "version": "1.0.0", + "name": "research-workbench-web", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "markdown-task-manager-web", - "version": "1.0.0", + "name": "research-workbench-web", + "version": "1.1.0", "dependencies": { + "@codemirror/lang-markdown": "^6.5.0", + "@uiw/react-codemirror": "^4.25.1", + "katex": "^0.16.45", "lucide-react": "^0.468.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^9.0.1", - "remark-gfm": "^4.0.0" + "rehype-katex": "^7.0.1", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", + "remark-gfm": "^4.0.0", + "remark-math": "^6.0.0" }, "devDependencies": { "@playwright/test": "^1.62.1", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.1.0", + "@types/node": "^22.20.1", "@types/react": "^18.3.17", "@types/react-dom": "^18.3.5", - "@vitejs/plugin-react": "^6.0.5", - "jsdom": "^25.0.1", - "typescript": "^5.7.2", - "vite": "^8.2.0", + "@vitejs/plugin-react": "^5.2.0", + "jsdom": "^27.4.0", + "typescript": "^5.9.3", + "vite": "^7.3.6", "vitest": "^4.1.10" } }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, "node_modules/@adobe/css-tools": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", - "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", "dev": true, "license": "MIT" }, "node_modules/@asamuzakjp/css-color": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", - "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", + "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", "dev": true, "license": "MIT", "dependencies": { - "@csstools/css-calc": "^2.1.3", - "@csstools/css-color-parser": "^3.0.9", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3", - "lru-cache": "^10.4.3" + "@csstools/css-calc": "^3.0.0", + "@csstools/css-color-parser": "^4.0.1", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.5" } }, "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", "dev": true, - "license": "ISC" + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" }, "node_modules/@babel/code-frame": { "version": "7.29.7", @@ -61,7 +110,6 @@ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", @@ -71,31 +119,439 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@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.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@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.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "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.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "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.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-validator-identifier": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "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/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", - "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, "engines": { "node": ">=6.9.0" } }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.1", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz", + "integrity": "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.10.3", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz", + "integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-css": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", + "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.2", + "@lezer/css": "^1.1.7" + } + }, + "node_modules/@codemirror/lang-html": { + "version": "6.4.11", + "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz", + "integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-css": "^6.0.0", + "@codemirror/lang-javascript": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/css": "^1.1.0", + "@lezer/html": "^1.3.12" + } + }, + "node_modules/@codemirror/lang-javascript": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz", + "integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-markdown": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.0.tgz", + "integrity": "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.7.1", + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.3.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.2.1", + "@lezer/markdown": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.3", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz", + "integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.5", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.5.tgz", + "integrity": "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.35.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.0.tgz", + "integrity": "sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz", + "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/theme-one-dark": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz", + "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.41.1", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.41.1.tgz", + "integrity": "sha512-ToDnWKbBnke+ZLrP6vgTTDScGi5H37YYuZGniQaBzxMVdtCxMrslsmtnOvbPZk4RX9bvkQqnWR/WS/35tJA0qg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.6.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@csstools/color-helpers": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", - "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", "dev": true, "funding": [ { @@ -109,13 +565,13 @@ ], "license": "MIT-0", "engines": { - "node": ">=18" + "node": ">=20.19.0" } }, "node_modules/@csstools/css-calc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", - "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", "dev": true, "funding": [ { @@ -129,17 +585,45 @@ ], "license": "MIT", "engines": { - "node": ">=18" + "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" } }, "node_modules/@csstools/css-color-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", - "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", + "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", "dev": true, "funding": [ { @@ -151,94 +635,549 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT", - "dependencies": { - "@csstools/color-helpers": "^5.1.0", - "@csstools/css-calc": "^2.1.4" - }, + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", + "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" } }, - "node_modules/@csstools/css-parser-algorithms": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", - "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], "license": "MIT", "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-tokenizer": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", - "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true } - ], - "license": "MIT", - "engines": { - "node": ">=18" } }, - "node_modules/@emnapi/core": { - "version": "2.0.0-alpha.3", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz", - "integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==", + "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", - "optional": true, "dependencies": { - "@emnapi/wasi-threads": "2.0.1", - "tslib": "^2.4.0" + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/@emnapi/runtime": { - "version": "2.0.0-alpha.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz", - "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==", + "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", - "optional": true, "dependencies": { - "tslib": "^2.4.0" + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/@emnapi/wasi-threads": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz", - "integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==", + "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", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" + "engines": { + "node": ">=6.0.0" } }, "node_modules/@jridgewell/sourcemap-codec": { @@ -248,38 +1187,90 @@ "dev": true, "license": "MIT" }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.1.tgz", - "integrity": "sha512-KjZdi8Q1wh89gsVmghvbrMgWl6ZWmRmHV6wjB7/g4Zf0dyO+hH3neZUtuDNPO00qq5YE5RITVWvrIZKRaAmzGQ==", + "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", - "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.3" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=23.5.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", - "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@oxc-project/types": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz", - "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==", - "dev": true, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "license": "MIT" + }, + "node_modules/@lezer/css": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.3.tgz", + "integrity": "sha512-RzBo8r+/6QJeow7aPHIpGVIH59xTcJXp399820gZoMo9noQDRVpJLheIBUicYwKcsbOYoBRoLZlf2720dG/4Tg==", "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/html": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz", + "integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/javascript": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", + "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/markdown": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.6.3.tgz", + "integrity": "sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0" } }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "license": "MIT" + }, "node_modules/@playwright/test": { "version": "1.62.1", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", @@ -296,27 +1287,45 @@ "node": ">=20" } }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.1.tgz", - "integrity": "sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==", + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "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": [ - "arm64" + "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" ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.1.tgz", - "integrity": "sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA==", + "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" ], @@ -325,15 +1334,12 @@ "optional": true, "os": [ "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.1.tgz", - "integrity": "sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ==", + "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" ], @@ -342,15 +1348,26 @@ "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" ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.1.tgz", - "integrity": "sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg==", + "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" ], @@ -359,32 +1376,46 @@ "optional": true, "os": [ "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.1.tgz", - "integrity": "sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA==", + "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, + "libc": [ + "glibc" + ], "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" ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.1.tgz", - "integrity": "sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg==", + "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" ], @@ -396,15 +1427,12 @@ "optional": true, "os": [ "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.1.tgz", - "integrity": "sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A==", + "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" ], @@ -416,15 +1444,46 @@ "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" ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.1.tgz", - "integrity": "sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg==", + "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, + "libc": [ + "musl" + ], + "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" ], @@ -436,17 +1495,31 @@ "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" ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.1.tgz", - "integrity": "sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ==", + "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": [ - "s390x" + "riscv64" ], "dev": true, "libc": [ @@ -456,17 +1529,31 @@ "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" ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.1.tgz", - "integrity": "sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw==", + "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": [ - "x64" + "s390x" ], "dev": true, "libc": [ @@ -476,15 +1563,29 @@ "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" ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.1.tgz", - "integrity": "sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A==", + "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" ], @@ -496,15 +1597,26 @@ "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" ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.1.tgz", - "integrity": "sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw==", + "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" ], @@ -513,48 +1625,54 @@ "optional": true, "os": [ "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.2.1.tgz", - "integrity": "sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww==", + "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, - "dependencies": { - "@emnapi/core": "2.0.0-alpha.3", - "@emnapi/runtime": "2.0.0-alpha.3", - "@napi-rs/wasm-runtime": "^1.2.0" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=23.5.0" - } + "os": [ + "win32" + ] }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.1.tgz", - "integrity": "sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw==", + "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": [ - "arm64" + "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" ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.1.tgz", - "integrity": "sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ==", + "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" ], @@ -563,17 +1681,7 @@ "optional": true, "os": [ "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", - "dev": true, - "license": "MIT" + ] }, "node_modules/@standard-schema/spec": { "version": "1.1.0", @@ -658,17 +1766,6 @@ } } }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", - "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -677,6 +1774,51 @@ "license": "MIT", "peer": true }, + "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/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -705,9 +1847,9 @@ "license": "MIT" }, "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==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, "node_modules/@types/estree-jsx": { @@ -720,14 +1862,20 @@ } }, "node_modules/@types/hast": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", - "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "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/katex": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz", + "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==", + "license": "MIT" + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -743,6 +1891,16 @@ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -750,9 +1908,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "18.3.31", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", - "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "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": "*", @@ -775,36 +1933,84 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@uiw/codemirror-extensions-basic-setup": { + "version": "4.25.9", + "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.9.tgz", + "integrity": "sha512-QFAqr+pu6lDmNpAlecODcF49TlsrZ0bj15zPzfhiqSDl+Um3EsDLFLppixC7kFLn+rdDM2LTvVjn5CPvefpRgw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + }, + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@codemirror/autocomplete": ">=6.0.0", + "@codemirror/commands": ">=6.0.0", + "@codemirror/language": ">=6.0.0", + "@codemirror/lint": ">=6.0.0", + "@codemirror/search": ">=6.0.0", + "@codemirror/state": ">=6.0.0", + "@codemirror/view": ">=6.0.0" + } + }, + "node_modules/@uiw/react-codemirror": { + "version": "4.25.9", + "resolved": "https://registry.npmjs.org/@uiw/react-codemirror/-/react-codemirror-4.25.9.tgz", + "integrity": "sha512-HftqCBUYShAOH0pGi1CHP8vfm5L8fQ3+0j0VI6lQD6QpK+UBu3J7nxfEN5O/BXMilMNf9ZyFJRvRcuMMOLHMng==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.6", + "@codemirror/commands": "^6.1.0", + "@codemirror/state": "^6.1.1", + "@codemirror/theme-one-dark": "^6.0.0", + "@uiw/codemirror-extensions-basic-setup": "4.25.9", + "codemirror": "^6.0.0" + }, + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@babel/runtime": ">=7.11.0", + "@codemirror/state": ">=6.0.0", + "@codemirror/theme-one-dark": ">=6.0.0", + "@codemirror/view": ">=6.0.0", + "codemirror": ">=6.0.0", + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, "node_modules/@ungap/structured-clone": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", - "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "license": "ISC" }, "node_modules/@vitejs/plugin-react": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz", - "integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", "dev": true, "license": "MIT", "dependencies": { - "@rolldown/pluginutils": "^1.0.1" + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" }, "engines": { "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", - "babel-plugin-react-compiler": "^1.0.0", - "vite": "^8.0.0" - }, - "peerDependenciesMeta": { - "@rolldown/plugin-babel": { - "optional": true - }, - "babel-plugin-react-compiler": { - "optional": true - } + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "node_modules/@vitest/expect": { @@ -975,13 +2181,6 @@ "node": ">=12" } }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, - "license": "MIT" - }, "node_modules/bail": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", @@ -992,20 +2191,84 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "node_modules/baseline-browser-mapping": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.8.tgz", + "integrity": "sha512-zAgkquC2WYF0PIc6XbNYkA2uuxxFavzgmX61R+dHDUa558V8Ejf8ozTZFR6QzM24RWu4kBcRkhJ5kpz77j9fnQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/browserslist": { + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", "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": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" }, "engines": { - "node": ">= 0.4" + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "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", @@ -1066,17 +2329,19 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, + "node_modules/codemirror": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", "license": "MIT", "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" } }, "node_modules/comma-separated-tokens": { @@ -1089,6 +2354,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1096,6 +2370,26 @@ "dev": true, "license": "MIT" }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT" + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", @@ -1104,25 +2398,30 @@ "license": "MIT" }, "node_modules/cssstyle": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", - "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", + "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/css-color": "^3.2.0", - "rrweb-cssom": "^0.8.0" + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.4" }, "engines": { - "node": ">=18" + "node": ">=20" } }, - "node_modules/cssstyle/node_modules/rrweb-cssom": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", - "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", "dev": true, - "license": "MIT" + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } }, "node_modules/csstype": { "version": "3.2.3", @@ -1131,17 +2430,27 @@ "license": "MIT" }, "node_modules/data-urls": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", - "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.1.tgz", + "integrity": "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==", "dev": true, "license": "MIT", "dependencies": { - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.0.0" + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^15.1.0" }, "engines": { - "node": ">=18" + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" } }, "node_modules/debug": { @@ -1181,16 +2490,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -1200,16 +2499,6 @@ "node": ">=6" } }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, "node_modules/devlop": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", @@ -1231,26 +2520,17 @@ "license": "MIT", "peer": true }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "node_modules/electron-to-chromium": { + "version": "1.5.398", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.398.tgz", + "integrity": "sha512-AsvhAxopJGh6museTDMIjn6JpDYOfgu4RLlygomt87MUwBUqTfd/1EiPtx10/LZE8xpTvkP2E9Gafq7lkLtodQ==", "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } + "license": "ISC" }, "node_modules/entities": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -1259,26 +2539,6 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/es-module-lexer": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", @@ -1286,33 +2546,56 @@ "dev": true, "license": "MIT" }, - "node_modules/es-object-atoms": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, + "hasInstallScript": true, "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" + "bin": { + "esbuild": "bin/esbuild" }, "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "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", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, "engines": { - "node": ">= 0.4" + "node": ">=6" } }, "node_modules/escape-string-regexp": { @@ -1348,9 +2631,9 @@ } }, "node_modules/expect-type": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", - "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -1381,27 +2664,10 @@ } } }, - "node_modules/form-data": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", - "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.4", - "mime-types": "^2.1.35" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1413,108 +2679,149 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "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-from-dom": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/hast-util-from-dom/-/hast-util-from-dom-5.0.1.tgz", + "integrity": "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==", + "license": "ISC", + "dependencies": { + "@types/hast": "^3.0.0", + "hastscript": "^9.0.0", + "web-namespaces": "^2.0.0" + }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, + "node_modules/hast-util-from-html": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", + "integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==", "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" + "@types/hast": "^3.0.0", + "devlop": "^1.1.0", + "hast-util-from-parse5": "^8.0.0", + "parse5": "^7.0.0", + "vfile": "^6.0.0", + "vfile-message": "^4.0.0" }, - "engines": { - "node": ">= 0.4" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html-isomorphic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hast-util-from-html-isomorphic/-/hast-util-from-html-isomorphic-2.0.0.tgz", + "integrity": "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-dom": "^5.0.0", + "hast-util-from-html": "^2.0.0", + "unist-util-remove-position": "^5.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", "license": "MIT", "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" }, - "engines": { - "node": ">= 0.4" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", "license": "MIT", - "engines": { - "node": ">= 0.4" + "dependencies": { + "@types/hast": "^3.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", "license": "MIT", - "engines": { - "node": ">= 0.4" + "dependencies": { + "@types/hast": "^3.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, + "node_modules/hast-util-raw": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz", + "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", "license": "MIT", "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-from-parse5": "^8.0.0", + "hast-util-to-parse5": "^8.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "parse5": "^7.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "dev": true, + "node_modules/hast-util-sanitize": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz", + "integrity": "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==", "license": "MIT", "dependencies": { - "function-bind": "^1.1.2" + "@types/hast": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "unist-util-position": "^5.0.0" }, - "engines": { - "node": ">= 0.4" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, "node_modules/hast-util-to-jsx-runtime": { @@ -1544,6 +2851,41 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-to-parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz", + "integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.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", @@ -1557,17 +2899,34 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/html-encoding-sniffer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", - "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", "dev": true, "license": "MIT", "dependencies": { - "whatwg-encoding": "^3.1.1" + "@exodus/bytes": "^1.6.0" }, "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, "node_modules/html-url-attributes": { @@ -1580,6 +2939,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -1608,19 +2977,6 @@ "node": ">= 14" } }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/indent-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", @@ -1707,39 +3063,38 @@ "license": "MIT" }, "node_modules/jsdom": { - "version": "25.0.1", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", - "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "version": "27.4.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz", + "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", "dev": true, "license": "MIT", "dependencies": { - "cssstyle": "^4.1.0", - "data-urls": "^5.0.0", - "decimal.js": "^10.4.3", - "form-data": "^4.0.0", - "html-encoding-sniffer": "^4.0.0", + "@acemir/cssom": "^0.9.28", + "@asamuzakjp/dom-selector": "^6.7.6", + "@exodus/bytes": "^1.6.0", + "cssstyle": "^5.3.4", + "data-urls": "^6.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.5", + "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", - "nwsapi": "^2.2.12", - "parse5": "^7.1.2", - "rrweb-cssom": "^0.7.1", + "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", - "tough-cookie": "^5.0.0", + "tough-cookie": "^6.0.0", "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^7.0.0", - "whatwg-encoding": "^3.1.1", + "webidl-conversions": "^8.0.0", "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.0.0", - "ws": "^8.18.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", "xml-name-validator": "^5.0.0" }, "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "canvas": "^2.11.2" + "canvas": "^3.0.0" }, "peerDependenciesMeta": { "canvas": { @@ -1747,277 +3102,72 @@ } } }, - "node_modules/lightningcss": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", - "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.33.0", - "lightningcss-darwin-arm64": "1.33.0", - "lightningcss-darwin-x64": "1.33.0", - "lightningcss-freebsd-x64": "1.33.0", - "lightningcss-linux-arm-gnueabihf": "1.33.0", - "lightningcss-linux-arm64-gnu": "1.33.0", - "lightningcss-linux-arm64-musl": "1.33.0", - "lightningcss-linux-x64-gnu": "1.33.0", - "lightningcss-linux-x64-musl": "1.33.0", - "lightningcss-win32-arm64-msvc": "1.33.0", - "lightningcss-win32-x64-msvc": "1.33.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", - "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", - "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", - "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", - "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", - "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", - "cpu": [ - "arm" - ], + "node_modules/jsdom/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], + "license": "BSD-2-Clause", "engines": { - "node": ">= 12.0.0" + "node": ">=20.19.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", - "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", - "cpu": [ - "arm64" - ], + "node_modules/jsdom/node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", "dev": true, - "libc": [ - "glibc" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", - "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", - "cpu": [ - "arm64" - ], + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", "dev": true, - "libc": [ - "musl" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", - "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": ">=6" } }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", - "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", - "cpu": [ - "x64" - ], + "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, - "libc": [ - "musl" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" + "license": "MIT", + "bin": { + "json5": "lib/cli.js" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", - "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": ">=6" } }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", - "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" + "node_modules/katex": { + "version": "0.16.45", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.45.tgz", + "integrity": "sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" ], - "engines": { - "node": ">= 12.0.0" + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "bin": { + "katex": "cli.js" } }, "node_modules/longest-streak": { @@ -2042,6 +3192,16 @@ "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/lucide-react": { "version": "0.468.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz", @@ -2082,16 +3242,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "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", @@ -2233,6 +3383,25 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-math": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-math/-/mdast-util-math-3.0.0.tgz", + "integrity": "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "longest-streak": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.1.0", + "unist-util-remove-position": "^5.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", @@ -2362,6 +3531,13 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/micromark": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", @@ -2552,6 +3728,25 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/micromark-extension-math": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz", + "integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==", + "license": "MIT", + "dependencies": { + "@types/katex": "^0.16.0", + "devlop": "^1.0.0", + "katex": "^0.16.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", @@ -2925,29 +4120,6 @@ ], "license": "MIT" }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -2983,12 +4155,15 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/nwsapi": { - "version": "2.2.24", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", - "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/obug": { "version": "2.1.4", @@ -3033,7 +4208,6 @@ "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", - "dev": true, "license": "MIT", "dependencies": { "entities": "^6.0.0" @@ -3057,9 +4231,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -3101,21 +4275,6 @@ "node": ">=20" } }, - "node_modules/playwright/node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/postcss": { "version": "8.5.25", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", @@ -3162,9 +4321,9 @@ } }, "node_modules/property-information": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", - "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "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", @@ -3241,6 +4400,16 @@ "react": ">=18" } }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -3255,6 +4424,54 @@ "node": ">=8" } }, + "node_modules/rehype-katex": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz", + "integrity": "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/katex": "^0.16.0", + "hast-util-from-html-isomorphic": "^2.0.0", + "hast-util-to-text": "^4.0.0", + "katex": "^0.16.0", + "unist-util-visit-parents": "^6.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-raw": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", + "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-raw": "^9.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-sanitize": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz", + "integrity": "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-sanitize": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-gfm": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", @@ -3273,6 +4490,22 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-math": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/remark-math/-/remark-math-6.0.0.tgz", + "integrity": "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-math": "^3.0.0", + "micromark-extension-math": "^3.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", @@ -3321,53 +4554,60 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/rolldown": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.1.tgz", - "integrity": "sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw==", + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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": { - "@oxc-project/types": "=0.142.0", - "@rolldown/pluginutils": "^1.0.0" + "@types/estree": "1.0.8" }, "bin": { - "rolldown": "bin/cli.mjs" + "rollup": "dist/bin/rollup" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18.0.0", + "npm": ">=8.0.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.2.1", - "@rolldown/binding-darwin-arm64": "1.2.1", - "@rolldown/binding-darwin-x64": "1.2.1", - "@rolldown/binding-freebsd-x64": "1.2.1", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.1", - "@rolldown/binding-linux-arm64-gnu": "1.2.1", - "@rolldown/binding-linux-arm64-musl": "1.2.1", - "@rolldown/binding-linux-ppc64-gnu": "1.2.1", - "@rolldown/binding-linux-s390x-gnu": "1.2.1", - "@rolldown/binding-linux-x64-gnu": "1.2.1", - "@rolldown/binding-linux-x64-musl": "1.2.1", - "@rolldown/binding-openharmony-arm64": "1.2.1", - "@rolldown/binding-wasm32-wasi": "1.2.1", - "@rolldown/binding-win32-arm64-msvc": "1.2.1", - "@rolldown/binding-win32-x64-msvc": "1.2.1" - } - }, - "node_modules/rrweb-cssom": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", - "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", - "dev": true, - "license": "MIT" - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, - "license": "MIT" + "@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/saxes": { "version": "6.0.0", @@ -3391,6 +4631,16 @@ "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/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -3459,6 +4709,12 @@ "node": ">=8" } }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, "node_modules/style-to-js": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", @@ -3502,9 +4758,9 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "dev": true, "license": "MIT", "dependencies": { @@ -3529,49 +4785,49 @@ } }, "node_modules/tldts": { - "version": "6.1.86", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", - "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.9.tgz", + "integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^6.1.86" + "tldts-core": "^7.4.9" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "6.1.86", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", - "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.9.tgz", + "integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==", "dev": true, "license": "MIT" }, "node_modules/tough-cookie": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", - "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { - "tldts": "^6.1.32" + "tldts": "^7.0.5" }, "engines": { "node": ">=16" } }, "node_modules/tr46": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", - "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", "dev": true, "license": "MIT", "dependencies": { "punycode": "^2.3.1" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/trim-lines": { @@ -3594,14 +4850,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -3616,6 +4864,13 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "node_modules/unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", @@ -3635,6 +4890,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/unist-util-find-after": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^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", @@ -3661,6 +4930,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/unist-util-remove-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", + "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.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", @@ -3703,6 +4986,37 @@ "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", @@ -3717,6 +5031,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.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", @@ -3732,17 +5060,18 @@ } }, "node_modules/vite": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", - "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", "dev": true, "license": "MIT", "dependencies": { - "lightningcss": "^1.33.0", - "picomatch": "^4.0.5", - "postcss": "^8.5.23", - "rolldown": "~1.2.0", - "tinyglobby": "^0.2.17" + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" }, "bin": { "vite": "bin/vite.js" @@ -3758,10 +5087,9 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.4.0", - "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", + "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", @@ -3774,18 +5102,15 @@ "@types/node": { "optional": true }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, "jiti": { "optional": true }, "less": { "optional": true }, + "lightningcss": { + "optional": true + }, "sass": { "optional": true }, @@ -3809,6 +5134,21 @@ } } }, + "node_modules/vite/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/vitest": { "version": "4.1.10", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", @@ -3899,6 +5239,12 @@ } } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", @@ -3912,28 +5258,24 @@ "node": ">=18" } }, - "node_modules/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/whatwg-encoding": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", - "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", - "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", "dev": true, - "license": "MIT", - "dependencies": { - "iconv-lite": "0.6.3" - }, + "license": "BSD-2-Clause", "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/whatwg-mimetype": { @@ -3947,17 +5289,17 @@ } }, "node_modules/whatwg-url": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", - "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", "dev": true, "license": "MIT", "dependencies": { - "tr46": "^5.1.0", - "webidl-conversions": "^7.0.0" + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/why-is-node-running": { @@ -4016,6 +5358,13 @@ "dev": true, "license": "MIT" }, + "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", diff --git a/web/package.json b/web/package.json index 6053012..e23dc14 100644 --- a/web/package.json +++ b/web/package.json @@ -1,32 +1,40 @@ { - "name": "markdown-task-manager-web", + "name": "research-workbench-web", "private": true, - "version": "1.0.0", + "version": "1.1.0", "type": "module", "scripts": { "dev": "vite --host 127.0.0.1 --port 5173", - "build": "tsc --noEmit && vite build", + "build": "tsc -b && vite build", "preview": "vite preview --host 127.0.0.1 --port 4173", "test": "vitest run --config vitest.config.ts", - "test:e2e": "node scripts/prepare-e2e-vault.mjs && playwright test" + "test:e2e": "npm run build && node scripts/prepare-playwright-vault.mjs && playwright test" }, "dependencies": { + "@codemirror/lang-markdown": "^6.5.0", + "@uiw/react-codemirror": "^4.25.1", + "katex": "^0.16.45", "lucide-react": "^0.468.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^9.0.1", - "remark-gfm": "^4.0.0" + "rehype-katex": "^7.0.1", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", + "remark-gfm": "^4.0.0", + "remark-math": "^6.0.0" }, "devDependencies": { "@playwright/test": "^1.62.1", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.1.0", + "@types/node": "^22.20.1", "@types/react": "^18.3.17", "@types/react-dom": "^18.3.5", - "@vitejs/plugin-react": "^6.0.5", - "jsdom": "^25.0.1", - "typescript": "^5.7.2", - "vite": "^8.2.0", + "@vitejs/plugin-react": "^5.2.0", + "jsdom": "^27.4.0", + "typescript": "^5.9.3", + "vite": "^7.3.6", "vitest": "^4.1.10" } } diff --git a/web/playwright.config.ts b/web/playwright.config.ts index 47c48ca..7ed89c8 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -1,45 +1,26 @@ import { defineConfig, devices } from '@playwright/test'; -import { existsSync } from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const webRoot = path.dirname(fileURLToPath(import.meta.url)); -const root = path.dirname(webRoot); -const localPython = path.join(root, '.venv', 'bin', 'python'); -const python = process.env.PM_TEST_PYTHON || (existsSync(localPython) ? localPython : 'python'); export default defineConfig({ testDir: './e2e', - fullyParallel: false, + timeout: 30_000, + expect: { timeout: 8_000 }, workers: 1, - retries: 0, - reporter: 'list', use: { - baseURL: 'http://127.0.0.1:4173', - trace: 'retain-on-failure', + baseURL: 'http://127.0.0.1:18765', + ...devices['Desktop Chrome'], }, - projects: [ - { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, - { name: 'mobile', use: { ...devices['iPhone 13'] } }, - ], webServer: [ { - command: `"${python}" -m uvicorn server.app:app --host 127.0.0.1 --port 8765`, - cwd: root, - env: { - ...process.env, - PM_VAULT_ROOT: path.join(webRoot, '.tmp', 'e2e-vault'), - }, - url: 'http://127.0.0.1:8765/api/health', + command: 'cd .. && PY=python3; if [ -x .venv/bin/python ]; then PY=.venv/bin/python; fi; PM_VAULT_ROOT=web/.tmp/playwright-vault PM_OUTPUT_ROOT=web/.tmp/playwright-vault/.generated $PY -m uvicorn server.app:app --host 127.0.0.1 --port 18765', + url: 'http://127.0.0.1:18765/api/health', reuseExistingServer: false, - timeout: 30_000, + timeout: 20_000, }, { - command: 'npm run dev -- --port 4173', - cwd: webRoot, - url: 'http://127.0.0.1:4173', + command: 'cd .. && PY=python3; if [ -x .venv/bin/python ]; then PY=.venv/bin/python; fi; PM_VAULT_ROOT=web/.tmp/playwright-empty-vault PM_OUTPUT_ROOT=web/.tmp/playwright-empty-vault/.generated $PY -m uvicorn server.app:app --host 127.0.0.1 --port 18766', + url: 'http://127.0.0.1:18766/api/health', reuseExistingServer: false, - timeout: 30_000, + timeout: 20_000, }, ], }); diff --git a/web/public/icons/apple-touch-icon.png b/web/public/icons/apple-touch-icon.png new file mode 100644 index 0000000..833cb42 Binary files /dev/null and b/web/public/icons/apple-touch-icon.png differ diff --git a/web/public/icons/icon-192.png b/web/public/icons/icon-192.png new file mode 100644 index 0000000..7ca6608 Binary files /dev/null and b/web/public/icons/icon-192.png differ diff --git a/web/public/icons/icon-512.png b/web/public/icons/icon-512.png new file mode 100644 index 0000000..cd39841 Binary files /dev/null and b/web/public/icons/icon-512.png differ diff --git a/web/public/icons/maskable-512.png b/web/public/icons/maskable-512.png new file mode 100644 index 0000000..58e2054 Binary files /dev/null and b/web/public/icons/maskable-512.png differ diff --git a/web/public/icons/research-workbench-icon.svg b/web/public/icons/research-workbench-icon.svg new file mode 100644 index 0000000..795b2b5 --- /dev/null +++ b/web/public/icons/research-workbench-icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/web/public/manifest.webmanifest b/web/public/manifest.webmanifest new file mode 100644 index 0000000..0470495 --- /dev/null +++ b/web/public/manifest.webmanifest @@ -0,0 +1,30 @@ +{ + "name": "Research Workbench", + "short_name": "Workbench", + "description": "Configurable Markdown-first research project workbench.", + "id": "/", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#f8f9f8", + "theme_color": "#f8f9f8", + "categories": ["productivity", "utilities"], + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "/icons/maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/web/public/offline.html b/web/public/offline.html new file mode 100644 index 0000000..5eb1e15 --- /dev/null +++ b/web/public/offline.html @@ -0,0 +1,37 @@ + + + + + + + Research Workbench Offline + + + +
+

Research Workbench is offline

+

Reconnect to load your hosted Markdown vault. Offline editing is not enabled in this version.

+
+ + diff --git a/web/public/sw.js b/web/public/sw.js new file mode 100644 index 0000000..eb274a6 --- /dev/null +++ b/web/public/sw.js @@ -0,0 +1,76 @@ +const CACHE_NAME = 'research-workbench-shell-v2'; +const APP_SHELL = [ + '/', + '/offline.html', + '/manifest.webmanifest', + '/icons/research-workbench-icon.svg', + '/icons/icon-192.png', + '/icons/icon-512.png', + '/icons/maskable-512.png', + '/icons/apple-touch-icon.png', +]; + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME) + .then((cache) => cache.addAll(APP_SHELL)) + .then(() => self.skipWaiting()), + ); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys() + .then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)))) + .then(() => self.clients.claim()), + ); +}); + +function shouldBypass(requestUrl) { + return requestUrl.origin !== self.location.origin + || requestUrl.pathname.startsWith('/api/') + || requestUrl.pathname === '/dashboard' + || requestUrl.pathname.startsWith('/dashboard/') + || requestUrl.pathname === '/sw.js'; +} + +async function cacheFirst(request) { + const cached = await caches.match(request); + if (cached) return cached; + const response = await fetch(request); + if (response.ok) { + const cache = await caches.open(CACHE_NAME); + await cache.put(request, response.clone()); + } + return response; +} + +async function shellFallback(request) { + try { + return await fetch(request); + } catch (error) { + if (request.mode === 'navigate') { + return await caches.match('/') || await caches.match('/offline.html'); + } + return await caches.match(request) || Response.error(); + } +} + +self.addEventListener('fetch', (event) => { + if (event.request.method !== 'GET') return; + + const requestUrl = new URL(event.request.url); + if (shouldBypass(requestUrl)) return; + + if ( + requestUrl.pathname.startsWith('/assets/') + || requestUrl.pathname.startsWith('/icons/') + || requestUrl.pathname === '/manifest.webmanifest' + || requestUrl.pathname === '/offline.html' + ) { + event.respondWith(cacheFirst(event.request)); + return; + } + + event.respondWith(shellFallback(event.request)); +}); diff --git a/web/scripts/prepare-playwright-vault.mjs b/web/scripts/prepare-playwright-vault.mjs new file mode 100644 index 0000000..fdcc3ad --- /dev/null +++ b/web/scripts/prepare-playwright-vault.mjs @@ -0,0 +1,47 @@ +import { existsSync, mkdirSync, rmSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const webRoot = resolve(here, '..'); +const appRoot = resolve(webRoot, '..'); +const syntheticVault = resolve(webRoot, '.tmp', 'playwright-vault'); +const emptyVault = resolve(webRoot, '.tmp', 'playwright-empty-vault'); +const python = existsSync(resolve(appRoot, '.venv', 'bin', 'python')) + ? resolve(appRoot, '.venv', 'bin', 'python') + : 'python3'; + +function run(args, env = {}) { + const result = spawnSync(python, args, { + cwd: appRoot, + env: { ...process.env, ...env }, + encoding: 'utf8', + stdio: 'inherit', + }); + if (result.status !== 0) { + throw new Error(`${python} ${args.join(' ')} failed with status ${result.status}`); + } +} + +rmSync(syntheticVault, { recursive: true, force: true }); +rmSync(emptyVault, { recursive: true, force: true }); +mkdirSync(emptyVault, { recursive: true }); + +run([ + resolve(appRoot, 'scripts', 'bootstrap_demo.py'), + '--source', + resolve(appRoot, 'example-vault'), + '--target', + syntheticVault, + '--today', + '2026-07-30', +]); + +const renderEnv = { + PM_VAULT_ROOT: syntheticVault, + PM_OUTPUT_ROOT: resolve(syntheticVault, '.generated'), + PM_RENDER_TIMESTAMP: '2026-07-30T12:00:00Z', +}; +run([resolve(appRoot, 'scripts', 'sync_markdown.py')], renderEnv); +run([resolve(appRoot, 'scripts', 'render_static.py')], renderEnv); diff --git a/web/src/AdminCenterView.test.tsx b/web/src/AdminCenterView.test.tsx new file mode 100644 index 0000000..c3d0f8b --- /dev/null +++ b/web/src/AdminCenterView.test.tsx @@ -0,0 +1,57 @@ +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { AdminCenterView } from './App'; +import { buildAdminCenter } from './lib/adminCenter'; +import type { VaultEntry } from './types'; + +const today = new Date(2026, 4, 15); + +const entries: VaultEntry[] = [ + { path: 'tasks/active/evaluation.md', filename: 'evaluation.md', title: 'Complete annual example form', kind: 'task', status: 'open', priority: '1', due: '2026-05-18', project: 'operations-admin', domain: 'admin', properties: { admin_category: 'forms-approvals' }, private: false }, + { path: 'tasks/active/policy.md', filename: 'policy.md', title: 'Prepare Supply Chain Pressure Index policy slides', kind: 'task', status: 'active', priority: '2', due: '2026-05-28', project: 'policy', domain: 'admin', properties: { admin_category: 'policy-service' }, private: false }, + { path: 'tasks/waiting/event.md', filename: 'event.md', title: 'Wait for Lakeside event venue reply', kind: 'task', status: 'waiting', priority: '3', due: '2026-07-01', project: 'personal-event', domain: 'admin', properties: { admin_category: 'life-admin' }, private: false }, +]; + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(today); +}); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe('AdminCenterView', () => { + it('renders metrics, lanes, and task quick actions', () => { + const onOpen = vi.fn(); + const onPatch = vi.fn(); + const data = buildAdminCenter(entries, today); + + render( + , + ); + + expect(screen.getByRole('heading', { name: 'Admin Center' })).toBeVisible(); + expect(screen.getByText('Urgent admin')).toBeVisible(); + expect(screen.getAllByText('Forms / approvals')[0]).toBeVisible(); + expect(screen.getByText('Policy + service')).toBeVisible(); + expect(screen.getAllByText('Life admin')[0]).toBeVisible(); + expect(screen.getByText('Complete annual example form')).toBeVisible(); + expect(screen.getByText('Prepare Supply Chain Pressure Index policy slides')).toBeVisible(); + + fireEvent.click(screen.getByText('Prepare Supply Chain Pressure Index policy slides')); + expect(onOpen).toHaveBeenCalledWith('tasks/active/policy.md'); + + const taskCard = screen.getByText('Complete annual example form').closest('article') as HTMLElement; + fireEvent.click(within(taskCard).getByText('More')); + fireEvent.click(within(taskCard).getByRole('button', { name: 'Done' })); + expect(onPatch).toHaveBeenCalledWith('tasks/active/evaluation.md', { status: 'done' }); + }); +}); diff --git a/web/src/App.tsx b/web/src/App.tsx index 7636fb0..645e9cd 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,419 +1,9727 @@ +import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { CSSProperties, ReactNode } from 'react'; import { + Activity, + AlarmClock, + Award, + Bot, CalendarDays, Check, - CircleDot, - FilePlus2, - FolderKanban, - LayoutDashboard, - Library, - Menu, - Moon, - PanelLeftClose, - RefreshCw, + ChevronLeft, + ChevronRight, + Clock3, + Circle, + ClipboardList, + Columns3, + Copy, + Eye, + EyeOff, + ExternalLink, + FileText, + FolderGit2, + GitBranch, + GraduationCap, + HeartPulse, + Home, + Layers, + Target, + ListFilter, + MoreHorizontal, + PanelLeft, + PanelRight, + Pencil, + Plane, + Plus, + Receipt, + RefreshCcw, Save, Search, - Sun, + Send, + SlidersHorizontal, + Shield, + Sparkles, + Tags, + TerminalSquare, + Type, + UsersRound, + Wallet, X, } from 'lucide-react'; -import { useCallback, useEffect, useMemo, useState } from 'react'; -import ReactMarkdown from 'react-markdown'; -import remarkGfm from 'remark-gfm'; - -import { createFile, loadEntries, loadFile, patchTask, saveFile, setToken } from './api'; -import { counts, entryKind, isTask, visibleEntries, wikiToMarkdown } from './model'; -import type { VaultEntry, VaultFile, View } from './types'; - -const viewItems: { id: View; label: string; icon: typeof LayoutDashboard }[] = [ - { id: 'overview', label: 'Overview', icon: LayoutDashboard }, - { id: 'tasks', label: 'Tasks', icon: Check }, - { id: 'projects', label: 'Projects', icon: FolderKanban }, - { id: 'calendar', label: 'Calendar', icon: CalendarDays }, - { id: 'library', label: 'Library', icon: Library }, -]; +import { + ApiError, + createEntry, + getEntries, + getConfig, + getDiagnostics, + getFile, + getGitStatus, + getGitHubSyncStatus, + getHealth, + getScholarStats, + getStoredToken, + patchTaskMetadata, + patchVaultMetadata, + pushGitHubSync, + reloadVault, + resetGitHubSync, + saveFile, + storeToken, +} from './api'; +import type { AdminCenterData } from './lib/adminCenter'; +import { codexInstructionCounts, formatCodexInstruction, parseCodexInstructions, type CodexBacklogData } from './lib/codexInstructions'; +import { buildCommands, type Command } from './lib/commands'; +import { DEFAULT_WORKBENCH_CONFIG, isViewEnabled, viewLabel } from './lib/config'; +import { configureDomainLabels, domainLabel, entryDomain } from './lib/domain'; +import { deadlineType, deadlineTypeLabel, isHardDeadline, nextDeadlineType } from './lib/deadlines'; +import { entryLens, fieldCounts, filterEntries, groupCounts, isTask } from './lib/filters'; +import { frontmatterFromContent, frontmatterScalar, replaceFrontmatter, scalarDisplay, setFrontmatterScalar } from './lib/frontmatter'; +import { buildHealthReview, healthCategory, healthCategoryLabel, healthEntryDate, healthRoutineLabel, isHealthReviewEntry, type HealthStravaSummary, type HealthTaskGroup } from './lib/healthReview'; +import { buildOverview, configureOwnerAliases, daysUntil, focusReason, isCoauthorAssignedTask, isDeferredTask, isManualUrgent, taskAssignee, type OverviewData, type OverviewTaskGroup, type ProjectPulse, type ProjectReview } from './lib/overview'; +import type { PerformanceEvaluationData } from './lib/performanceEvaluation'; +import type { RAManagementData, RARecord } from './lib/raManagement'; +import { parseLedgerRows, type TravelCenterData, type TravelLedgerItem, type TravelTrip } from './lib/travelCenter'; +import { awaitingSubmissions, conferenceDateLabel, isConferenceSubmissionDeadline, monitoredSubmissions, upcomingSubmissions, type ConferenceSubmission } from './lib/submissions'; +import { availabilityForDate, buildAvailability, ymd, type AvailabilityDay, type AvailabilityStatus } from './lib/availability'; +import { outstandingRefereeReports, type RefereeReport } from './lib/refereeReports'; +import { splitProjectPulse } from './lib/projectPulse'; +import { entryToneClass, kindToneClass, priorityTone, statusTone, urgencyTone } from './lib/visualTaxonomy'; +import { buildCalendarMonth, buildCalendarWeek, buildCalendarYear, buildWorkbenchData, type CalendarDay, type CalendarItem, type CalendarMonth, type CalendarViewMode, type WorkbenchData, type TypeGroup } from './lib/workbench'; +import { applyTaskMetadataUpdates, TaskQuickEdit, taskQuickEditFromEntry, taskQuickEditFromFile } from './TaskQuickEdit'; +import type { AppDiagnostics, AppHealth, AppView, ContextScope, EditorMode, EntryFilters, GitHubSyncPushResult, GitHubSyncResult, GitHubSyncStatus, GitStatus, LayoutPanels, QuickLookState, ScholarStats, TaskMetadataUpdates, TypographyPreferences, VaultEntry, VaultFile, VaultMetadataUpdates, WorkbenchConfig } from './types'; + +const MarkdownEditor = lazy(() => import('./MarkdownEditor')); +const MarkdownRenderer = lazy(() => import('./MarkdownRenderer').then((module) => ({ default: module.MarkdownRenderer }))); +const LazyTimePlanView = lazy(() => import('./TimePlanView').then((module) => ({ default: module.TimePlanView }))); + +const defaultFilters: EntryFilters = { + lens: 'all', + query: '', + domain: '', + privateMode: true, + collection: '', + entryProject: '', + noteRole: '', + taskStatus: '', + taskPriority: '', + taskProject: '', +}; + +const defaultPanels: LayoutPanels = { + nav: true, + library: false, + inspector: true, +}; + +const PANELS_KEY = 'pm_workbench_panels'; +const TYPOGRAPHY_KEY = 'pm_typography_preferences'; +const CONTEXT_PANEL_KEY = 'pm_context_panel_open'; +const privateHiddenLabel = 'Private hidden in this view'; +const privateVisibleLabel = 'Private visible in this view'; +const privateVisibilityTitle = 'Display filter only: this does not encrypt files or remove private data from the loaded vault.'; + +const defaultTypography: TypographyPreferences = { + editorFont: 'SFMono-Regular, ui-monospace, Menlo, Consolas, monospace', + previewFont: 'ui-serif, Georgia, "Times New Roman", serif', + codeFont: 'SFMono-Regular, ui-monospace, Menlo, Consolas, monospace', + fontSize: 16, + lineHeight: 1.62, + bodyWidth: 'L', +}; + +const bodyWidthPx: Record = { + S: 620, + M: 760, + L: 900, + XL: 1080, + XXL: 1240, +}; -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : 'Something went wrong'; +const viewLabels: Record = { + overview: 'Overview', + 'open-tasks': 'Open Tasks', + 'codex-backlog': 'Codex Backlog', + 'admin-center': 'Admin Center', + 'performance-evaluation': 'Performance Evaluation', + 'travel-center': 'Travel Center', + 'ra-management': 'Collaborators', + 'health-review': 'Health Review', + sync: 'Sync', + projects: 'Projects', + 'project-detail': 'Project', + types: 'Types', + calendar: 'Calendar', + 'time-plan': 'Time Plan', + recent: 'Recent', + library: 'Library', + editor: 'Editor', +}; + +function loadPanels(): LayoutPanels { + if (typeof window === 'undefined') return defaultPanels; + const mobileDefault = window.matchMedia?.('(max-width: 860px)').matches ? { inspector: false } : {}; + try { + const parsed = JSON.parse(window.localStorage.getItem(PANELS_KEY) || ''); + return { ...defaultPanels, ...parsed, ...mobileDefault }; + } catch { + return { ...defaultPanels, ...mobileDefault }; + } +} + +export function loadTypographyPreferences(): TypographyPreferences { + if (typeof window === 'undefined') return defaultTypography; + try { + const parsed = JSON.parse(window.localStorage.getItem(TYPOGRAPHY_KEY) || ''); + return normalizeTypographyPreferences(parsed); + } catch { + return defaultTypography; + } } -function statusTone(status?: string): string { - if (status === 'done') return 'success'; - if (status === 'blocked' || status === 'cancelled') return 'danger'; - if (status === 'waiting') return 'warning'; - return 'neutral'; +function loadContextPanelOpen(): boolean { + if (typeof window === 'undefined') return false; + const raw = window.localStorage.getItem(CONTEXT_PANEL_KEY); + return raw === null ? false : raw === 'true'; } -function displayDate(value?: string): string { - if (!value) return ''; - const date = new Date(`${value}T12:00:00`); - return Number.isNaN(date.valueOf()) - ? value - : date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); +function normalizeTypographyPreferences(value: Partial | null | undefined): TypographyPreferences { + const bodyWidth = ['S', 'M', 'L', 'XL', 'XXL'].includes(String(value?.bodyWidth)) + ? value?.bodyWidth as TypographyPreferences['bodyWidth'] + : defaultTypography.bodyWidth; + const fontSize = Number(value?.fontSize); + const lineHeight = Number(value?.lineHeight); + return { + editorFont: value?.editorFont || defaultTypography.editorFont, + previewFont: value?.previewFont || defaultTypography.previewFont, + codeFont: value?.codeFont || defaultTypography.codeFont, + fontSize: Number.isFinite(fontSize) ? Math.min(22, Math.max(13, fontSize)) : defaultTypography.fontSize, + lineHeight: Number.isFinite(lineHeight) ? Math.min(2, Math.max(1.2, lineHeight)) : defaultTypography.lineHeight, + bodyWidth, + }; } -function bodyFromSource(source: string): string { - if (!source.startsWith('---\n') && !source.startsWith('---\r\n')) return source; - const lines = source.split(/\r?\n/); - const end = lines.findIndex((line, index) => index > 0 && line.trim() === '---'); - return end >= 0 ? lines.slice(end + 1).join('\n') : source; +export function saveTypographyPreferences(value: TypographyPreferences): void { + if (typeof window === 'undefined') return; + window.localStorage.setItem(TYPOGRAPHY_KEY, JSON.stringify(value)); } -function normalizeTarget(value: string): string { - return decodeURIComponent(value).trim().toLowerCase().replace(/\.md$/, ''); +export function typographyCssVariables(value: TypographyPreferences): CSSProperties { + return { + '--editor-font': value.editorFont, + '--preview-font': value.previewFont, + '--code-font': value.codeFont, + '--editor-font-size': `${value.fontSize}px`, + '--editor-line-height': String(value.lineHeight), + '--document-width': `${bodyWidthPx[value.bodyWidth]}px`, + } as CSSProperties; } -function matchingEntry(entries: VaultEntry[], target: string): VaultEntry | undefined { - const wanted = normalizeTarget(target); - return entries.find((entry) => { - const candidates = [ - entry.id, - entry.title, - entry.path, - entry.path.replace(/\.md$/, ''), - entry.filename.replace(/\.md$/, ''), - ...(entry.aliases || []), - ]; - return candidates.some((candidate) => candidate && normalizeTarget(candidate) === wanted); +function formatStamp(value?: number): string { + if (!value) return 'unknown'; + return new Date(value * 1000).toLocaleString(); +} + +function parsePropertyValue(raw: string, original: unknown): unknown { + if (Array.isArray(original)) { + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : [raw]; + } catch { + return raw.split(',').map((item) => item.trim()).filter(Boolean); + } + } + if (typeof original === 'boolean') return ['true', '1', 'yes'].includes(raw.toLowerCase()); + if (typeof original === 'number') { + const numeric = Number(raw); + return Number.isFinite(numeric) ? numeric : raw; + } + if (raw === 'true') return true; + if (raw === 'false') return false; + return raw; +} + +function displayMetadataValue(value: unknown): string { + if (value === null || value === undefined) return ''; + if (Array.isArray(value)) { + if (value.some((item) => item !== null && typeof item === 'object')) { + return `${value.length} structured ${value.length === 1 ? 'item' : 'items'}`; + } + return value.join(', '); + } + if (typeof value === 'object') return 'Structured object'; + return String(value); +} + +function isStructuredMetadataValue(value: unknown): boolean { + if (value === null || value === undefined) return false; + if (Array.isArray(value)) return value.some((item) => item !== null && typeof item === 'object'); + return typeof value === 'object'; +} + +function applyMetadataUpdates( + frontmatter: Record, + updates: VaultMetadataUpdates, +): Record { + const next = { ...frontmatter }; + for (const [key, rawValue] of Object.entries(updates)) { + if (rawValue === null || rawValue === undefined || String(rawValue).trim() === '') { + delete next[key]; + continue; + } + if (key === 'priority' || key === 'estimate_minutes') { + const numeric = Number(rawValue); + next[key] = Number.isFinite(numeric) ? numeric : rawValue; + } else if (key === 'private' || key === 'dashboard' || key === 'urgent') { + next[key] = rawValue === true || String(rawValue).toLowerCase() === 'true'; + } else { + next[key] = String(rawValue).trim(); + } + } + return next; +} + +function entryIdentity(entry: VaultEntry | null): string { + if (!entry) return ''; + return entry.id || entry.project || entry.title || entry.path; +} + +function isProject(entry: VaultEntry | null): boolean { + if (!entry) return false; + const kind = String(entry.kind || entry.type || '').toLowerCase(); + return kind === 'project' || /^projects\/[^/]+\/README\.md$/.test(entry.path); +} + +function todayIso(): string { + return new Date().toISOString().slice(0, 10); +} + +function calendarIso(date = new Date()): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +function parseCalendarIso(value: string): Date { + const [year, month, day] = value.split('-').map((part) => Number(part)); + if (!year || !month || !day) return new Date(); + return new Date(year, month - 1, day); +} + +function shiftCalendarDate(date: Date, viewMode: CalendarViewMode, amount: number): Date { + if (viewMode === 'year') return new Date(date.getFullYear() + amount, date.getMonth(), 1); + if (viewMode === 'month') return new Date(date.getFullYear(), date.getMonth() + amount, 1); + return new Date(date.getFullYear(), date.getMonth(), date.getDate() + amount * 7); +} + +function calendarItemKey(item: CalendarItem): string { + return `${item.entry.path}:${item.rangeStart || item.date}:${item.rangeEnd || item.date}`; +} + +function uniqueCalendarItems(items: CalendarItem[]): CalendarItem[] { + const byRange = new Map(); + for (const item of items) { + const key = calendarItemKey(item); + const current = byRange.get(key); + if (!current || item.date < current.date) { + byRange.set(key, item); + } + } + return [...byRange.values()].sort((a, b) => { + const aStart = a.rangeStart || a.date; + const bStart = b.rangeStart || b.date; + if (aStart !== bStart) return aStart.localeCompare(bStart); + return a.entry.title.localeCompare(b.entry.title); }); } -function EntryRow({ - entry, - active, - onOpen, -}: { - entry: VaultEntry; - active: boolean; - onOpen: (entry: VaultEntry) => void; -}) { - return ( - - ); +function calendarDateSpanLabel(item: CalendarItem): string { + const start = item.rangeStart || item.date; + const end = item.rangeEnd || item.date; + if (start === end) return start; + return `${start} - ${end}`; } -function Overview({ - entries, - onOpen, -}: { - entries: VaultEntry[]; - onOpen: (entry: VaultEntry) => void; -}) { - const summary = counts(entries); - const tasks = entries - .filter(isTask) - .filter((entry) => !['done', 'cancelled', 'archived'].includes(String(entry.status))) - .sort((a, b) => Number(a.priority || 9) - Number(b.priority || 9)) - .slice(0, 8); - const projects = entries.filter((entry) => entryKind(entry) === 'project').slice(0, 6); - return ( -
-
-

Markdown workbench

-

Your work, in plain files.

-

Tasks, project notes, and decisions remain readable with or without this app.

-
-
-
{summary.tasks}open tasks
-
{summary.projects}projects
-
{summary.waiting}waiting or blocked
-
{summary.notes}notes
-
-
-
-

Next work

{tasks.length} shown
-
- {tasks.map((entry) => )} - {!tasks.length &&

No open tasks yet.

} -
-
-
-

Projects

{summary.projects} total
-
- {projects.map((entry) => )} - {!projects.length &&

Create a project to get started.

} -
-
-
-
- ); +type CalendarScope = 'events' | 'focus' | 'all'; + +function calendarHighlightClass(item: CalendarItem): string { + return `calendar-importance-${item.importance || 'normal'} calendar-highlight-${item.highlight || 'none'}`; +} + +function calendarShouldShowCompactHighlight(item: CalendarItem): boolean { + return Boolean(item.highlightShortLabel) && (item.importance === 'major' || item.importance === 'hard-deadline'); +} + +function calendarShouldShowAgendaHighlight(item: CalendarItem): boolean { + if (item.highlight === 'none') return false; + return item.highlight !== 'event' || item.importance !== 'normal'; +} + +function calendarTaskPriority(item: CalendarItem): number { + const parsed = Number(item.entry.priority); + return Number.isFinite(parsed) ? parsed : 99; +} + +function calendarItemMatchesScope(item: CalendarItem, scope: CalendarScope): boolean { + if (scope === 'all') return true; + if (item.kind !== 'task') return true; + if (scope === 'events') return item.importance === 'hard-deadline'; + return item.daysFromToday <= 1 || (calendarTaskPriority(item) <= 1 && item.daysFromToday <= 14); +} + +function hiddenTaskCountsByDate(allItems: CalendarItem[], visibleItems: CalendarItem[]): Map { + const visibleKeys = new Set(visibleItems.map((item) => `${item.entry.path}:${item.date}`)); + const counts = new Map(); + for (const item of allItems) { + if (item.kind !== 'task') continue; + if (visibleKeys.has(`${item.entry.path}:${item.date}`)) continue; + counts.set(item.date, (counts.get(item.date) || 0) + 1); + } + return counts; +} + +const LIFECYCLE_CLOSED_STATUSES = new Set(['done', 'cancelled', 'archived']); +const LIFECYCLE_ACTIVE_STATUSES = new Set(['open', 'active', 'blocked']); + +function taskLifecycleMovePath(path: string, status: string): string | undefined { + if (!path.startsWith('tasks/')) return undefined; + const normalized = status.trim().toLowerCase(); + const filename = path.split('/').pop() || ''; + if (!filename) return undefined; + let folder = ''; + if (LIFECYCLE_CLOSED_STATUSES.has(normalized)) folder = 'tasks/done'; + if (normalized === 'waiting') folder = 'tasks/waiting'; + if (LIFECYCLE_ACTIVE_STATUSES.has(normalized)) folder = 'tasks/active'; + if (!folder) return undefined; + const target = `${folder}/${filename}`; + return target === path ? undefined : target; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function appendToMarkdownSection(content: string, section: string, line: string): string { + const pattern = new RegExp(`^##\\s+${escapeRegExp(section)}\\s*$`, 'im'); + const match = pattern.exec(content); + const trimmed = line.trimEnd(); + if (!match) { + const prefix = content.endsWith('\n') ? '' : '\n'; + return `${content}${prefix}\n## ${section}\n\n${trimmed}\n`; + } + const afterHeading = match.index + match[0].length; + const rest = content.slice(afterHeading); + const nextHeading = rest.search(/\n##\s+/); + const insertAt = nextHeading >= 0 ? afterHeading + nextHeading : content.length; + const before = content.slice(0, insertAt).replace(/\s*$/, '\n\n'); + const after = content.slice(insertAt); + const suffix = after.startsWith('\n') || !after ? '' : '\n'; + return `${before}${trimmed}\n${suffix}${after}`; +} + +function ensureMarkdownSections(content: string, sections: Array<{ title: string; body: string }>): string { + let next = content; + for (const section of sections) { + const pattern = new RegExp(`^##\\s+${escapeRegExp(section.title)}\\s*$`, 'im'); + if (pattern.test(next)) continue; + const prefix = next.endsWith('\n') ? '' : '\n'; + next = `${next}${prefix}\n## ${section.title}\n\n${section.body.trimEnd()}\n`; + } + return next; +} + +function visibleStatusFromContent(content: string, fallback: unknown): string { + return frontmatterScalar(content, 'status') || displayMetadataValue(fallback) || ''; +} + +function needsTokenInput(message: string): boolean { + const normalized = message.toLowerCase(); + return normalized.includes('pm_app_token') || normalized.includes('app token') || normalized.includes('invalid or missing'); +} + +function missingServerToken(message: string): boolean { + return message.toLowerCase().includes('pm_app_token is required'); +} + +function emptyVaultMessage(health: AppHealth | null, entries: VaultEntry[]): string { + const taskCount = entries.filter(isTask).length; + if (entries.length > 0 && taskCount > 0) return ''; + if (!health) return ''; + if (health.entry_count === 0 || entries.length === 0) { + return 'No Markdown entries were found. Initialize a vault or point PM_VAULT_ROOT at a folder containing Markdown files.'; + } + if (taskCount === 0) { + return `The hosted vault loaded ${health.entry_count ?? entries.length} Markdown entries, but no task entries. Confirm the deployed vault contains tasks/active/*.md.`; + } + return ''; +} + +interface NavigationSnapshot { + activeView: AppView; + selectedPath: string; + selectedProjectPath: string; +} + +type SyncCommandRequest = { id: number; action: 'status' | 'push-dry-run' } | null; + +function sameNavigationSnapshot(a: NavigationSnapshot, b: NavigationSnapshot): boolean { + return a.activeView === b.activeView && a.selectedPath === b.selectedPath && a.selectedProjectPath === b.selectedProjectPath; } -export default function App() { +export function App() { + const [workbenchConfig, setWorkbenchConfig] = useState(DEFAULT_WORKBENCH_CONFIG); const [entries, setEntries] = useState([]); - const [view, setView] = useState('overview'); - const [query, setQuery] = useState(''); - const [selected, setSelected] = useState(null); - const [source, setSource] = useState(''); - const [mode, setMode] = useState<'preview' | 'edit'>('preview'); + const [scholarStats, setScholarStats] = useState(null); + const [selectedPath, setSelectedPath] = useState(''); + const [selectedProjectPath, setSelectedProjectPath] = useState(''); + const [selectedFile, setSelectedFile] = useState(null); + const [content, setContent] = useState(''); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); - const [message, setMessage] = useState(''); - const [authNeeded, setAuthNeeded] = useState(false); - const [sidebarOpen, setSidebarOpen] = useState(false); - const [dark, setDark] = useState(() => window.matchMedia('(prefers-color-scheme: dark)').matches); + const [patchingTaskPath, setPatchingTaskPath] = useState(''); + const [patchingMetadataPath, setPatchingMetadataPath] = useState(''); + const [error, setError] = useState(''); + const [health, setHealth] = useState(null); + const [gitStatus, setGitStatus] = useState(null); + const [syncStatus, setSyncStatus] = useState(null); + const [dockSyncResult, setDockSyncResult] = useState(null); + const [dockSyncChecking, setDockSyncChecking] = useState(false); + const [filters, setFilters] = useState(defaultFilters); + const [layoutPanels, setLayoutPanels] = useState(loadPanels); + const [activeView, setActiveView] = useState('overview'); + const [editorMode, setEditorMode] = useState('preview'); + const [paletteOpen, setPaletteOpen] = useState(false); + const [paletteQuery, setPaletteQuery] = useState(''); + const [overviewFocus, setOverviewFocus] = useState('overview'); + const [tokenDraft, setTokenDraft] = useState(getStoredToken()); + const [typography, setTypography] = useState(loadTypographyPreferences); + const [typographyOpen, setTypographyOpen] = useState(false); + const [contextPanelOpen, setContextPanelOpen] = useState(loadContextPanelOpen); + const [quickLook, setQuickLook] = useState(null); + const [quickLookFile, setQuickLookFile] = useState(null); + const [quickLookLoading, setQuickLookLoading] = useState(false); + const [quickLookError, setQuickLookError] = useState(''); + const [navigationHistory, setNavigationHistory] = useState([]); + const [syncCommandRequest, setSyncCommandRequest] = useState(null); + + const selectedEntry = useMemo( + () => entries.find((entry) => entry.path === selectedPath) || null, + [entries, selectedPath], + ); + const currentNavigation = useMemo( + () => ({ activeView, selectedPath, selectedProjectPath }), + [activeView, selectedPath, selectedProjectPath], + ); + const dirty = Boolean(selectedFile && content !== selectedFile.content); + const showSideLibrary = layoutPanels.library && activeView === 'editor' && Boolean(selectedPath); + const overviewData = useMemo(() => buildOverview(entries, filters.privateMode), [entries, filters.privateMode]); + const workbenchBuilderFilters = useMemo(() => ({ + ...defaultFilters, + privateMode: filters.privateMode, + taskStatus: filters.taskStatus, + taskPriority: filters.taskPriority, + taskProject: filters.taskProject, + }), [filters.privateMode, filters.taskPriority, filters.taskProject, filters.taskStatus]); + const workbenchBaseData = useMemo( + () => buildWorkbenchData(entries, workbenchBuilderFilters, new Date(), { ownerAliases: workbenchConfig.owner_aliases }), + [entries, workbenchBuilderFilters, workbenchConfig.owner_aliases], + ); + const filteredEntriesForView = useMemo( + () => filterEntries(workbenchBaseData.visibleEntries, filters), + [filters, workbenchBaseData.visibleEntries], + ); + const workbenchData = useMemo( + () => ({ ...workbenchBaseData, filteredEntries: filteredEntriesForView }), + [filteredEntriesForView, workbenchBaseData], + ); + const commandEntries = useMemo( + () => filters.privateMode ? entries.filter((entry) => !entry.private) : entries, + [entries, filters.privateMode], + ); + const commands = useMemo(() => { + const next = buildCommands(commandEntries); + if (selectedPath) { + next.unshift({ + id: 'quick-look-current', + title: 'Quick Look Current File', + detail: selectedPath, + kind: 'action', + }); + } + next.unshift( + { + id: 'toggle-context-panel', + title: contextPanelOpen ? 'Hide Context Panel' : 'Show Context Panel', + detail: 'Toggle the right-side Codex context composer', + kind: 'action', + }, + { + id: 'open-typography-settings', + title: 'Open Typography Settings', + detail: 'Adjust editor and preview reading controls', + kind: 'action', + }, + ); + return next; + }, [commandEntries, contextPanelOpen, selectedPath]); + const filteredCommands = useMemo(() => { + const q = paletteQuery.trim().toLowerCase(); + if (!q) return commands.slice(0, 40); + return commands.filter((command) => `${command.title} ${command.detail}`.toLowerCase().includes(q)).slice(0, 40); + }, [commands, paletteQuery]); + const taskProjects = useMemo( + () => [...new Set(entries.filter(isTask).map((entry) => entry.project).filter(Boolean) as string[])].sort(), + [entries], + ); + const typeGroups = useMemo(() => groupCounts(workbenchData.visibleEntries), [workbenchData.visibleEntries]); + const domainGroups = useMemo(() => workbenchData.domainGroups.map(({ lens, count }) => ({ lens, count })), [workbenchData.domainGroups]); + const collectionGroups = useMemo(() => fieldCounts(workbenchData.visibleEntries, 'collection'), [workbenchData.visibleEntries]); + const entryProjectGroups = useMemo(() => { + const counts = new Map(); + for (const entry of workbenchData.visibleEntries) { + const value = entry.project || entry.area; + if (value) counts.set(value, (counts.get(value) || 0) + 1); + } + return [...counts.entries()] + .map(([lens, count]) => ({ lens, count })) + .sort((a, b) => b.count - a.count || a.lens.localeCompare(b.lens)); + }, [workbenchData.visibleEntries]); + const noteRoleGroups = useMemo(() => fieldCounts(workbenchData.visibleEntries, 'note_role'), [workbenchData.visibleEntries]); - const refresh = useCallback(async (force = false) => { + const loadVault = useCallback(async () => { setLoading(true); - setMessage(''); + setError(''); try { - setEntries(await loadEntries(force)); - setAuthNeeded(false); - } catch (error) { - if ((error as { status?: number }).status === 401) setAuthNeeded(true); - setMessage(errorMessage(error)); + const nextHealth = await getHealth().catch(() => null); + setHealth(nextHealth); + const [nextConfig, nextEntries, nextGit, nextSyncStatus, nextScholar] = await Promise.all([ + getConfig().catch(() => DEFAULT_WORKBENCH_CONFIG), + getEntries(), + getGitStatus().catch(() => null), + getGitHubSyncStatus().catch(() => null), + getScholarStats().catch(() => null), + ]); + setWorkbenchConfig(nextConfig); + configureDomainLabels(nextConfig.domain_labels); + configureOwnerAliases(nextConfig.owner_aliases); + document.title = nextConfig.application_name; + setEntries(nextEntries); + setGitStatus(nextGit); + setSyncStatus(nextSyncStatus); + setScholarStats(nextScholar); + setDockSyncResult(null); + const deploymentMessage = emptyVaultMessage(nextHealth, nextEntries); + if (deploymentMessage) { + setError(deploymentMessage); + } + } catch (exc) { + setError(exc instanceof Error ? exc.message : 'Could not load vault'); } finally { setLoading(false); } }, []); - useEffect(() => { void refresh(); }, [refresh]); useEffect(() => { - document.documentElement.dataset.theme = dark ? 'dark' : 'light'; - }, [dark]); + void loadVault(); + }, [loadVault]); - const openEntry = useCallback(async (entry: VaultEntry) => { - setMessage(''); - try { - const file = await loadFile(entry.path); - setSelected(file); - setSource(file.content); - setMode('preview'); - setSidebarOpen(false); - } catch (error) { - setMessage(errorMessage(error)); + useEffect(() => { + if (typeof window !== 'undefined') { + window.localStorage.setItem(PANELS_KEY, JSON.stringify(layoutPanels)); } - }, []); + }, [layoutPanels]); + + useEffect(() => { + saveTypographyPreferences(typography); + }, [typography]); + + useEffect(() => { + if (typeof window !== 'undefined') { + window.localStorage.setItem(CONTEXT_PANEL_KEY, String(contextPanelOpen)); + } + }, [contextPanelOpen]); + + useEffect(() => { + if (!selectedPath) { + setSelectedFile(null); + setContent(''); + return; + } + // We already hold this exact file in memory (e.g. just saved/renamed, or a + // reverted navigation). Re-fetching here would clobber the buffer and races + // the just-set state, so skip it. + if (selectedFile && selectedFile.path === selectedPath) { + return; + } + // Navigating to a different file with unsaved edits would silently discard + // them. Confirm first; on cancel, revert the navigation to the dirty file. + if (selectedFile && content !== selectedFile.content) { + const proceed = window.confirm('You have unsaved changes that will be lost. Discard them and switch files?'); + if (!proceed) { + setSelectedPath(selectedFile.path); + return; + } + } + let cancelled = false; + setError(''); + getFile(selectedPath) + .then((file) => { + if (cancelled) return; + setSelectedFile(file); + setContent(file.content); + }) + .catch((exc) => { + if (!cancelled) setError(exc instanceof Error ? exc.message : 'Could not open file'); + }); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedPath]); + + // Warn before a tab close/reload drops unsaved editor edits. + useEffect(() => { + if (!dirty) return undefined; + const handler = (event: BeforeUnloadEvent) => { + event.preventDefault(); + event.returnValue = ''; + }; + window.addEventListener('beforeunload', handler); + return () => window.removeEventListener('beforeunload', handler); + }, [dirty]); + + useEffect(() => { + if (!quickLook?.path) { + setQuickLookFile(null); + setQuickLookError(''); + return; + } + let cancelled = false; + setQuickLookLoading(true); + setQuickLookError(''); + getFile(quickLook.path) + .then((file) => { + if (!cancelled) setQuickLookFile(file); + }) + .catch((exc) => { + if (!cancelled) setQuickLookError(exc instanceof Error ? exc.message : 'Could not preview file'); + }) + .finally(() => { + if (!cancelled) setQuickLookLoading(false); + }); + return () => { + cancelled = true; + }; + }, [quickLook]); + + useEffect(() => { + function onKeyDown(event: KeyboardEvent) { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') { + event.preventDefault(); + openCommandPalette(); + } + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') { + event.preventDefault(); + void handleSave(); + } + if (event.key === 'Escape') { + setPaletteOpen(false); + setTypographyOpen(false); + closeQuickLook(); + } + } + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }); + + function openCommandPalette() { + setPaletteQuery(''); + setPaletteOpen(true); + } + + function togglePanel(panel: keyof LayoutPanels) { + setLayoutPanels((current) => ({ ...current, [panel]: !current[panel] })); + } + + function applyNavigation(snapshot: NavigationSnapshot) { + setActiveView(snapshot.activeView); + setSelectedPath(snapshot.selectedPath); + setSelectedProjectPath(snapshot.selectedProjectPath); + if (snapshot.activeView === 'overview') setOverviewFocus('overview'); + } + + function navigateTo(snapshot: NavigationSnapshot) { + if (!sameNavigationSnapshot(currentNavigation, snapshot)) { + setNavigationHistory((history) => { + const last = history[history.length - 1]; + const nextHistory = last && sameNavigationSnapshot(last, currentNavigation) + ? history + : [...history, currentNavigation]; + return nextHistory.slice(-50); + }); + } + applyNavigation(snapshot); + } + + function goBack() { + const target = navigationHistory[navigationHistory.length - 1]; + if (!target) return; + setNavigationHistory((history) => history.slice(0, -1)); + applyNavigation(target); + } + + function openView(view: AppView) { + navigateTo({ activeView: view, selectedPath, selectedProjectPath }); + } + + function openEntry(path: string) { + navigateTo({ activeView: 'editor', selectedPath: path, selectedProjectPath }); + const entry = entries.find((candidate) => candidate.path === path); + if (!entry || isTask(entry) || path.startsWith('tasks/')) setEditorMode('preview'); + } + + function openProject(path: string) { + navigateTo({ activeView: 'project-detail', selectedPath, selectedProjectPath: path }); + } + + function openQuickLook(path: string) { + setQuickLook({ path }); + } + + function closeQuickLook() { + setQuickLook(null); + } - const openWikiTarget = useCallback((href: string) => { - const raw = href.slice('vault:'.length).split('#', 1)[0]; - const entry = matchingEntry(entries, raw); - if (entry) void openEntry(entry); - else setMessage(`Linked document “${decodeURIComponent(raw)}” is not available in this vault.`); - }, [entries, openEntry]); + function openQuickLookInEditor() { + if (!quickLook?.path) return; + openEntry(quickLook.path); + closeQuickLook(); + } - const save = async () => { - if (!selected || source === selected.content) return; + async function handleSave() { + if (!selectedFile || !dirty) return; setSaving(true); - setMessage(''); + setError(''); try { - const saved = await saveFile(selected, source); - setSelected(saved); - setSource(saved.content); - await refresh(); - setMessage('Saved'); - } catch (error) { - setMessage(errorMessage(error)); + const status = visibleStatusFromContent(content, selectedFile.frontmatter.status); + const moveTo = taskLifecycleMovePath(selectedFile.path, status); + const contentToSave = LIFECYCLE_CLOSED_STATUSES.has(status.toLowerCase()) && !frontmatterScalar(content, 'completed') + ? setFrontmatterScalar(content, 'completed', todayIso()) + : content; + const saved = await saveFile(selectedFile.path, contentToSave, selectedFile.modified_at, moveTo); + setSelectedPath(saved.path); + setSelectedFile(saved); + setContent(saved.content); + const nextEntries = await reloadVault(); + setEntries(nextEntries); + setDockSyncResult(null); + } catch (exc) { + if (exc instanceof ApiError && exc.status === 409) { + setError(`${exc.message}. Your edits are still in the editor; reload after copying anything you need.`); + } else { + setError(exc instanceof Error ? exc.message : 'Save failed'); + } } finally { setSaving(false); } - }; + } - const changeTaskStatus = async (status: string) => { - if (!selected) return; - setSaving(true); + async function handleReload() { + setError(''); + const nextEntries = await reloadVault(); + setEntries(nextEntries); + setDockSyncResult(null); + if (selectedPath) { + const file = await getFile(selectedPath); + setSelectedFile(file); + setContent(file.content); + } + } + + async function handleCreate(kind: string) { + const title = window.prompt(`Title for new ${kind}`); + if (!title) return; + setError(''); + try { + const created = await createEntry(kind, title); + const nextEntries = await reloadVault(); + setEntries(nextEntries); + setDockSyncResult(null); + setSelectedFile(created); + setContent(created.content); + navigateTo({ activeView: 'editor', selectedPath: created.path, selectedProjectPath }); + if (kind === 'task') setEditorMode('preview'); + } catch (exc) { + setError(exc instanceof Error ? exc.message : 'Create failed'); + } + } + + async function handlePatchTaskMetadata(path: string, updates: TaskMetadataUpdates, currentModifiedAt?: number) { + if (!Object.keys(updates).length) return; + setError(''); + if (selectedFile?.path === path && dirty) { + const nextFrontmatter = applyTaskMetadataUpdates(selectedFile.frontmatter || {}, updates); + const nextContent = replaceFrontmatter(content, nextFrontmatter); + setSelectedFile({ ...selectedFile, frontmatter: nextFrontmatter }); + setContent(nextContent); + setError('Task metadata was applied to the unsaved editor draft. Press Save to write it to Markdown.'); + return; + } + + setPatchingTaskPath(path); try { - const saved = await patchTask(selected, { status }); - setSelected(saved); - setSource(saved.content); - await refresh(); - } catch (error) { - setMessage(errorMessage(error)); + const saved = await patchTaskMetadata(path, updates, currentModifiedAt); + if (selectedFile?.path === path) { + setSelectedPath(saved.path); + setSelectedFile(saved); + setContent(saved.content); + } + const nextEntries = await reloadVault(); + setEntries(nextEntries); + setDockSyncResult(null); + } catch (exc) { + if (exc instanceof ApiError && exc.status === 409) { + setError(`${exc.message}. Reload this task before changing metadata.`); + } else { + setError(exc instanceof Error ? exc.message : 'Task metadata update failed'); + } } finally { - setSaving(false); + setPatchingTaskPath(''); } - }; + } + + async function handlePatchVaultMetadata(path: string, updates: VaultMetadataUpdates, currentModifiedAt?: number) { + if (!Object.keys(updates).length) return; + setError(''); + if (selectedFile?.path === path && dirty) { + const nextFrontmatter = applyMetadataUpdates(selectedFile.frontmatter || {}, updates); + const nextContent = replaceFrontmatter(content, nextFrontmatter); + setSelectedFile({ ...selectedFile, frontmatter: nextFrontmatter }); + setContent(nextContent); + setError('Metadata was applied to the unsaved editor draft. Press Save to write it to Markdown.'); + return; + } + + setPatchingMetadataPath(path); + try { + const saved = await patchVaultMetadata(path, updates, currentModifiedAt); + if (selectedFile?.path === path) { + setSelectedFile(saved); + setContent(saved.content); + } + const nextEntries = await reloadVault(); + setEntries(nextEntries); + setDockSyncResult(null); + } catch (exc) { + if (exc instanceof ApiError && exc.status === 409) { + setError(`${exc.message}. Reload this file before changing metadata.`); + } else { + setError(exc instanceof Error ? exc.message : 'Metadata update failed'); + } + } finally { + setPatchingMetadataPath(''); + } + } + + async function reloadSelectedFile() { + if (!selectedPath) return; + setError(''); + try { + const file = await getFile(selectedPath); + setSelectedFile(file); + setContent(file.content); + } catch (exc) { + setError(exc instanceof Error ? exc.message : 'Could not open file'); + } + } - const create = async () => { - const kind = window.prompt('Create a task, project, event, or note:', 'task')?.trim().toLowerCase(); - if (!kind) return; - const title = window.prompt(`Title for the new ${kind}:`)?.trim(); + async function createLinkedTask() { + const source = selectedEntry; + const title = window.prompt('Title for linked task', source ? `Follow up: ${source.title}` : ''); if (!title) return; + setError(''); try { - const file = await createFile(kind, title); - setSelected(file); - setSource(file.content); - setMode('edit'); - await refresh(); - } catch (error) { - setMessage(errorMessage(error)); + const created = await createEntry('task', title); + const updates: VaultMetadataUpdates = {}; + if (source?.project || source?.id) updates.project = source.project || source.id || ''; + if (source) updates.related_to = `[[${entryIdentity(source)}]]`; + if (Object.keys(updates).length) await patchVaultMetadata(created.path, updates, created.modified_at); + const nextEntries = await reloadVault(); + setEntries(nextEntries); + const linked = await getFile(created.path); + setSelectedFile(linked); + setContent(linked.content); + navigateTo({ activeView: 'editor', selectedPath: linked.path, selectedProjectPath }); + setEditorMode('preview'); + } catch (exc) { + setError(exc instanceof Error ? exc.message : 'Could not create linked task'); } - }; + } - const filtered = useMemo( - () => visibleEntries(entries, view, query), - [entries, view, query], - ); - const selectedEntry = selected ? entries.find((entry) => entry.path === selected.path) : undefined; - const dirty = Boolean(selected && source !== selected.content); + function handleCommand(command: Command) { + if (command.kind === 'entry' && command.payload) openEntry(command.payload); + if (command.kind === 'view' && command.payload) { + openView(command.payload as AppView); + if (command.id === 'open-must-not-slip') setOverviewFocus('must-not-slip'); + } + if (command.kind === 'layout' && command.payload) togglePanel(command.payload as keyof LayoutPanels); + if (command.id === 'reload') void handleReload(); + if (command.id === 'github-sync-status') { + openView('sync'); + setSyncCommandRequest({ id: Date.now(), action: 'status' }); + } + if (command.id === 'github-sync-push-dry-run') { + openView('sync'); + setSyncCommandRequest({ id: Date.now(), action: 'push-dry-run' }); + } + if (command.id === 'toggle-private') setFilters((current) => ({ ...current, privateMode: !current.privateMode })); + if (command.id === 'quick-look-current' && selectedPath) openQuickLook(selectedPath); + if (command.id === 'toggle-context-panel') setContextPanelOpen((current) => !current); + if (command.id === 'open-typography-settings') { + openView('editor'); + setTypographyOpen(true); + } + if (command.kind === 'create' && command.payload) void handleCreate(command.payload); + setPaletteOpen(false); + setPaletteQuery(''); + } - if (authNeeded) { - return ( -
-
{ - event.preventDefault(); - const data = new FormData(event.currentTarget); - setToken(String(data.get('token') || '')); - void refresh(); - }}> -
-

Unlock your task manager

-

Enter the private app token configured on your server. It stays in this browser.

- - - - {message &&

{message}

} -
+ async function handleDockSyncCheck() { + setDockSyncChecking(true); + setError(''); + try { + const nextStatus = await getGitHubSyncStatus(); + setSyncStatus(nextStatus); + if (!nextStatus.enabled || !nextStatus.configured || !nextStatus.token_configured || !nextStatus.git_available) { + setDockSyncResult(null); + return; + } + setDockSyncResult(await pushGitHubSync({ dryRun: true })); + } catch (exc) { + setDockSyncResult(null); + setError(exc instanceof Error ? exc.message : 'Could not check GitHub sync status'); + } finally { + setDockSyncChecking(false); + } + } + + function updateFrontmatter(nextFrontmatter: Record) { + if (!selectedFile) return; + const nextContent = replaceFrontmatter(content, nextFrontmatter); + setContent(nextContent); + setSelectedFile({ ...selectedFile, frontmatter: nextFrontmatter }); + } + + function selectType(lens: string) { + setFilters((current) => ({ ...current, lens, domain: '', collection: '', noteRole: '' })); + openView('library'); + } + + function selectDomain(domain: string) { + setFilters((current) => ({ ...current, lens: 'all', domain, collection: '', noteRole: '' })); + openView('library'); + } + + function selectCollection(collection: string) { + setFilters((current) => ({ ...current, lens: 'all', domain: '', collection, noteRole: '' })); + openView('library'); + } + + function selectNoteRole(noteRole: string) { + setFilters((current) => ({ ...current, lens: 'all', domain: '', collection: '', noteRole })); + openView('library'); + } + + const inspectorVisible = activeView === 'editor' && layoutPanels.inspector && Boolean(selectedPath); + + return ( +
+ {layoutPanels.nav && ( + + )} + {showSideLibrary && ( + + )} +
+ 0} + onGoBack={goBack} + /> + {activeView === 'editor' && selectedPath ? ( + openQuickLook(selectedPath)} + typography={typography} + setTypography={setTypography} + typographyOpen={typographyOpen} + setTypographyOpen={setTypographyOpen} + contextPanelOpen={contextPanelOpen} + onToggleContextPanel={() => setContextPanelOpen((current) => !current)} + onApplyFrontmatter={updateFrontmatter} + onContentChange={setContent} + taskProjects={taskProjects} + entries={workbenchData.visibleEntries} + projectReview={workbenchData.projectReview} + onOpen={openEntry} + onCreateLinkedTask={createLinkedTask} + onOpenView={openView} + onRetryOpen={reloadSelectedFile} + /> + ) : ( + + )}
- ); + {inspectorVisible && ( + + )} + {paletteOpen && ( + setPaletteOpen(false)} + /> + )} + {quickLook && ( + entry.path === quickLook.path) || null} + file={quickLookFile} + loading={quickLookLoading} + error={quickLookError} + onClose={closeQuickLook} + onOpen={openQuickLookInEditor} + /> + )} + {error && needsTokenInput(error) && ( + { + storeToken(tokenDraft); + void loadVault(); + }} + /> + )} + + openView('sync')} + onCreate={handleCreate} + onOpenPalette={openCommandPalette} + onSave={handleSave} + /> +
+ ); +} + +type DateLabelMode = 'full' | 'relative'; + +function dueDateLabel(date: string | null | undefined, mode: DateLabelMode = 'full'): string { + if (!date) return 'no date'; + const days = daysUntil(date); + if (days === null) return date; + if (days === 0) return mode === 'relative' ? 'today' : `${date} · today`; + if (days < 0) { + const relative = `${Math.abs(days)}d overdue`; + return mode === 'relative' ? relative : `${date} · ${relative}`; } + const relative = `in ${days}d`; + return mode === 'relative' ? relative : `${date} · ${relative}`; +} + +function dueLabel(entry: VaultEntry, mode: DateLabelMode = 'full'): string { + return dueDateLabel(entry.due || entry.date || entry.start_date || '', mode); +} +function minutesLabel(minutes: number): string { + if (!minutes) return '0m'; + const hours = Math.floor(minutes / 60); + const rest = minutes % 60; + if (!hours) return `${rest}m`; + return rest ? `${hours}h ${rest}m` : `${hours}h`; +} + +function estimateLabel(entry: VaultEntry): string | null { + const raw = entry.estimate_minutes ?? entry.properties?.estimate_minutes; + const minutes = Number(raw); + if (!Number.isFinite(minutes) || minutes <= 0) return null; + return minutesLabel(minutes); +} + +export function WorkbenchTopbar({ + config = DEFAULT_WORKBENCH_CONFIG, + activeView, + layoutPanels, + selectedEntry, + onTogglePanel, + onOpenPalette, + canGoBack, + onGoBack, +}: { + config?: WorkbenchConfig; + activeView: AppView; + layoutPanels: LayoutPanels; + selectedEntry: VaultEntry | null; + onTogglePanel: (panel: keyof LayoutPanels) => void; + onOpenPalette: () => void; + canGoBack: boolean; + onGoBack: () => void; + }) { return ( -
- - {sidebarOpen &&
+
+ + + + +
+ + ); +} -
-
- -
- - { - setQuery(event.target.value); - if (event.target.value && view === 'overview') { - setSelected(null); - setView('library'); - } - }} - /> -
- + +
+ + + +
+ + )} + {sheet === 'more' && ( +
+
+ More + +
+ {activeView === 'editor' && ( +
+ Editor +
+ + {(['split', 'preview', 'raw'] as const).map((mode) => ( + + ))} +
+
+ )} + {activeEditorTask && ( +
+ Current task +
+ + + + +
+
+ )} +
+ Centers +
+ + + + + + + +
+
+
+ Utilities +
+ + + + +
+

+ {gitStatus?.enabled ? `${gitStatus.branch || 'detached'} · ${gitStatus.changed.length} changed` : 'Git disabled'} +

+
+
+ )} +
+ {primaryItems.map((item) => ( + + ))} +
+ + ); +} + +function projectSlugFromEntry(entry: VaultEntry | null): string { + if (!entry) return ''; + const parts = entry.path.split('/'); + const index = parts.findIndex((part) => part === 'projects' || part === 'areas'); + return index >= 0 ? parts[index + 1] || '' : String(entry.id || entry.project || '').trim(); +} + +function dockProjectLabel(entry: VaultEntry | null): string { + if (!entry) return 'Dashboard'; + return entry.title; +} + +function dockSyncSummary( + status: GitHubSyncStatus | null, + result: GitHubSyncPushResult | null, + checking: boolean, +): { tone: string; label: string; detail: string; title: string } { + if (checking) { + return { tone: 'checking', label: 'Checking', detail: 'dry-run in progress', title: 'Checking hosted vault against GitHub' }; + } + if (!status) { + return { tone: 'unknown', label: 'Sync unknown', detail: 'status not loaded', title: 'Open Sync or refresh the vault to load hosted sync status' }; + } + if (!status.enabled) { + return { tone: 'off', label: 'Sync off', detail: 'not enabled', title: 'GitHub sync is disabled on this server' }; + } + if (!status.configured) { + return { tone: 'attention', label: 'Sync setup', detail: 'repo missing', title: 'GitHub sync is enabled but not fully configured' }; + } + if (!status.token_configured) { + return { tone: 'attention', label: 'Token needed', detail: 'server secret missing', title: 'PM_GITHUB_TOKEN is not configured on the server' }; + } + if (!status.git_available) { + return { tone: 'attention', label: 'Git missing', detail: 'server cannot sync', title: 'The server cannot find git' }; + } + if (status.errors?.length) { + return { tone: 'attention', label: 'Sync attention', detail: status.errors[0], title: status.errors.join(' ') }; + } + if (result) { + const pending = result.changed.length + result.missing_from_vault.length; + if (pending === 0) { + return { + tone: 'synced', + label: 'Synced', + detail: `${shortSha(status.remote_head)} · ${status.vault_file_count} files`, + title: 'Last dry-run found no hosted vault changes to push', + }; + } + return { + tone: 'pending', + label: `${pending} pending`, + detail: `${result.changed.length} changed · ${result.missing_from_vault.length} remote extras`, + title: 'Last dry-run found hosted vault differences. Open Sync to review and push.', + }; + } + return { + tone: 'ready', + label: 'Sync ready', + detail: `${shortSha(status.remote_head)} · check changes`, + title: 'Run a dry-run check from the dock, or open Sync for full controls', + }; +} + +export function WorkDock({ + config = DEFAULT_WORKBENCH_CONFIG, + activeView, + data, + overviewData, + selectedEntry, + selectedProjectPath, + dirty, + saving, + filters, + setFilters, + syncStatus, + syncResult, + syncChecking, + onCheckSync, + onOpenSync, + onCreate, + onOpenPalette, + onSave, +}: { + config?: WorkbenchConfig; + activeView: AppView; + data: WorkbenchData; + overviewData: OverviewData; + selectedEntry: VaultEntry | null; + selectedProjectPath: string; + dirty: boolean; + saving: boolean; + filters: EntryFilters; + setFilters: (next: EntryFilters | ((current: EntryFilters) => EntryFilters)) => void; + syncStatus: GitHubSyncStatus | null; + syncResult: GitHubSyncPushResult | null; + syncChecking: boolean; + onCheckSync: () => Promise | void; + onOpenSync: () => void; + onCreate: (kind: string) => void; + onOpenPalette: () => void; + onSave: () => void; +}) { + const [newMenuOpen, setNewMenuOpen] = useState(false); + const syncSummary = dockSyncSummary(syncStatus, syncResult, syncChecking); + const selectedProject = + activeView === 'project-detail' + ? data.projectReview.find((item) => item.project.path === selectedProjectPath)?.project || null + : selectedEntry && !isProject(selectedEntry) + ? data.projectReview.find((item) => belongsToProjectEntry(selectedEntry, item.project))?.project || null + : isProject(selectedEntry) + ? selectedEntry + : null; + const projectTasks = selectedProject ? projectOpenTasks(selectedProject, data.visibleEntries).filter((task) => !isCoauthorAssignedTask(task)) : []; + const contextTitle = + activeView === 'editor' && selectedEntry ? selectedEntry.title : + selectedProject ? dockProjectLabel(selectedProject) : + viewLabel(config, activeView, viewLabels[activeView] || 'Dashboard'); + const contextMeta = + activeView === 'editor' && selectedEntry ? `${entryLens(selectedEntry)} · ${selectedEntry.path}` : + selectedProject ? `${projectTasks.length} open · ${selectedProject.status || 'project'}` : + `${overviewData.openTasks.length} open tasks`; + const canSave = activeView === 'editor' && dirty; + + function createFromDock(kind: string) { + setNewMenuOpen(false); + onCreate(kind); + } + + return ( + + ); +} + +function WorkbenchView({ + activeView, + data, + entries, + overviewData, + scholar, + overviewFocus, + loading, + error, + privateMode, + filters, + setFilters, + taskProjects, + typeGroups, + domainGroups, + collectionGroups, + entryProjectGroups, + noteRoleGroups, + onOpen, + onOpenProject, + onCreate, + onOpenPalette, + onOpenView, + syncCommandRequest, + onReloadVault, + selectedProjectPath, + onPatchTaskMetadata, + patchingTaskPath, + onQuickLook, + onSelectType, + onSelectDomain, + onSelectCollection, + onSelectNoteRole, +}: { + activeView: AppView; + data: WorkbenchData; + entries: VaultEntry[]; + overviewData: OverviewData; + scholar: ScholarStats | null; + overviewFocus: string; + loading: boolean; + error: string; + privateMode: boolean; + filters: EntryFilters; + setFilters: (next: EntryFilters | ((current: EntryFilters) => EntryFilters)) => void; + taskProjects: string[]; + typeGroups: Array<{ lens: string; count: number }>; + domainGroups: Array<{ lens: string; count: number }>; + collectionGroups: Array<{ lens: string; count: number }>; + entryProjectGroups: Array<{ lens: string; count: number }>; + noteRoleGroups: Array<{ lens: string; count: number }>; + onOpen: (path: string) => void; + onOpenProject: (path: string) => void; + onCreate: (kind: string) => void; + onOpenPalette: () => void; + onOpenView: (view: AppView) => void; + syncCommandRequest: SyncCommandRequest; + onReloadVault: () => Promise | void; + selectedProjectPath: string; + onPatchTaskMetadata: (path: string, updates: TaskMetadataUpdates, currentModifiedAt?: number) => Promise | void; + patchingTaskPath: string; + onQuickLook: (path: string) => void; + onSelectType: (lens: string) => void; + onSelectDomain: (domain: string) => void; + onSelectCollection: (collection: string) => void; + onSelectNoteRole: (noteRole: string) => void; +}) { + if (activeView === 'open-tasks') { + return ( + + ); + } + if (activeView === 'codex-backlog') { + return ; + } + if (activeView === 'admin-center') { + return ( + + ); + } + if (activeView === 'performance-evaluation') { + return ( + + ); + } + if (activeView === 'travel-center') { + return ( + + ); + } + if (activeView === 'ra-management') { + return ( + + ); + } + if (activeView === 'health-review') { + return ( + + ); + } + if (activeView === 'sync') { + return ; + } + if (activeView === 'projects') { + return ; + } + if (activeView === 'project-detail') { + return ( + + ); + } + if (activeView === 'types') { + return ; + } + if (activeView === 'calendar') { + return ; + } + if (activeView === 'time-plan') { + return ( + }> + + + ); + } + if (activeView === 'recent') { + return ; + } + if (activeView === 'library') { + return ( + + ); + } + return ( + + ); +} + +export function GitHubSyncView({ + commandRequest, + onReloadVault, +}: { + commandRequest?: SyncCommandRequest; + onReloadVault?: () => Promise | void; +}) { + const [status, setStatus] = useState(null); + const [diagnostics, setDiagnostics] = useState(null); + const [lastResult, setLastResult] = useState(null); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const [action, setAction] = useState(''); + const [pushDryRunReady, setPushDryRunReady] = useState(false); + const [resetDryRunReady, setResetDryRunReady] = useState(false); + const [deleteMissing, setDeleteMissing] = useState(false); + const [deleteExtra, setDeleteExtra] = useState(false); + const [commitMessage, setCommitMessage] = useState(`Sync hosted vault ${todayIso()}`); + + const busy = loading || Boolean(action); + const unavailable = !status?.enabled || !status?.configured || !status?.token_configured || !status?.git_available; + const statusMeta = status + ? `${status.remote || status.repo || 'remote not set'} · ${status.branch || 'branch not set'} · ${status.vault_file_count} vault files` + : 'Hosted GitHub status has not loaded yet.'; + + const refreshStatus = useCallback(async () => { + setLoading(true); + setError(''); + try { + const [nextStatus, nextDiagnostics] = await Promise.all([ + getGitHubSyncStatus(), + getDiagnostics(), + ]); + setStatus(nextStatus); + setDiagnostics(nextDiagnostics); + } catch (exc) { + setError(exc instanceof Error ? exc.message : 'Could not load GitHub sync status'); + } finally { + setLoading(false); + } + }, []); + + const runPush = useCallback(async (dryRun: boolean) => { + setAction(dryRun ? 'push-dry-run' : 'push'); + setError(''); + try { + const result = await pushGitHubSync({ + commitMessage, + dryRun, + deleteMissing, + }); + setLastResult({ kind: 'push', result }); + if (dryRun && result.ok) setPushDryRunReady(true); + if (!dryRun) { + setPushDryRunReady(false); + await refreshStatus(); + } + } catch (exc) { + setError(exc instanceof Error ? exc.message : dryRun ? 'Push dry-run failed' : 'Push failed'); + } finally { + setAction(''); + } + }, [commitMessage, deleteMissing, refreshStatus]); + + const runReset = useCallback(async (dryRun: boolean) => { + setAction(dryRun ? 'pull-dry-run' : 'pull'); + setError(''); + try { + const result = await resetGitHubSync({ + dryRun, + backup: true, + deleteExtra, + }); + setLastResult({ kind: 'reset', result }); + if (dryRun && result.ok) setResetDryRunReady(true); + if (!dryRun) { + setResetDryRunReady(false); + await onReloadVault?.(); + await refreshStatus(); + } + } catch (exc) { + setError(exc instanceof Error ? exc.message : dryRun ? 'Pull dry-run failed' : 'Pull from GitHub failed'); + } finally { + setAction(''); + } + }, [deleteExtra, onReloadVault, refreshStatus]); + + useEffect(() => { + void refreshStatus(); + }, [refreshStatus]); + + useEffect(() => { + if (!commandRequest) return; + if (commandRequest.action === 'status') void refreshStatus(); + if (commandRequest.action === 'push-dry-run') void runPush(true); + }, [commandRequest?.id]); + + return ( +
+ void refreshStatus()} disabled={busy}> + {loading ? 'Refreshing' : 'Refresh'} + + )} + /> + + {error &&
{error}
} + {status?.errors?.length ? ( +
+ + {status.errors.join(' ')} +
+ ) : null} + +
+ + + + + + + +
+ +
+ + +
+ } /> +

+ Preview hosted Markdown/vault-owned changes before creating a GitHub commit. Private folders and generated dashboard/data files stay excluded. +

+ +
+ + +
+

+ Real push is enabled only after a successful push dry-run in this browser session. +

+
+ +
+ } /> +

+ Preview remote differences first. A real pull writes a backup under `.backups/` before copying remote files into the hosted vault. +

+
+ + +
+

+ Real pull is enabled only after a successful pull dry-run in this browser session. +

+
+ +
+ } /> +
+
Repo
{status?.repo || '-'}
+
Branch
{status?.branch || '-'}
+
Remote
{status?.remote || '-'}
+
Vault
{status?.vault_root || '-'}
+
Author
{status ? `${status.author_name} <${status.author_email}>` : '-'}
+
+
+ Advanced deletion options + + +
+
+ + + +
+ } /> + {lastResult ? :

Run a dry-run to inspect what would change.

} +
+
+
+ ); +} + +function DiagnosticsPanel({ diagnostics, status }: { diagnostics: AppDiagnostics | null; status: GitHubSyncStatus | null }) { + const cache = diagnostics?.generated_cache; + const latestAction = diagnostics?.actions.latest || status?.actions?.latest || null; + const staleFiles = cache?.files.filter((file) => !file.fresh) || []; + const hosted = diagnostics?.hosted_service; + const hostedLabel = hosted ? (hosted.configured ? (hosted.ok ? 'online' : 'down') : 'not configured') : '-'; + const seed = diagnostics?.seed; + const seedLabel = seed ? (seed.exists ? (seed.error ? 'manifest error' : seed.mode || 'seeded') : 'no manifest') : '-'; + return ( +
+ } /> +
+ + + + + + + + + +
+
+
Vault root
{diagnostics?.vault_root || status?.vault_root || '-'}
+
Vault files
{String(diagnostics?.vault_hash.file_count ?? status?.vault_file_count ?? '-')}
+
Last vault scan
{diagnostics?.last_vault_scan_at || '-'}
+
Last Markdown save
{diagnostics?.last_markdown_save_at || '-'}
+
Seed manifest
{seed?.exists ? `${seed.generated_at || 'unknown time'} · ${seed.seed_source || 'unknown source'}` : seed?.path || '-'}
+
Seed commit
{shortSha(seed?.app_commit ?? undefined)}
+
Remote head
{shortSha(diagnostics?.github_sync.remote_head || status?.remote_head)}
+
GitHub branch
{diagnostics?.github_sync.branch || status?.branch || '-'}
+
Latest Actions SHA
{shortSha(latestAction?.head_sha)}
+
Hosted URL
{hosted?.url || '-'}
+
Hosted status
{hosted?.configured ? `${hosted.status_code || 'no HTTP status'} · ${hosted.error || (hosted.ok ? 'ok' : 'not ok')}` : hosted?.error || '-'}
+
Hosted last success
{hosted?.last_success_at || '-'}
+
+ {staleFiles.length ? ( + file.path)} /> + ) : null} + {diagnostics?.github_sync.errors.length ?

{diagnostics.github_sync.errors.join(' ')}

: null} + {diagnostics?.actions.errors.length ?

{diagnostics.actions.errors.join(' ')}

: null} + {seed?.error ?

{seed.error}

: null} +
+ ); +} + +function GitHubActionsPanel({ status }: { status: GitHubSyncStatus | null }) { + const actions = status?.actions; + const latest = actions?.latest; + const errors = actions?.errors || []; + const runTitle = latest ? latest.workflow_name || latest.name || 'Latest workflow run' : 'No run loaded'; + const runState = latest ? latest.conclusion || latest.status || 'unknown' : '-'; + const meta = !actions + ? 'status not loaded' + : actions.configured + ? `${actions.branch || 'branch'} · ${actions.runs.length} recent` + : actions.token_configured + ? 'repo not configured' + : 'token missing'; + return ( +
+ } /> + {latest ? ( +
+
+ {runState} + {runTitle} +
+
+
Event
{latest.event || '-'}
+
Head
{shortSha(latest.head_sha)}
+
Updated
{latest.updated_at || '-'}
+
+ {latest.html_url && ( + + Open Actions run + + )} +
+ ) : ( +

No workflow run is available from the server-side GitHub status check.

+ )} + {errors.length ?

{errors.join(' ')}

: null} +
+ ); +} + +function GitHubSyncResultView({ result }: { result: GitHubSyncResult }) { + if (result.kind === 'push') { + const push = result.result; + return ( +
+
+ + + + +
+ {push.head &&

Pushed commit `{shortSha(push.head)}`.

} + `${change.status || '?'} ${change.path}`)} /> + +
+ ); + } + const reset = result.result; + return ( +
+
+ + + + +
+ {reset.backup &&

Backup written to `{reset.backup}`.

} + + + +
+ ); +} + +function SyncFileList({ title, values }: { title: string; values: string[] }) { + if (!values.length) return null; + return ( +
+ {title} · {values.length} +
+ {values.slice(0, 24).map((value) => {value})} + {values.length > 24 && +{values.length - 24} more} +
+
+ ); +} + +function boolLabel(value: boolean | undefined): string { + if (value === undefined) return '-'; + return value ? 'yes' : 'no'; +} + +function actionsLabel(status: GitHubSyncStatus | null): string { + const actions = status?.actions; + if (!actions) return '-'; + if (!actions.configured) return actions.token_configured ? 'not configured' : 'token missing'; + const latest = actions.latest; + if (!latest) return actions.errors.length ? 'error' : 'no run'; + return latest.conclusion || latest.status || 'unknown'; +} + +function ciTone(value: string): string { + const normalized = value.toLowerCase(); + if (normalized === 'success') return 'success'; + if (['failure', 'cancelled', 'timed_out', 'action_required'].includes(normalized)) return 'failure'; + if (['queued', 'requested', 'waiting', 'in_progress', 'pending'].includes(normalized)) return 'running'; + return 'unknown'; +} + +function shortSha(value: string | undefined): string { + return value ? value.slice(0, 8) : '-'; +} + +function resultTitle(result: GitHubSyncResult): string { + if (result.kind === 'push') return result.result.dry_run ? 'push dry-run' : result.result.pushed ? 'pushed' : 'push result'; + return result.result.dry_run ? 'pull dry-run' : 'pull result'; +} + +function ViewHeader({ eyebrow, title, meta, actions }: { eyebrow: string; title: string; meta: string; actions?: ReactNode }) { + return ( +
+
+ {eyebrow} +

{title}

+

{meta}

+
+ {actions &&
{actions}
} +
+ ); +} + +const CODEX_LANE_TONE: Record = { + queued: { zone: 'zone-blue', icon: }, + blocked: { zone: 'zone-danger', icon: }, + processed: { zone: 'zone-good', icon: }, + cancelled: { zone: 'zone-neutral', icon: }, +}; + +export function CodexBacklogView({ data, onOpen }: { data: CodexBacklogData; onOpen: (path: string) => void }) { + return ( +
+ +
+ + + + +
+

+ These are manual, inspectable task-local requests. Sync moves the Markdown; it does not automatically run queued instructions. +

+
+ {data.lanes.map((lane) => { + const tone = CODEX_LANE_TONE[lane.status] ?? { zone: 'zone-neutral', icon: }; + return ( +
+ + {lane.items.length === 0 ? ( +

No {lane.label.toLowerCase()} instructions.

+ ) : ( +
+ {lane.items.map((item) => ( + + ))} +
+ )} +
+ ); + })} +
+
+ ); +} + +function PanelLoading({ label }: { label: string }) { + return ( +
+ +

{label}

+
+ ); +} + +export function Overview({ + data, + travelStrip = [], + adminCenter, + performanceEvaluation, + scholar, + focus, + loading, + error, + privateMode, + onOpen, + onOpenProject, + onCreate, + onOpenPalette, + onOpenView, + onPatchTaskMetadata, + patchingTaskPath, +}: { + data: OverviewData; + travelStrip?: VaultEntry[]; + adminCenter: AdminCenterData; + performanceEvaluation: PerformanceEvaluationData; + scholar: ScholarStats | null; + focus: string; + loading: boolean; + error: string; + privateMode: boolean; + onOpen: (path: string) => void; + onOpenProject: (path: string) => void; + onCreate: (kind: string) => void; + onOpenPalette: () => void; + onOpenView: (view: AppView) => void; + onPatchTaskMetadata: (path: string, updates: TaskMetadataUpdates, currentModifiedAt?: number) => Promise | void; + patchingTaskPath: string; +}) { + useEffect(() => { + if (focus === 'overview') return; + document.getElementById(focus)?.scrollIntoView({ block: 'start', behavior: 'smooth' }); + }, [focus]); + + const researchTasks = data.taskGroups.find((group) => group.key === 'research'); + const otherTasks = data.taskGroups.find((group) => group.key === 'other'); + const totalWaiting = data.taskGroups.reduce((sum, group) => sum + group.waiting.length, 0); + const hasOutstandingSubmissions = monitoredSubmissions(data.submissions).length > 0; + const hasOutstandingReferee = outstandingRefereeReports(data.refereeReports).length > 0; + const hasSideColumn = data.upcomingDeadlines.length > 0 || hasOutstandingSubmissions || hasOutstandingReferee || Boolean(scholar?.available); + const focusPaths = new Set(data.mustNotSlip.map((entry) => entry.path)); + const hardDeadlines = data.openTasks + .filter((entry) => !focusPaths.has(entry.path) && isHardDeadline(entry) && !isConferenceSubmissionDeadline(entry)) + .sort((a, b) => { + const dueDiff = (daysUntil(a.due || a.date || a.start_date) ?? 9999) - (daysUntil(b.due || b.date || b.start_date) ?? 9999); + if (dueDiff !== 0) return dueDiff; + return a.title.localeCompare(b.title); + }); + + return ( +
+
+
+ Triage desk +

Today

+

+ {loading ? 'Loading vault...' : `${researchTasks?.totalCount || 0} research · ${otherTasks?.totalCount || 0} other · ${data.coauthorTasks.length} coauthor · ${totalWaiting} waiting`} + {privateMode ? ` · ${privateHiddenLabel}` : ` · ${privateVisibleLabel}`} +

+ {error &&

{error}

} +
+
+ + + +
+
+ + + + + + {travelStrip.length > 0 && ( + + )} + + + +
+
+ + + + + +
+ + {hasSideColumn && ( +
+ + + + +
+ )} +
+
+ ); +} + +export function activityDayCountForWidth(width: number, totalDays: number): number { + if (totalDays <= 0) return 0; + if (!Number.isFinite(width) || width <= 0) return Math.min(totalDays, 56); + const cellWidth = 4; + const cellGap = 2; + const fit = Math.floor((width + cellGap) / (cellWidth + cellGap)) - 1; + const minimum = Math.min(totalDays, 14); + return Math.max(minimum, Math.min(totalDays, fit)); +} + +function OverviewDecisionStrip({ + data, + adminCenter, + hardDeadlines, + onOpen, + onOpenView, +}: { + data: OverviewData; + adminCenter: AdminCenterData; + hardDeadlines: VaultEntry[]; + onOpen: (path: string) => void; + onOpenView: (view: AppView) => void; +}) { + const nextTrip = data.travelSummary.nextTrip; + const nextHard = hardDeadlines[0]; + const researchCount = data.taskGroups.find((group) => group.key === 'research')?.totalCount || 0; + const otherCount = data.taskGroups.find((group) => group.key === 'other')?.totalCount || 0; + return ( +
+
+ +
+

Jump to

+ Planning shortcuts across travel, calendar, time, and admin +
+
+
+ } + label="Travel" + title={nextTrip ? nextTrip.title : 'No upcoming trip'} + detail={nextTrip ? `${travelDateLabel(nextTrip)} · ${data.travelSummary.approvalNeeded} approvals` : 'Travel planning is clear'} + onClick={() => onOpenView('travel-center')} + /> + } + label="Calendar" + title={nextHard ? nextHard.title : 'No hard deadline queued'} + detail={nextHard ? `${dueLabel(nextHard, 'relative')} · ${hardDeadlines.length} outside focus` : `${data.upcomingDates.length} dated items`} + onClick={() => nextHard ? onOpen(nextHard.path) : onOpenView('calendar')} + /> + } + label="Time Plan" + title={`${researchCount} research · ${otherCount} admin/other`} + detail={`${data.taskGroups.reduce((sum, group) => sum + group.waiting.length, 0)} waiting · build allocation`} + onClick={() => onOpenView('time-plan')} + /> + } + label="Admin risk" + title={`${adminCenter.metrics.urgent} urgent · ${adminCenter.metrics.waiting} waiting`} + detail={`${data.openTasks.length} open tasks · ${data.projectReview.length} projects`} + onClick={() => onOpenView('admin-center')} + /> +
+
+ ); +} + +function OverviewDecisionButton({ + tone, + icon, + label, + title, + detail, + onClick, +}: { + tone: 'travel' | 'deadline' | 'time' | 'admin'; + icon: ReactNode; + label: string; + title: string; + detail: string; + onClick: () => void; +}) { + return ( + + ); +} + +type OverviewWorkQueueTab = 'research' | 'other' | 'waiting' | 'coauthor' | 'later'; + +function OverviewWorkQueue({ + data, + focusPaths, + onOpen, + onOpenProject, + onOpenView, + onPatch, + patchingTaskPath, +}: { + data: OverviewData; + focusPaths: Set; + onOpen: (path: string) => void; + onOpenProject: (path: string) => void; + onOpenView: (view: AppView) => void; + onPatch: (path: string, updates: TaskMetadataUpdates, currentModifiedAt?: number) => Promise | void; + patchingTaskPath: string; +}) { + const [activeTab, setActiveTab] = useState('research'); + const researchGroup = data.taskGroups.find((group) => group.key === 'research'); + const otherGroup = data.taskGroups.find((group) => group.key === 'other'); + const tabEntries = (group?: OverviewTaskGroup) => uniqueEntries([...(group?.now || []), ...(group?.next || [])]).filter((entry) => !focusPaths.has(entry.path)); + const waitingEntries = uniqueEntries(data.taskGroups.flatMap((group) => group.waiting)).filter((entry) => !focusPaths.has(entry.path)); + const researchAnchor = tabEntries(researchGroup)[0] || researchGroup?.waiting.find((entry) => !focusPaths.has(entry.path)) || null; + const hardDeadlineEntries = data.openTasks + .filter((entry) => !focusPaths.has(entry.path) && isHardDeadline(entry)) + .sort((a, b) => (daysUntil(a.due || a.date || a.start_date) ?? 9999) - (daysUntil(b.due || b.date || b.start_date) ?? 9999)); + const adminBottleneck = (otherGroup?.waiting.find((entry) => !focusPaths.has(entry.path)) || tabEntries(otherGroup)[0] || null); + const coauthorWaiting = data.coauthorTasks.find((entry) => !focusPaths.has(entry.path)) || waitingEntries[0] || null; + const laterEntries = uniqueEntries(data.upcomingDates.filter((entry) => isTask(entry))).filter((entry) => !focusPaths.has(entry.path)); + const tabs: Array<{ key: OverviewWorkQueueTab; label: string; detail: string; entries: VaultEntry[]; total: number }> = [ + { key: 'research', label: 'Research', detail: 'Papers, models, slides, writing', entries: tabEntries(researchGroup), total: researchGroup?.totalCount || 0 }, + { key: 'other', label: 'Admin', detail: 'Admin, travel-adjacent, personal/process work', entries: tabEntries(otherGroup), total: otherGroup?.totalCount || 0 }, + { key: 'waiting', label: 'Waiting', detail: 'Blocked or externally owned', entries: waitingEntries, total: waitingEntries.length }, + { key: 'coauthor', label: 'Coauthor', detail: 'Assigned out or waiting on collaborators', entries: data.coauthorTasks.filter((entry) => !focusPaths.has(entry.path)), total: data.coauthorTasks.length }, + { key: 'later', label: 'Later', detail: 'Dated items not already surfaced', entries: laterEntries, total: laterEntries.length }, + ]; + const current = tabs.find((tab) => tab.key === activeTab) || tabs[0]; + const visibleEntries = current.entries.slice(0, 6); + const hiddenCount = Math.max(0, current.total - visibleEntries.length); + return ( +
+
+ +
+

Work Queue

+ One queue, filtered by what kind of work it is. +
+ +
+
+ + + + !focusPaths.has(entry.path)).length} + entry={coauthorWaiting} + empty="No external wait" + onOpen={onOpen} + /> +
+
+ +
+ Queue tabs + Research, admin, waiting, coauthor, and later work +
+ + {data.openTasks.length} + +
+
+ {tabs.map((tab) => ( + + ))} +
+
+
+
+

{current.label}

+ {current.detail} +
+ {current.total} +
+
+ {visibleEntries.length === 0 ?

No items in this queue.

: visibleEntries.map((entry) => ( + + ))} + {hiddenCount > 0 && ( + + )} +
+
+
+ +
+

Project review

+ Projects needing attention +
+ + {data.triage.review.length} + +
+
+ {data.triage.review.length === 0 ?

No project review flags.

: data.triage.review.map((item) => ( + + ))} +
+
+
+
+ ); +} + +function OverviewWorkSummaryButton({ + label, + count, + entry, + empty, + onOpen, +}: { + label: string; + count: number; + entry: VaultEntry | null; + empty: string; + onOpen: (path: string) => void; +}) { + return ( + + ); +} + +function OverviewActivityHeatmap({ + data, + recentEntries = [], + onOpen, +}: { + data: OverviewData['activityHeatmap']; + recentEntries?: VaultEntry[]; + onOpen?: (path: string) => void; +}) { + const heatmapRef = useRef(null); + const [visibleDayCount, setVisibleDayCount] = useState(() => Math.min(data.days.length, 56)); + + useEffect(() => { + const node = heatmapRef.current; + if (!node) return; + + const update = (width: number) => { + setVisibleDayCount(activityDayCountForWidth(width, data.days.length)); + }; + + update(node.clientWidth); + if (typeof ResizeObserver === 'undefined') return; + + const observer = new ResizeObserver((entries) => { + update(entries[0]?.contentRect.width || node.clientWidth); + }); + observer.observe(node); + return () => observer.disconnect(); + }, [data.days.length]); + + const visibleDays = data.days.slice(-visibleDayCount); + const mostRecent = recentEntries[0]; + return ( +
+
+ + + {data.totalCompleted} cleared · {data.totalEdits} edited · {data.activeDays} active days + {mostRecent && ( + <> + {' · '} + + + )} + +
+
+
+ {visibleDays.map((day) => ( + + ))} +
+
+
+ ); +} + +function moneyLabel(value: number, currency = 'USD'): string { + if (!value) return '$0'; + return new Intl.NumberFormat(undefined, { + style: 'currency', + currency, + maximumFractionDigits: value >= 100 ? 0 : 2, + }).format(value); +} + +function percentLabel(value: number): string { + if (!Number.isFinite(value)) return '0%'; + return `${value >= 10 ? value.toFixed(0) : value.toFixed(1)}%`; +} + +function travelDateLabel(trip: Pick): string { + if (!trip.startDate) return 'TBD'; + const base = trip.endDate && trip.endDate !== trip.startDate ? `${trip.startDate} to ${trip.endDate}` : trip.startDate; + const days = daysUntil(trip.startDate); + if (days === null) return base; + if (days === 0) return `${base} · today`; + if (days < 0) return `${base} · ${Math.abs(days)}d ago`; + return `${base} · in ${days}d`; +} + +function OverviewTravelCenterJump({ + summary, + trackedCount, + onOpenView, + onOpen, +}: { + summary: OverviewData['travelSummary']; + trackedCount: number; + onOpenView: (view: AppView) => void; + onOpen: (path: string) => void; +}) { + const nextTrip = summary.nextTrip; + return ( +
+ + {summary.urgentBlocker && ( + + )} +
+ ); +} + +function OverviewCoauthorSection({ + entries, + totalCount, + onOpen, + onPatch, + patchingTaskPath, +}: { + entries: VaultEntry[]; + totalCount: number; + onOpen: (path: string) => void; + onPatch: (path: string, updates: TaskMetadataUpdates, currentModifiedAt?: number) => Promise | void; + patchingTaskPath: string; +}) { + return ( +
+ +
+

Coauthors

+ Assigned out or waiting on collaborators +
+ + {totalCount} + +
+
+ {entries.length === 0 ?

No tasks are assigned to coauthors.

: entries.map((entry) => ( + + ))} +
+
+ ); +} + +function OverviewScholarCard({ scholar }: { scholar: ScholarStats | null }) { + if (!scholar || !scholar.available) return null; + const metrics = scholar.metrics || {}; + const citations = metrics.citations?.all ?? 0; + const hIndex = metrics.h_index?.all ?? 0; + const i10 = metrics.i10_index?.all ?? 0; + const byYear = Object.entries(scholar.citations_by_year || {}).sort(([a], [b]) => a.localeCompare(b)); + const maxYear = Math.max(1, ...byYear.map(([, value]) => value)); + const topPub = (scholar.top_publications || [])[0]; + return ( +
+
+ Google Scholar + {scholar.profile_url && ( + View profile + )} +
+
+ {citations} citations + {hIndex} h-index + {i10} i10-index +
+ {byYear.length > 0 && ( +
+ {byYear.map(([year, count]) => ( + + ))} +
+ )} + {topPub && ( +
+ Top: {topPub.title}{topPub.year ? ` (${topPub.year})` : ''} · {topPub.citations} +
+ )} +
+ ); +} + +function OverviewDeadlineRail({ entries, onOpen }: { entries: VaultEntry[]; onOpen: (path: string) => void }) { + if (entries.length === 0) return null; + return ( +
+ } /> +
    + {entries.map((entry) => ( +
  • + +
  • + ))} +
+
+ ); +} + +const AVAILABILITY_DOW = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']; +const AVAILABILITY_LABEL: Record = { free: 'Available', busy: 'Busy', away: 'Away' }; + +function availabilityMonthCount(): number { + if (typeof window === 'undefined') return 3; + const width = window.innerWidth; + if (width < 360) return 1; // very narrow phones + if (width < 900) return 2; // phones / tablets / narrow windows + return 3; // wide +} + +function AvailabilityMonth({ year, month, availability, todayKey }: { year: number; month: number; availability: Map; todayKey: string }) { + const first = new Date(year, month, 1); + const monthName = first.toLocaleDateString('en-US', { month: 'long' }); + const firstDow = first.getDay(); + const daysInMonth = new Date(year, month + 1, 0).getDate(); + + const cells: Array = []; + for (let i = 0; i < firstDow; i += 1) cells.push(null); + for (let d = 1; d <= daysInMonth; d += 1) cells.push(new Date(year, month, d)); + + return ( +
+
{monthName}
+
+ {AVAILABILITY_DOW.map((d) => {d})} + {cells.map((date, i) => { + if (!date) return
+
+ ); +} + +function AvailabilityCalendar({ entries }: { entries: VaultEntry[] }) { + const today = useMemo(() => new Date(), []); + const todayKey = ymd(today); + const availability = useMemo(() => buildAvailability(entries), [entries]); + const [monthOffset, setMonthOffset] = useState(0); + const [monthCount, setMonthCount] = useState(availabilityMonthCount); + useEffect(() => { + const onResize = () => setMonthCount(availabilityMonthCount()); + window.addEventListener('resize', onResize); + return () => window.removeEventListener('resize', onResize); + }, []); + + const months = Array.from({ length: monthCount }, (_, i) => new Date(today.getFullYear(), today.getMonth() + monthOffset + i, 1)); + const rangeStart = months[0]; + const rangeEnd = months[months.length - 1]; + const rangeLabel = monthCount === 1 + ? rangeStart.toLocaleDateString('en-US', { month: 'long', year: 'numeric' }) + : rangeStart.getFullYear() === rangeEnd.getFullYear() + ? `${rangeStart.toLocaleDateString('en-US', { month: 'short' })} – ${rangeEnd.toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}` + : `${rangeStart.toLocaleDateString('en-US', { month: 'short', year: 'numeric' })} – ${rangeEnd.toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}`; + + const todayStatus = availabilityForDate(availability, today); + + return ( +
+
+
+ +
+

Availability

+ By travel and commitments — color-coded, three months +
+
+ + +
+ +
+ + {rangeLabel} + +
+ +
+ {months.map((m) => ( + + ))} +
+ +
+ + + +
+
+ ); +} + +function SubmissionRow({ submission, mode, onOpen }: { submission: ConferenceSubmission; mode: 'upcoming' | 'awaiting'; onOpen: (path: string) => void }) { + const conf = conferenceDateLabel(submission); + let toneClass: string; + let rightLabel: string; + let tip: string; + if (mode === 'upcoming') { + toneClass = urgencyTone({ path: submission.path, filename: '', title: submission.title, due: submission.due }); + rightLabel = dueDateLabel(submission.due, 'relative'); + tip = `${submission.title} · submit by ${dueDateLabel(submission.due, 'full')}`; + } else { + // Awaiting a decision: show the expected-decision date if known (with urgency so + // an overdue decision flags a chase-up), otherwise just mark it submitted. + rightLabel = submission.decisionExpected + ? dueDateLabel(submission.decisionExpected, 'relative') + : (submission.submittedOn ? 'submitted' : 'awaiting'); + toneClass = submission.decisionExpected + ? urgencyTone({ path: submission.path, filename: '', title: submission.title, due: submission.decisionExpected }) + : 'awaiting'; + tip = submission.decisionExpected + ? `${submission.title} · decision expected ${submission.decisionExpected}` + : (submission.submittedOn ? `${submission.title} · submitted ${submission.submittedOn}` : submission.title); + } + return ( +
  • + +
  • + ); +} + +function SubmissionsPanel({ + submissions, + onOpen, + wrapperClass = 'overview-zone zone-queue', + title = 'Conference submissions', + detail = 'Upcoming deadlines and submissions awaiting a decision', + showAwaiting = true, +}: { + submissions: ConferenceSubmission[]; + onOpen: (path: string) => void; + wrapperClass?: string; + title?: string; + detail?: string; + showAwaiting?: boolean; +}) { + const upcoming = upcomingSubmissions(submissions); + const awaiting = showAwaiting ? awaitingSubmissions(submissions) : []; + if (upcoming.length === 0 && awaiting.length === 0) return null; + return ( +
    + } /> + {upcoming.length > 0 && ( +
    +

    Upcoming

    +
      + {upcoming.map((submission) => ( + + ))} +
    +
    + )} + {awaiting.length > 0 && ( +
    +

    Awaiting response

    +
      + {awaiting.map((submission) => ( + + ))} +
    +
    + )} +
    + ); +} + +function RefereeReportsPanel({ reports, onOpen }: { reports: RefereeReport[]; onOpen: (path: string) => void }) { + const outstanding = outstandingRefereeReports(reports); + if (outstanding.length === 0) return null; + return ( +
    + } /> +
      + {outstanding.map((report) => ( +
    • + +
    • + ))} +
    +
    + ); +} + +function ProjectPulsePanel({ pulse, onOpen }: { pulse: ProjectPulse[]; onOpen: (path: string) => void }) { + if (pulse.length === 0) return null; + const { featured, rest } = splitProjectPulse(pulse, 3); + return ( +
    + } /> +
    + {featured.map((project) => ( + + ))} +
    + {rest.length > 0 && ( +
    + + +
    + {rest.map((project) => ( + + ))} +
    +
    + )} +
    + ); +} + +function ProjectPulseCard({ pulse, onOpen }: { pulse: ProjectPulse; onOpen: (path: string) => void }) { + const due = pulse.nextDeadline; + const dueTone = due ? urgencyTone({ path: pulse.path, filename: '', title: pulse.name, due: due.date, deadline_type: due.type }) : ''; + const activityBits = [ + `${pulse.openTaskCount} open`, + pulse.coauthorTaskCount > 0 ? `${pulse.coauthorTaskCount} delegated` : '', + pulse.recentCompletionCount > 0 ? `${pulse.recentCompletionCount} closed in 3 weeks` : '', + ].filter(Boolean); + const activityTitle = `Activity — ${activityBits.join(' · ')}`; + return ( +