From 055db690c2da209dfd0d29b8e388c0007abfbd54 Mon Sep 17 00:00:00 2001 From: Simon F Date: Thu, 30 Jul 2026 14:50:02 -0400 Subject: [PATCH 1/2] Generalize app as Research Workbench --- .dockerignore | 5 + .env.example | 7 +- .github/ISSUE_TEMPLATE/bug_report.yml | 52 + .github/ISSUE_TEMPLATE/config.yml | 5 + .github/ISSUE_TEMPLATE/feature_request.yml | 30 + .github/pull_request_template.md | 18 + .github/workflows/ci.yml | 29 +- .gitignore | 16 + CHANGELOG.md | 14 +- Dockerfile | 5 +- LICENSE | 2 +- Makefile | 22 +- README.md | 165 +- compose.yaml | 7 +- docs/ARCHITECTURE.md | 21 + docs/BACKUP_RESTORE.md | 16 + docs/CLI.md | 20 + docs/CONFIGURATION.md | 46 + docs/DOCKER.md | 34 + docs/GITHUB_SYNC.md | 20 + docs/MODULES.md | 25 + docs/PRIVACY.md | 10 +- docs/SELF_HOSTING.md | 76 +- docs/UPGRADING.md | 15 + docs/VAULT_FORMAT.md | 7 +- docs/images/overview.png | Bin 0 -> 151065 bytes example-vault/START_HERE.md | 9 +- .../dates/2026-12-22-example-milestone.md | 10 - example-vault/dates/harbor-flows-milestone.md | 11 + example-vault/dates/methods-summit.md | 15 + .../collaborators/avery-example-assignment.md | 15 + .../collaborators/avery-example-checkin.md | 15 + .../notes/collaborators/avery-example.md | 14 + .../performance/methods-note-achievement.md | 20 + .../notes/performance/research-category.md | 17 + example-vault/notes/performance/review.md | 14 + .../notes/performance/source-check.md | 13 + .../notes/travel/methods-summit-itinerary.md | 17 + example-vault/notes/travel/travel-ledger.md | 35 + example-vault/notes/wellness/weekly-plan.md | 33 + example-vault/projects/harbor-flows/README.md | 28 + .../harbor-flows/notes/design-note.md | 17 + .../projects/research-example/README.md | 23 - .../research-example/notes/design-note.md | 16 - example-vault/settings/scholar-stats.json | 17 + example-vault/settings/time_planning.md | 11 + example-vault/settings/workbench.yml | 43 + .../tasks/active/t-book-methods-summit.md | 19 + .../tasks/active/t-draft-method-note.md | 8 +- .../tasks/active/t-submit-example-form.md | 17 + .../active/t-update-performance-review.md | 17 + .../tasks/active/t-wellness-review.md | 19 + .../tasks/waiting/t-avery-model-table.md | 18 + .../tasks/waiting/t-review-data-checks.md | 10 +- pyproject.toml | 20 +- render.yaml | 21 - requirements.txt | 12 +- scripts/bootstrap_demo.py | 20 +- scripts/calendar_feed.py | 563 + scripts/check_links.py | 84 + scripts/docker_smoke.py | 160 + scripts/generate_weekly_review.py | 209 + scripts/import_strava_archive.py | 298 + scripts/markdown_reader.py | 287 + scripts/pm.py | 570 + scripts/privacy_scan.py | 150 +- scripts/render_boards.py | 5 + scripts/render_calendar.py | 5 + scripts/render_dashboard.py | 5 + scripts/render_date.py | 28 + scripts/render_private_export.py | 5 + scripts/render_static.py | 255 + scripts/render_status_index.py | 5 + scripts/render_today.py | 5 + scripts/report_task_lifecycle_duplicates.py | 99 + scripts/seed_render_vault.py | 425 + scripts/sync_markdown.py | 196 + scripts/time_blocks.py | 796 + scripts/time_plan_context.py | 463 + scripts/validate_vault_schema.py | 342 + scripts/vault_parsing.py | 16 +- scripts/workbench_config.py | 166 + scripts/workbench_paths.py | 28 + server/__init__.py | 2 +- server/app.py | 966 +- server/github_sync.py | 16 +- server/vault.py | 15 +- templates/academic/coauthor_update.md | 32 + templates/academic/code_run_log.md | 47 + templates/academic/data_note.md | 43 + templates/academic/derivation_note.md | 41 + templates/academic/feedback_note.md | 43 + templates/academic/idea_note.md | 32 + templates/academic/literature_note.md | 34 + templates/academic/meeting_note.md | 40 + templates/academic/model_note.md | 63 + templates/academic/paper_status.md | 43 + templates/academic/presentation_plan.md | 37 + templates/academic/result_note.md | 39 + templates/academic/revision_matrix.md | 32 + templates/academic/section_draft_note.md | 38 + templates/academic/technical_issue.md | 36 + templates/daily_note.md | 44 + templates/health/daily_health_log.md | 48 + templates/health/medical_visit.md | 15 + templates/health/symptom_log.md | 9 + templates/health/weekly_health_review.md | 31 + templates/health/workout_log.md | 9 + templates/notes/literature_note.md | 12 + templates/notes/meeting_note.md | 16 + templates/notes/research_note.md | 15 + templates/project/README_template.md | 18 + templates/project/progress_template.md | 7 + templates/ra/ra_status.md | 25 + templates/review/weekly_review.md | 15 + templates/weekly_review.md | 27 + tests/test_bootstrap.py | 7 +- tests/test_calendar_feed.py | 190 + tests/test_check_links.py | 19 + tests/test_codex_instructions.py | 44 + tests/test_distribution.py | 82 + tests/test_github_sync.py | 263 + tests/test_pm_cli.py | 67 + tests/test_privacy_scan.py | 63 +- tests/test_public_export_privacy.py | 23 + tests/test_public_tree.py | 5 +- tests/test_render_static_dashboard.py | 131 + tests/test_scholar_api.py | 101 + tests/test_seed_render_vault.py | 207 + tests/test_server_vault.py | 452 + tests/test_task_lifecycle_consistency.py | 47 + tests/test_task_lifecycle_duplicate_report.py | 41 + tests/test_task_metadata_api.py | 307 + tests/test_time_blocks.py | 595 + tests/test_time_plan_api.py | 411 + tests/test_time_plan_context.py | 88 + tests/test_vault_parsing.py | 64 + tests/test_vault_schema.py | 142 + uv.lock | 959 ++ web/e2e/hosted-app.spec.ts | 142 + web/e2e/workbench.spec.ts | 35 - web/index.html | 18 +- web/package-lock.json | 3239 ++-- web/package.json | 26 +- web/playwright.config.ts | 39 +- web/public/icons/apple-touch-icon.png | Bin 0 -> 6865 bytes web/public/icons/icon-192.png | Bin 0 -> 10333 bytes web/public/icons/icon-512.png | Bin 0 -> 26322 bytes web/public/icons/maskable-512.png | Bin 0 -> 18562 bytes web/public/icons/research-workbench-icon.svg | 11 + web/public/manifest.webmanifest | 30 + web/public/offline.html | 37 + web/public/sw.js | 76 + web/scripts/prepare-playwright-vault.mjs | 47 + web/src/AdminCenterView.test.tsx | 57 + web/src/App.tsx | 9986 ++++++++++- web/src/CalendarView.test.tsx | 133 + web/src/CodexBacklogView.test.tsx | 34 + web/src/EditorPolish.test.tsx | 241 + web/src/GitHubSyncView.test.tsx | 240 + web/src/HealthReviewView.test.tsx | 262 + web/src/MarkdownEditor.tsx | 22 + web/src/MarkdownRenderer.security.test.tsx | 100 + web/src/MarkdownRenderer.tsx | 77 + web/src/MobileBottomBar.test.tsx | 149 + web/src/OpenTasksView.test.tsx | 99 + web/src/Overview.test.tsx | 185 + web/src/OverviewRow.test.tsx | 60 + web/src/PerformanceEvaluationView.test.tsx | 158 + web/src/ProjectReferencePreview.test.tsx | 51 + web/src/ProjectTaskCard.test.tsx | 48 + web/src/RAManagementView.test.tsx | 125 + web/src/Sidebar.test.tsx | 78 + web/src/TaskQuickEdit.test.tsx | 61 + web/src/TaskQuickEdit.tsx | 250 + web/src/TimePlanView.test.tsx | 465 + web/src/TimePlanView.tsx | 895 + web/src/TravelCenterView.test.tsx | 146 + web/src/WorkDock.test.tsx | 248 + web/src/WorkbenchTopbar.test.tsx | 63 + web/src/api.ts | 211 +- web/src/lib/adminCenter.test.ts | 73 + web/src/lib/adminCenter.ts | 161 + web/src/lib/availability.test.ts | 40 + web/src/lib/availability.ts | 90 + web/src/lib/codexInstructions.test.ts | 54 + web/src/lib/codexInstructions.ts | 112 + web/src/lib/commands.ts | 60 + web/src/lib/config.ts | 71 + web/src/lib/deadlines.ts | 38 + web/src/lib/domain.ts | 60 + web/src/lib/filters.test.ts | 95 + web/src/lib/filters.ts | 75 + web/src/lib/frontmatter.test.ts | 40 + web/src/lib/frontmatter.ts | 144 + web/src/lib/healthReview.test.ts | 246 + web/src/lib/healthReview.ts | 574 + web/src/lib/markdownPreview.test.ts | 39 + web/src/lib/markdownPreview.ts | 39 + web/src/lib/overview.test.ts | 404 + web/src/lib/overview.ts | 815 + web/src/lib/performanceEvaluation.test.ts | 183 + web/src/lib/performanceEvaluation.ts | 212 + web/src/lib/projectPulse.test.ts | 104 + web/src/lib/projectPulse.ts | 122 + web/src/lib/raManagement.test.ts | 143 + web/src/lib/raManagement.ts | 298 + web/src/lib/refereeReports.test.ts | 35 + web/src/lib/refereeReports.ts | 70 + web/src/lib/submissions.test.ts | 70 + web/src/lib/submissions.ts | 154 + web/src/lib/travelCenter.test.ts | 251 + web/src/lib/travelCenter.ts | 1048 ++ web/src/lib/visualTaxonomy.ts | 102 + web/src/lib/workbench.test.ts | 235 + web/src/lib/workbench.ts | 616 + web/src/main.tsx | 5 +- web/src/model.ts | 4 +- web/src/serviceWorker.ts | 9 + web/src/styles.css | 14090 +++++++++++++++- web/src/test/setup.ts | 1 + web/src/types.ts | 465 +- web/src/vite-env.d.ts | 1 + web/tsconfig.json | 6 +- web/vite.config.ts | 1 + web/vitest.config.ts | 2 +- 226 files changed, 50160 insertions(+), 2045 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/pull_request_template.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/BACKUP_RESTORE.md create mode 100644 docs/CLI.md create mode 100644 docs/CONFIGURATION.md create mode 100644 docs/DOCKER.md create mode 100644 docs/GITHUB_SYNC.md create mode 100644 docs/MODULES.md create mode 100644 docs/UPGRADING.md create mode 100644 docs/images/overview.png delete mode 100644 example-vault/dates/2026-12-22-example-milestone.md create mode 100644 example-vault/dates/harbor-flows-milestone.md create mode 100644 example-vault/dates/methods-summit.md create mode 100644 example-vault/notes/collaborators/avery-example-assignment.md create mode 100644 example-vault/notes/collaborators/avery-example-checkin.md create mode 100644 example-vault/notes/collaborators/avery-example.md create mode 100644 example-vault/notes/performance/methods-note-achievement.md create mode 100644 example-vault/notes/performance/research-category.md create mode 100644 example-vault/notes/performance/review.md create mode 100644 example-vault/notes/performance/source-check.md create mode 100644 example-vault/notes/travel/methods-summit-itinerary.md create mode 100644 example-vault/notes/travel/travel-ledger.md create mode 100644 example-vault/notes/wellness/weekly-plan.md create mode 100644 example-vault/projects/harbor-flows/README.md create mode 100644 example-vault/projects/harbor-flows/notes/design-note.md delete mode 100644 example-vault/projects/research-example/README.md delete mode 100644 example-vault/projects/research-example/notes/design-note.md create mode 100644 example-vault/settings/scholar-stats.json create mode 100644 example-vault/settings/time_planning.md create mode 100644 example-vault/settings/workbench.yml create mode 100644 example-vault/tasks/active/t-book-methods-summit.md create mode 100644 example-vault/tasks/active/t-submit-example-form.md create mode 100644 example-vault/tasks/active/t-update-performance-review.md create mode 100644 example-vault/tasks/active/t-wellness-review.md create mode 100644 example-vault/tasks/waiting/t-avery-model-table.md delete mode 100644 render.yaml create mode 100644 scripts/calendar_feed.py create mode 100644 scripts/check_links.py create mode 100644 scripts/docker_smoke.py create mode 100755 scripts/generate_weekly_review.py create mode 100644 scripts/import_strava_archive.py create mode 100755 scripts/markdown_reader.py create mode 100755 scripts/pm.py create mode 100644 scripts/render_boards.py create mode 100644 scripts/render_calendar.py create mode 100644 scripts/render_dashboard.py create mode 100644 scripts/render_date.py create mode 100644 scripts/render_private_export.py create mode 100644 scripts/render_static.py create mode 100644 scripts/render_status_index.py create mode 100644 scripts/render_today.py create mode 100644 scripts/report_task_lifecycle_duplicates.py create mode 100755 scripts/seed_render_vault.py create mode 100755 scripts/sync_markdown.py create mode 100644 scripts/time_blocks.py create mode 100644 scripts/time_plan_context.py create mode 100644 scripts/validate_vault_schema.py create mode 100644 scripts/workbench_config.py create mode 100644 scripts/workbench_paths.py create mode 100644 templates/academic/coauthor_update.md create mode 100644 templates/academic/code_run_log.md create mode 100644 templates/academic/data_note.md create mode 100644 templates/academic/derivation_note.md create mode 100644 templates/academic/feedback_note.md create mode 100644 templates/academic/idea_note.md create mode 100644 templates/academic/literature_note.md create mode 100644 templates/academic/meeting_note.md create mode 100644 templates/academic/model_note.md create mode 100644 templates/academic/paper_status.md create mode 100644 templates/academic/presentation_plan.md create mode 100644 templates/academic/result_note.md create mode 100644 templates/academic/revision_matrix.md create mode 100644 templates/academic/section_draft_note.md create mode 100644 templates/academic/technical_issue.md create mode 100644 templates/daily_note.md create mode 100644 templates/health/daily_health_log.md create mode 100644 templates/health/medical_visit.md create mode 100644 templates/health/symptom_log.md create mode 100644 templates/health/weekly_health_review.md create mode 100644 templates/health/workout_log.md create mode 100644 templates/notes/literature_note.md create mode 100644 templates/notes/meeting_note.md create mode 100644 templates/notes/research_note.md create mode 100644 templates/project/README_template.md create mode 100644 templates/project/progress_template.md create mode 100644 templates/ra/ra_status.md create mode 100644 templates/review/weekly_review.md create mode 100644 templates/weekly_review.md create mode 100644 tests/test_calendar_feed.py create mode 100644 tests/test_check_links.py create mode 100644 tests/test_codex_instructions.py create mode 100644 tests/test_distribution.py create mode 100644 tests/test_github_sync.py create mode 100644 tests/test_pm_cli.py create mode 100644 tests/test_public_export_privacy.py create mode 100644 tests/test_render_static_dashboard.py create mode 100644 tests/test_scholar_api.py create mode 100644 tests/test_seed_render_vault.py create mode 100644 tests/test_server_vault.py create mode 100644 tests/test_task_lifecycle_consistency.py create mode 100644 tests/test_task_lifecycle_duplicate_report.py create mode 100644 tests/test_task_metadata_api.py create mode 100644 tests/test_time_blocks.py create mode 100644 tests/test_time_plan_api.py create mode 100644 tests/test_time_plan_context.py create mode 100644 tests/test_vault_parsing.py create mode 100644 tests/test_vault_schema.py create mode 100644 uv.lock create mode 100644 web/e2e/hosted-app.spec.ts delete mode 100644 web/e2e/workbench.spec.ts create mode 100644 web/public/icons/apple-touch-icon.png create mode 100644 web/public/icons/icon-192.png create mode 100644 web/public/icons/icon-512.png create mode 100644 web/public/icons/maskable-512.png create mode 100644 web/public/icons/research-workbench-icon.svg create mode 100644 web/public/manifest.webmanifest create mode 100644 web/public/offline.html create mode 100644 web/public/sw.js create mode 100644 web/scripts/prepare-playwright-vault.mjs create mode 100644 web/src/AdminCenterView.test.tsx create mode 100644 web/src/CalendarView.test.tsx create mode 100644 web/src/CodexBacklogView.test.tsx create mode 100644 web/src/EditorPolish.test.tsx create mode 100644 web/src/GitHubSyncView.test.tsx create mode 100644 web/src/HealthReviewView.test.tsx create mode 100644 web/src/MarkdownEditor.tsx create mode 100644 web/src/MarkdownRenderer.security.test.tsx create mode 100644 web/src/MarkdownRenderer.tsx create mode 100644 web/src/MobileBottomBar.test.tsx create mode 100644 web/src/OpenTasksView.test.tsx create mode 100644 web/src/Overview.test.tsx create mode 100644 web/src/OverviewRow.test.tsx create mode 100644 web/src/PerformanceEvaluationView.test.tsx create mode 100644 web/src/ProjectReferencePreview.test.tsx create mode 100644 web/src/ProjectTaskCard.test.tsx create mode 100644 web/src/RAManagementView.test.tsx create mode 100644 web/src/Sidebar.test.tsx create mode 100644 web/src/TaskQuickEdit.test.tsx create mode 100644 web/src/TaskQuickEdit.tsx create mode 100644 web/src/TimePlanView.test.tsx create mode 100644 web/src/TimePlanView.tsx create mode 100644 web/src/TravelCenterView.test.tsx create mode 100644 web/src/WorkDock.test.tsx create mode 100644 web/src/WorkbenchTopbar.test.tsx create mode 100644 web/src/lib/adminCenter.test.ts create mode 100644 web/src/lib/adminCenter.ts create mode 100644 web/src/lib/availability.test.ts create mode 100644 web/src/lib/availability.ts create mode 100644 web/src/lib/codexInstructions.test.ts create mode 100644 web/src/lib/codexInstructions.ts create mode 100644 web/src/lib/commands.ts create mode 100644 web/src/lib/config.ts create mode 100644 web/src/lib/deadlines.ts create mode 100644 web/src/lib/domain.ts create mode 100644 web/src/lib/filters.test.ts create mode 100644 web/src/lib/filters.ts create mode 100644 web/src/lib/frontmatter.test.ts create mode 100644 web/src/lib/frontmatter.ts create mode 100644 web/src/lib/healthReview.test.ts create mode 100644 web/src/lib/healthReview.ts create mode 100644 web/src/lib/markdownPreview.test.ts create mode 100644 web/src/lib/markdownPreview.ts create mode 100644 web/src/lib/overview.test.ts create mode 100644 web/src/lib/overview.ts create mode 100644 web/src/lib/performanceEvaluation.test.ts create mode 100644 web/src/lib/performanceEvaluation.ts create mode 100644 web/src/lib/projectPulse.test.ts create mode 100644 web/src/lib/projectPulse.ts create mode 100644 web/src/lib/raManagement.test.ts create mode 100644 web/src/lib/raManagement.ts create mode 100644 web/src/lib/refereeReports.test.ts create mode 100644 web/src/lib/refereeReports.ts create mode 100644 web/src/lib/submissions.test.ts create mode 100644 web/src/lib/submissions.ts create mode 100644 web/src/lib/travelCenter.test.ts create mode 100644 web/src/lib/travelCenter.ts create mode 100644 web/src/lib/visualTaxonomy.ts create mode 100644 web/src/lib/workbench.test.ts create mode 100644 web/src/lib/workbench.ts create mode 100644 web/src/serviceWorker.ts create mode 100644 web/src/test/setup.ts create mode 100644 web/src/vite-env.d.ts 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..dedd4c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,19 +13,30 @@ concurrency: cancel-in-progress: true jobs: - test: + application: 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 0000000000000000000000000000000000000000..2157c86c1c3eb2d0a0fbaf442b0f92fe2f284f6d GIT binary patch literal 151065 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 fzzy7gDusP}ki%uK?*-fg6`YuZRB&1(#{G literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..833cb420a9a7dcb30b43fac382123364ee716069 GIT binary patch literal 6865 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 pYOaOy$B-d1$=wxU7Sspq= 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%0DwhWu4+bYO40O2Z`k` z%!3zibH5|udB3&#cj`f#_8VxpH)2;J)-OkPX=$+JIqZnxZSEUR{wqx_&bEJimRx#H z;=`-w5{v8?)A%Sy>nO>LJtiuE0M)DL-8ck;zv{(Xt-t zM!J*2X#dvYPH?aC0A8uo5S}*e-08M%3mZ8;E~t&BXUE~prR}|@uGv|Ia0+{}mp)ZsE0EbA}P zk3;_2`oNAp%Kg2YIo^q=c1902yCD1K1tM);)kCi^Eub}|01d&MzZuDk_tR0iA)ytQ zx6!~d#!=@@a6z+>|RoR zW1=b~ym>`UGE_c=W;gAv1-ZlHQ%1}X9J1E;=1^0p1}hSI1@hl}v-@~4RvOnV7DhE$ zs)X#qk1c?Q>KB50Gzj$jT*GF;%m+3gY=gPJ?yw zD;uvgr6gfo%vaQ$3i(DjIqZCSaCc)i3oayM^Y=ar!6$mQdxsuy1z!%TzAH$#nVsTd z<~hDt5Pxros8!~7en7&}6u_pde-?Y~KeknU+Z=xtMaR@w1qYlt4j&ux6)=h>Q~gIO z1CpXkxt%w-fR^o40?~km-TxNQ(t<#T zWiVs6l4<_?=FADRnwwwQeW%Pt1JQq+xBASEuPZbP#vmeie8YISEdb2R)X*^luwX^H?j6ywb_gr^#Sz6 zpZ()oO~?lnQX+wT6IKnDjuAFpvYUTp-m|kia*||^>=&e}dlM%g5r$0mCny5cQicmf zS#+n>5M?orRFJ*qU*8*(^Ql*v>Es$ktp=sbe&i$>Q~+Z86kunYEdr1H`qZs`=pPLr zDtsY>U?Z3v4NF~HLP%5ht?fjkN+q)6DQbW^e07{s-{Q?PU!JT^S z!AAYKo&3MbZu#Abk5!1dc7(@MZDp>=TBb3@D1JCg42<;Hui&x$iW$d`X_{wu%ztoc zbD{)+Qluh@bNdk>2^h(;K0a{u2?D5pCM!Zc!usPZ!+z7I508pPa94 zL!#^-<-G!UsCDZxQfDySM5w#mPx6@fR1XUb2#+L8RI%&ftVPD6x8n$D< z8-FdW1E>6FkBE|z8j{+N)FlJ@BX#x{S*S1_r&N`@tUKPx7_v5zYleU?O-kiu5eHoe z6cp~_KYxbV2(&sU+z$0@PK$hYV=S^!zQS?23!)i5M!Fhqc3o)yhN-xiyPGr;XP&>; zvsStX6k{LiEyeW4R28*F#YFW2k}u>*(f(m&CPAMn3XwoBH_rQfFu5%O;1 z**t|a#5q0&p!jNDFZ0yh%nQSGw8h zJh=P{E%Xd{NP;T(a_nj%biU7S-%fjvDKGdQjdF4!Y&g&#(v?R0Xr<${UR}>|N|h>y z0Tx9)K64`KA@GGj-PHdsodzrleWDt6npEL1nRhbrYkY9gPzC}4Zbcn@pp}t`ocL9< z0cBYqO5gXyHL1?jl+z^LP2NNj1Mh{Ga}p&&7CAVo9@3r?6E9RB)9afp#!S#ftmIdQ zNjqri)iW=|13l3Bwk;t`YP=(mQ?&3nDZHIGWFvCB^XvR2|Nj0#)iHe)h2%mBo{?sM zq2R3X)D_4P$`u(r$v*z!yfb{%#;np3>V8JA|Ag6rwR9dj;V|V3(YcSGZ#*nQ$UrUd zlz_~ny{1N^O^;o*>!L{Z1!1C;vLO7;91#Luv~>NOOd}PHC3Y+^IeTV@^0Kv#IZ3Ge zk`+Mg8B*p;X8c@m<__ULoOGjMi0>YLdjT7M_z)nHA|lkMfXRq`ZsyJjYm4kr1jD>B zwbV`vUw=@{iUrm;HGspl*tbj_PdWKDYQ}dbbWAS5SUVARaX$#NgT{^qv^&J{t|Jb3Bqc zZrGPgUQ9c~YE$s&KrN$xVd5H1uhebP&LPhK3igMnbVd6e zj70b3Dr_#0AcNV|8n%RLTtcE^y20K2$QEAcs_5MLd-{c7E{*Rin&oI>W~~z#D$OXfK>vxosEhbYgs)@2!OA61VYa zhz8^JzP#U;x+&K7)MPQRS@O^Q(~w-R^hu7+Om^bkSKQ^dZx-Y*Y@bO3L%%a9b$RUt z7e9BVIp|nZMDSoKpXELI3Av^2HMVa24Cx|sowU3_6Xi4f0-u%`+gARsTdA#)iJ%cC zW!S#gszw0lk1~(6<75n!4-xXKU@fP(;+((}W~~G6H_AAWFeddZ;OC;{5Zd9?m$C_3 z;%~lKcLSrqPm3X=+h*8mTL#rSnl;D{2}< zk)DbV{*s#6OHn0h@#oT7#Twcj(41qN3SZtu2;LCA1Qp$k1m#qe7)hfHY?hU|+=heGmH4}Wxd zD${y8eOoG>W_a$~?F$y*^Aq{ioZ6nSV6Z+YW`T#xzL{ghYN83iTW$YDqh5MM9oR{u z-f^DJBl)17m?5JYp%>&y-LQM9YU8!}ror(kHya%n+iR`_kiq7&R+?mw`Vq0UX(W-K z>rB-5@v#b)ZA8&3bCkBqbYb(eJF(noMW`@WKQ-(fN|I+d+l{JgdEv}!#uus9dNeNJ zHF6Q^NsZ_~t{m76yE<_+UyE?e>ftY;nmUT5bz8 z9w_oti0eX%JsCl23)EUA1V2b#ql|xHw+guOhyz#$Gq$Q1H;y-GJi3zcHEgZiT3Q_? z&5LiNuRO_XDPsL3{d5Iq;t0$R6JRVB04Ctf>K!-Tn+fvYRcJRJu&i~M?o{JZ;>r-R z7@h*l$6SK)yIC}})9*#FdC}qW1Q;C+O=Worf;aLojZO_xx--nJ?P#jmdY%G~_rFvl zhESbXjQ;Up9TvlDc^3Cif>ga5c8epTash_^u8|s?lkg!oVNfIDQa1pjq1U?C8onw) zXh3LC_;EdIo^oBTqsO)5`lHSsC5ILrbp{*Dh$y1#7oS&rMh5})THo&u2Li=|JOD{m z^bf2$H-Dp?-jlY^YfAJgnxyKfXAo;PoM0|Nj)U`46lsP*dz^jatOJjDDdjcq;jdcP zsmDUW(2cKHxcEzDaskN>HuMh^L!~Bu|+%U3tq{F6M@bI`7nnv!lTfrQrU# zm5}=S8lG-gW}VWZrt)_5+&OOksAK*fC17T)J+Bz+4dbyE|7a%ILFOPL0=>~O4T015 zDPZoCSFqzQGNwGZ+K#}b0W~Hl()fHj?`UYqU?&$Rr=@%VH4~Q%2}zqfazpMTK`Wgg z6EcrXqMAt^A(r7oTB?np`q9HvI%Al9Dya^hFk+TptCrs1VU_T9vzq3G(w=1VWrM{c zIvOWc-NHEJ1rRTXa1AJ^)@sSzWp)_E=_E93{f?$c)EJHujNyek=RFOiTn6J`rRI*$ zHK(!O3K)n$+ae<4G-iD9j|E3$UEM8!+OY~9(h98a!2l~aF8RE>izvj=IfWvD*PvDh z&cT`bhVx4rC_@J@ra%knoKLHwo;?pIh#-z=KH7`UhAJ28K zIuEKGYD&pP%?!a3RE?8JAi$mHT#JfH?IvWC-eg!E4JROduEs==2=ZeYc$*b`OBL_I z9VNvy*{&Lx)w^hDCZNML`tuxMrZ)(b)*AC{+5` z+8x)SH-owE$NYvPMsc7gKth#!3|_JIa3mS6qp9w!{NmGU6+I|?2XK+-Tz6{btb<&{H^VCV(=Xh7 zd9?bwgxRDZl%|tPVTnV9EwoGEw40%hlw22B_isV_P`8fVIg|2-pdmS7t&^B&S0rhX zFL}YQO98&A`=khTcg`g5bz;<1L2Ad4CzAjt&RbM%@6MmtScSQ_XuH>i48Ot>*`aZe zoPs#8ycf{A;AfQ8Dstx^!4Yu;bU7j(Kh;toZTR{Y?e7kO@GP7-oCpx3*dAs^t_B^yU3z<4C<|<5 z`Y)c+j6XnTtY1_Cj;SAn5m}D7cYmS|v6| zA$@q@P>pu8Lldg57u-w*GOTtCLfQdKpjIW#TbcoDzDoVPQ0V4{kaF^|DZ}RFesx6-pqDBns{CaszUsgNP8^LQWfA#YFW^X3E;i zL93IAi*|GGAd2&fKHeS{8QF)Gw{Me($^mr4<#rp|Ioik90dNzYn^=eB9~@XV2ZxMi zJ(t1K@>S9Z{-+N9{Xf6{-#?j^D04yI>xM{?nO}P`$4?C8kvWt_mo;b4;FMeQQi?@V zpdhVdQ)`S(y1TM7;2||iiM2+2ck)e9dk}Uf2M3_r8@K^Igd%h;E&vWtt<#5pQ3gr@ zV3Cl^kwx=yKM%+Dj)^*;0Cbg4g-N&zFzgHHF?LX_hXI2{{2gQ@Q3y<&{p{(s952Wehj| zkjAp{QsWP~_HmX@-%)O=)89g2-BOI{pla;;b`!~b<0%`5H|A1)c~oF=BOoinOgP#` zoTN-PnI4RLp4K0;NBSxz>CakwZn?~3YU}5yX*INjqs{ohF07~=#Dp=v`+Th~#b@I9 zk7)z;t6G-_KI~d+&|KTn=lq0M>}*b;ngDs5O!yw1(q<&*+{s_u?r)!2#A5`@Cb^|^ z;LFd?KL7*m_b+{Ja@|&Z*AjX2!q_eT| z&T0I`2n)+VURe>D2Fo7SsK08Uoh?#NI8IVVe*NBC2zB7R^<319q!PsJYr}`=o(m^d zT&VljTtq#Msr9-J2)MwV3#>GHnOT_}~Xk73_xp#Q5!95X8l0)t{X&Mi4s z?GCvE|2007`D98O6#hw(yg^r}%ULR9U&OvQtu?I6Ns79L4~%+OM6SEN(skWY8^53$ zpL%n7@ePdzYcZQJl{cv(a>gDnaw-a zOTFo-xZIOdKIr4Wer3IY_XEsbaN)SG<1@+TG|$xeGoz}~NMioI_Mw&<)H1{aVF8`` zL%W270iYlG8?3>wytHz5pYMZ&W;KEO2sUHfYN;o9JCcf^btHlXK)lbo!-t|8J{aoU zQAyr3Dh?h~0LgFpRE))aZp-K=<5WqtYUJb=T+TMA=y(3Z zCZ^QK$c{3WD-ei~jM~w>Gs1hEMzB%e%e97hssSTZ-o9evYBy|PapFe8!sG5|f}{9u zQ^I^6jB&zBr{O103L(a3a@RNOSa_nc z50`nxAAbNKbVTcEU7PdTixW)Ionh7YyXThVE}p!UEKiAB9ndH;!k&QhdonE&@!d8| zu9?rtnFcc>7ectKa10+6DZcVa_VBzzFHmqR5TKUCBtQ}Jha}eC>2hv3Z$^U7(h)Ji zmfu9tq(VT@@aLMV)0~En(Knw1Ls0oa5zHQ3TE54;w1-oD;hqaq;l)r~A8r3Jk5l$N zI$YQ=RoY!JRWkneD7SW{ofp)mSBxgl5Tx6oKdpCL1~X&bRg=NI-_T8!Ut2cVKxuWy z-H_&TlhdPfIs4>#d^aBr<&`EY5$-RAdW6zn9+%-`UyR+~r=;v)p)hnqbkOSnG%s1< z5JdhfbT{^lOCrzAHbV=>mJ)Fjbnr1|Y!xbph>(SAuGpc-m7JITFJcHmUCDb(4(WVQ zbL-*GfnipHkHLD^_Ok|7x!GzueBj<)-=l+%X5u&}-BFXX+*Hlrd-R9X&eV3To~m<~ z|12DLHGBY|F_(7^2a0T7CK*J7C69q{!nubsWFvIDyLZk{_&G80&Nov-XZg3ub_JR; zqY!{PYfRpAyJdKI3M%mm+HrHQn-9y8pa!yJQ~WQD+AQRBl)FDn35`jXYiMy`RzD&zj8Y8m?R$-YzvYv zPl&upnehM5zx>}`=l}bZK5iSDBeZhNhxu++yKVP*>8J~e)Yl#>bwWBr%^)rZN6v_VJI$$$o+=7c3E5`kwhtjP(WUYtln( zVQ21LhZF2^1gpG3L(tE@1n#`d(bZ%$(BVz4<-!FUbVr(>pl^ltW2|vXY<4&WMXLFs znLxX)6J3(!qgzh;XMDq9f+m~^InGIy8s&6O6SP+hcx!Wzu&ve1^5tkIB6qr6>+kp5 zd3hDGT{zynJbw{=_q#so6XTH`G2)bS=dN9kHk0}sE7X^E@zB&e9ZVGT%+QQz1b3bj zOW58cN>y($-3xZdPCoc!bnyU=Cwo5P+@~Zw7SCMO6FO9RBr+_ss&8aR=Py$US`b)E z&N2IEIQfFQ#Pcv*_P2_!5>GCA-WY``H%(YH5!m1j4M<^w+$t08Y5i+w^Y(mS|54Xu z?`d3L;UNFOPW)TGz9BPw&xDksW#Y@}*|(>y%qaDfyMawjg9N*wq=eMe?K}MYR)4} zUi0T1TZ3Ha?&a$6Q9CE^8v=Zrc$)^@#gYwW8ReXLb_wCZYbT>eQoifa`1#?L;wYw3ya29;KA`Ep-P?Gby zXqFN}ofi@nZKoW(YT@ux0sXc#dBD(xAZPFO6`VhE^CZiK;bfdf zJY9zOR?zXucDV-lELUnqMGMR`wVPSMYj%#hP-Z~4)Q-KoD#;cP3ND*+kB#jT4t0>7lc{}N;Wojule_*$KyEF z|y!2+dteJOAd`~ zAC~j!ZJw%$aw5Pr_gp3>yP37I|LDHeAHP5@c?x4M!p7C1wgovD=kx)lCUY_S`$JEB zOzch%O=jd}t|Ouhw4GY~N!6`8H$qlQ?6q&ELg5ZU!AYR2`+>$p^Ts@Dp?)!VF#e46 z&h=LNuqs)!9tR|OZ+4L%%1VEo$TYaZhM`ZsS(>}ln^iuHmx~L7BC9&CHP^e3S~6( z?zGouk|D_tP2Yfd+XiSjc{OBCVpl{{vf))n8YyRTnkV0YAuyjkp&XE*@ zW#Q7xv(F2|eW>#o7UKw2civHWu7{QkP5o{#-$h;`<(l+|re>nlW^14xC-pv3lFEsC z{GBi`a+e8$Gh|2!svXGZs2CiU?24m~Re#KWaQLDH+uYoF3KZ4g?-3+Jbq$koIbRm4 zGD3+9WC)G9{FiT`O8PkN@SDn?`woe|iw z;Ts%ABZb78P5tF8hYD3Cg<5?rQ<2`aftG!d<1LADoqML&IyglaLH{ZAroBfXyW@lE zzVXa-EisjT*#;pD4BvDq0CCb|r|iF%LGhSLKs@8wYWufA+ll<_X_iP|XOT->TSZSm z*se$)!Yz98`ywY>WPu)WXQQ&+QFt`^2%*X-%5=(Zsh2XihQj{U`K;(bhyqHy8HP>SJk2k9qI$ma?o*yeq zC+)cQ#ZW|Wo-2R6)$l^L-EbS?8u8eh3(n5%S4tc9s290aR&XAA3jIV!CYayjlK-rX zGn3JCk|r!7+3wusx^Oj=QVYx~hXTC^G?lrlR1P6}4c5t;#T)iL5=jsPyfmb@GeoYUrFVD6Rv`FZKBS zpCchxUZ#uf4&eGqeg}p&nejDS&vt?FcwuZl!*7xUO1R*Kml;xR-duWbmRz9Mr&csh z?#fRfn-u5AQrhAy-+9Q2V0#z?w;Qu{Zhg?=&s?F0f=plXV>I*3=c6MLL#erWGUof8E66kt$=!L_pR(h*V!9SVb`{lZu@Qs|UPdQ>B4}uY+`l$#5S_q*v{i{?O@t%04KB zT$(4V+)<|Kv6^XlPSqiqX!oyM9878e3YJ-U-EX$t>(}PsawpY zt#H`0$=oOz|1r&87B2QD=Ujej-iw$mIXLw9VZgKVXVV~URC!+bam>BCkHMn>w12W9 z4^3d^L2jRX)JIh%S(1(zNi(D?HK=uarJVoX9+Fv2O4UbFPdByD#_-zFE)G zdc-ubA(_Jf#*tRZ<;92y3RVsWN*fl;dl;P|pDbRo=KP-T;5?Ies=eaCQwQTp4W^<8 z_o~Lp@ZpzKZ5oH)871>I)Mel&!y>-gb=GS{_LIP$ z;)k8jA{IR;Z&Fb;RJe`aH@z%&IjBg{gb^a#?<@(-9}6E&`rL}CRzUdwy~0$MkYUg0 zJHwQs8mec>F4<<>R#3TyN#lq=eLPhuep^E(i&4%RWojp(22a z>~|IL&yEqC;Gx+plycsU9a3J^|E3zM1o}H8t}x;PdB8QYSb+wddo&)}&1@$jPl|JN)2x&!CgdDyJ^jNhX zng)B{S8RBvk@MyGO@8oQy+gz2FnZb1O}n7W$ABxgqf+r&&bKk-_~tE2VbjakcSEq% z%-FGD3#a?Nlqr!pPr?|=Wb5|#7dMXDND8x0Z}}eWe>s*0VEUWgsS?x~hXR`VGzs27 zsn4AoZ;scZ-(vr%*~h+Fm4$g{g4Tofe{hQux^ihrADnBR707ph^to2y?-~I?T`3*v6-8@gUv$e~Oum#p&R?9b4b|sqnPmljrAftaG z4=HNu>-A4k+RE-2ZWteZ;sTO1)_Z$N!qOK-n%Hs>pWfzL)&)K*ecM|Rh zy!j|`QIxg__GlKD6POlh)Ec1rRIDixsK_ud&P5~MCbW^gdicKwJ<)^1_!DtKZlneAPUe!e4U#-rmZoVU)86S-vNDhNqxgp9NOFkWo zE`kvi;Yh=5?k4w@T#BM z=La|FW@$HzpX#676L@1TyEv{GYtxF_wmE*l;Wu&fA<5aoQ;LDli!WssE#yzmj6d>Ia9&8N_{>_ zVe92X8?W!tf=mfUd>Q5XN>Ng;3PPe~Gj}>{w7T0K6`YH8)=Qjo=%xxGIrgt|mRffg ztM|Z8N_@|2MT^x@sZ&-hH%Ae*uZ!|y#f(=bvj3y$<;}lleg-Gj%`oI~d;M_A-H&F% z;1mVeclB6c288BGQlJ@~Mpfu9QX@_-qI?L0>p3;V8CpW5Fh!Dn9wXasr0EE{Zio+c*V*6((z9CO;N9?WwadE~ya-Z>H(`He(lwjw5z zK=}qZMnrp7TjQ`$`p)u8FV~&kCBd|xl>=4Rj2OF-N>}D^DA@!Yk+NcA-#y#5eL-+I z$v)|x)_rxlqQyn)*iLOvQnGS{no0u%zxG$7YR}|#`qWuH@ z>YTjY!a?z9P^aANY9f}jc#MxL*+X`@qL}!?Y#52_zS@L zwXgDv*gE(2-plR523%a>%NN}*<4?eK=#Y>iE)wOL0t!@47 zUbhRPZ1vea5oFi{?GrCpelE8D&c7rzv;XZ{1Ma)PFwBtW&ktnb3;P*dWl$hXn^iSa z>!A*JZfV+Lwr-&ajm&R<`EB!O6nVoOf0{s!BQic}Y#x-MnsdtwOl(r)L zsIAuDQ-KvszLALt`^&9>;~}EAeDBZ}<0)!}&Ypq^H)8v`xx+k(z<}Ga<7F8VME{>{ z3%`>pSKyX{^6!zzhkdIm8@5;=(8@8#va2jvjd~DQ7(%L#pfsTpJD$X!Lj+n@Baio8 z=Ha5R2;on}30a(nhJGiWHQisDk+6+1yGfRBHhRth_6hfhYHrCbsI_@ZS$$wP7GwY$ zvf#)>gQo(CF`MlNx!YP{6U8%S&PCdlPevY1NM`)1^FUDEOe$;G8@E{M!%1k|vleMR z`)R+lksuCM8;XlyFt*66UQN>&R-apyB;OmAzHPpo(uTiSsIS6e8#DGm>u7cX2jAgm z4?e@1562hZCjdK$fzLf*THSojLQ|CTb;rntJxvbzSk^MJtE9DOk~y^MktRxt_^%oH z;KBZ4ijQhs_=4m0;Bar&Iaz?!$DHG*qJxcbRGZMZr?Q6oV+-Yi{-{VfagUG8^#0f%1=OA>^Ty5JoJMQMCp#TCpKxVO)v$&VMD8g210VyeO3etIhhN`u80^s@jQNWUNOnbt zz8CJ5&WKuS6zOhzuLXb`^?@O)rD1`r2&tHHFk=D7V|`$oj`%UAs0_=x>C6-iTi+9e zx|?t`#!Bzc@J!~SeG)Iq^s?YZEz?nXBR9(P01SghivC|~-yPOe^R2r>3%v+JKsqWQ z9i>+xfFFv0(xeM2AYG)FM35pynLV>+&04c&z3bi1Fh;ruz44=OPS)|ZkTg}a-($>nsD+9*+($JQB^L?ZJWQ>l zMB|Sam)5%K7>JtLkH$%4 zbTo5mk$UP5UAw?qn{jZEs>u=Y*xTn;#S2?fm z50gu|dg_T%3*Qr(Lb4GH=wWGF3cD~((S&oJ&2*g`?EFBW%`>@dvAMF7j}}$AhXbOkAkFo1r`DoLq~2Y-ng+6Bp63Dc}nX4(F|W)$Hvabo|^fR;G~-uK3xm|_+bM< zD^YtswdHxEGnEy!T*I4?oQ3!d`S;fj#HuQqz`WLbY{YN$FjI~B(lZbMz=j2UJCswK z$LFd?01#@bM>CzFdIgP46%pVJ-(?sHicrB8`Z5v{c|+M~?=n}yImv84Uvtj=X)ZEV z9L*c6z!xM!JAY`7Lq16r{H)?B4<1Q6WMPAD2B}d zYG*AoN2BuMUpvOw(t7 zOd;OdeWIH`Q)(-iHL=dorNR8S4k(z0{pCT z(?vP9&#QYh*s4|c5N4jE@p*7~znutwemq0*kO$f%w)xD}+k#K-FmQ5@%CL6vlxf%w z4pJ#bhZLx;Q?^=K$#tL_v@{Xm2uyCa+m5KvTfds>?CBa=@NuS8jw(9Z`^T@2gWYN( z`5+g+O?Kr< z_3S>Xmfa}>MZ$lVi_dzMT#6-@M4^8o!0o4+N=y}x|CJI-2Z4y`cQvPCwnveImZ@Bu72lhl z4t{)a&u91YqLW)$dSneIPNpGwmb&rbob3Cn78x z7~OYVzYb>in-lKGu?wnPu}JB#L%z_zxQVwrpk2Sy$bNa$^3y{ir3_?L%%+Xj4!^iq zql{zTzfw|jK&A*`Pdf(s#k^fbDDt~sp80m~OlSmo05&jF4?AUKs1}&pifxp}>6$J(Q5mBIA$><(q&wfg=$lsI$V{x5 z482qrd|dd*w60MJbblj+4}jkTwxczo5)9Q%Z94e+3=fa{Ii%XHY=dP#M>K-zYLiA@ zOxJBPYLC_k6$m@5C+he(0tN_BEosstfKiIpQcuKNho~C~r9&{V^wbj{0SIBFf7&+9 zcvB(WTCs$w>EkcBtonL5wrQ~fL?~*$a`Syka7S^=)YS6LO<}Kf9Xp3HfX0kLkX@9d z2+4b-GWEGHj6L&cCdK==+WuW#SGw>^!Q-VFc_%s&M1XQEb4<+1EiW&OMK#_$^y$^XoB!nMF-Qogs)yS(&cJJy6`Hlh z*|oJV_K;$ABt49^%QD(T_IWgKFn2*fLm{tSQMD+_V{Y?jw*?j$V>ij@%#&rV5PTuA zvtq*P=kN=VZzF}hG88ugsIQq>ZI(ueqJ%!3bko29X5z&)P1ndgd9^q!;ZXTx%OBN# z%0wXlb}xK3lvKziU{9;1nS7QQ^k$1=J2YTZQMLGPx{jfzhDYrR?|8tmP0f67@aEh{ zgKO=x&K?h+R)VnSuYf+5f}h~hA_loq>#L_)x_G-B4b=hqWjxXM9N~#@#w3L!{jGxY zK(y1`bO1w3>YmRo2z-^`8tiQy*cvim0Cr=9S<40ml45cMUh?udx3~UB0;=l13iEve z)LQ3niC$5V$5@cN+6*)pKKaN$wZ+VAfESFG5qSSjnCj-T_tnswhv({lDRj0fHFYHZ z?HPhhm*i?Z|GFA5%@JUl69LjKN;;2K8#A||njp~_s(7rnt1zE9!~&pNEpZiQC;31W z*@sQh>yu$=-rZK-9Tzy>TZsSZc-7nu|FJ?1U|WCSU>8sjpXJ7$&Bm$M~^ zQ_0a19jK7?2Iua~O5e@yhZn*h6+8u?yx703hxIB2B-hRwx{cEFY}dL(1Kr9$U;_5& z!IIz!;TWq0&`?kB_And;7OZ^5OiA+wFSxV^@CX11+z}Z$f}59WIfvyR;G1;-;zK=g z(f+|aW7?l@hH=Q!B8e*Z4zFFU=2_>T1wO`iH8TXOic-3p=+t9e5#k!Gg*}*Y!0(hCrrzcezo*HHKwa<>&Y&H z8}YPsgpwcJ_TX(1h-YbQPPTE=WgXY0RTYiQ#fbfxXCZs<>7kLtsObkIo@C2|O|st02_)S&<9J5yv=5dyUo&`&0F4`}ByEYctPHT{phI zG!$Y<+&ls+X9YN;Tc;NaM+p^zCmr2-wC!Jd_SYc`I zRf-X@uAebOOcDV?arDM!!9B7S-pQCT^AcV0x8c+08h{ezHF$N~)tVG$ilW9u31DP> z>h(0J`n8)Mem8gkV5obTQSvjiIq|TRIkzSWsDs~wdAlyKSLw2#(@1Kq6!U0vG*pW~ z#;<{xD*vA>8Rv&?7<_3n3zPZ|^S=db*jWR5n2N>8if=d8&zzW?S^uVQI@;VW0x8(L z0!;ZI*<#>zD-K2zODUf6k)-j}|0A$i?*6-;^kz2vdXNNZy9Ys>E z2dvSQY8AY2;y@46Ah!3tdIeJYX5lGmi9_5dGJwuqIr4610hz+A0!m;}>^7E4?Vf|2 zwm*TDTvP2CFWn7?j*0!dgeS*$`%iR4*dG0)hh6E>M)L=4+W=p(FGWzD92>b)eVbtW zWlMqTZ4^CkAULUf)SG9Y7HLLn4bIdw8*QA$IO>|QVI0p@LTL8JT&39ZS{5!kPzQ)M z;qG4FXSWVnzoUpdb+QjAbFyRu$HA%^QNRG?WgLsTg=~p_c=^xzNnT3(L*q6ZbbxqN zQ;R?YzM&5U=@3x8EE!cr6FC8U*?o!|z|uR$X&{*mFM9W0u5SKFHZOpFd)dEeym zdw~5igiLLy7uQzW4E)Cv9xJOo7eg`i?d~}ovV8X%7;2`l0Ayhk(Np~NNiNP9j2F98 z*dCen?y*KzWo?p;Cct!i2=J%tPi;EMPbUsx(Ud-n?E*ebx<-;_wp6W7XqRPeG>vw{ zbo;Eac#8K*qu8_yL*LkLwbdmSk>c{)Qa-1QEqUwRT`7TeL*UMOTfj#CBfDG3iFcac zdfo;u)k6o2f`pU0@txT|=cZes3Zv#FtaVj?UQ_xV?Aakk7(#*)zxxaECe zO^RFxMmr0yOI zA8WC8#vlU0t9vO4bBrahhb+hcmT_Hht9c(2QckT1&t+?a3}_tbe=S|v>NpeTv*vp+ zDHRqeca8va%yAlWltJxq`r)_%^uCh|0}IB>oWrAzQ#V^~PsZN4>?Ek6YVFS>iQ_Kv z0a9d%Yq|cx=qt`^-V>Gh{SqqyWXE+QG(dRiuH#DSC=RU7uv6ll0OA-nyt;YeJa@>{ zcgR>eS5SXmw#bJ$zNm`XmnPuAIXt0gn01NjUNn{0(7R*r*>r!$HEpKZZw9F{uX?ne zh2vaSn*oN{9i(vCmokoV-?8FcGMgVc=_ zE04Csk|ruKz5wQ>duN^n#D59{M^dOQm{5?AB|y=8Nk;FzX6N>wn+`bj?wY9)V(e-P zUW17+p($ITyUzAS^OxJND0P(nV%Sh)`es6FM23iHBEZ&=uxuyV>C3c#nN$$ zrjAkB0>BY1^{N#d)FB2;c6BvJb?IuWokuML$&c^`AGw^LdA3*_Hu$ItXz0(IAwQFk z^TAnKYG0d0oSKuL{iXnXHeZLXEMT%_*lADvOLdjtOMMcsX)wGPdRKH_TK1eK1sXSf zsC1u^x~)H3SpQOU{$TSf+N%Xm2`vKmwv|-(b}p*!4A0ht-sdnYMKLF;9$tx;yOwwB zTDwD8ulHw=QTVy;OYzDKqL}B`%rZhc(|;Ls_HfgbO~MrI$^UpKLq7O)TuAfwOSo-W zYjO4VPg65u+Xy%4JSW&afF#z_tS8hj;)P$tbU`d72jCp!;zzOfms0aU2~5J1N_H?D zYO#(?CwTwU^WHe=x@lumUUzO9W$~jcoT|LxOO4&Kj*_za6y4iX$LcCXp&289;Lba$?{Gc5na7Zy^!5>UlwEDnJpmy!?)pMR7`i(g_aE z$3TI-1Q~>coZxKy>WP4atpBlK6PeM%6F^4u&a7J>>5(`JdtOcI*YaB*MnUGJv9oCv zs)7JO`TG*J#Rb8}7D4aSN>=iiKQkKf{;|cS@tH_ z&ID~nkDLu-(sz|5i6ydB!|tnNHQv`uWm}Z}NL~AQ6^^;fBfTZBDV!Q;O}d~>AM-Hm z0#s93#?Cyw@&w3sIVmQ*(zu6@2AUZs?;Ulx0By)afRQG>_*fu2ty0H({Rt>?4()ub zGWE~2It1=j2RMSLG*w5OZYV&;(3QbbDRPVNDS%T0P~06R+LBS&yXP*rM&AEU0Z}Z@ zyaeem zlO_Y;B3euvzo-xE-TW&wgA|mLj;Nw=HR(Sw`P+DOk`)A%h18NwA*ZQ&$$|D1rZPg{ zJyddQ1?Dy}V-C5EeLjS%;Cx$Z5dm~S#q$TrHSe6a9^w0rA( zvtMD9+8jx=OmVqbPGkKv-<2TqlHmN$`PGi*r+sm}3|@jYwOfk(p5)9xw9CtOqA^ns zkZUNlJv$E`=u#q;X05Be#DOA7Y6-Et#JGn?!(19$rPaGu(w-Suu5GicE82JIu}nbh zCbwNtPFa=VGE6(kZMhcB>KMAZBPq$%?%kmn?|d6kZ*uB_1;RLYMmJ)W-%YDKULIu8fp| zprxZNe7v&+6h?3}A9|g1s6KX-JBDhI8kOenozeH8IuE`=s?G-=pwGoUsGR1V%oAf; zwELZg4c+;`u6pS!^khJ(OzV#JRo&Y*J`zeQh3$Wg6Vq292=G!t=wFXS(|pF6N#d#e zgnHt0)1~&Y8v}L#u%NjDqGE}NQ&*jph`aH#_eiU^(DKv@@UZ4%g_epRni^VVc_qlQoygroSshn(uB8wL(JTOD(>IE#BL58Bn=uhBku8? zj*JVsjVsFTqZCviw6;?oAdLH0v#C{*q|Rpn&c&{!GaP37f{kH#45kbfGesN$8O0n7A$&9G-8#Y`=H}wa{ zl0acky1#R*AcW+<85-tfPE;9#A+lg^UU$%W-pcUj&LaovU~sgIy0?t||2gqOj+-i2 zA*6OFtoM!=Njv-JVkZxr>wZapD>r;JY+7r&j%z&uZwZ#_I_q-Cwabh0XO9 z4jhmDEU)c~&+#M7C|s+b;D6%U@7_;5>ia z)rM%BCYit4hNJz^KFVnhxEU5DM`GWt$XpwwI@uil!ZAS`uwy*%u&q8&j3oKAIO4Sx zyFk4eH2LcK7lK}Vm%iE&U6D2Ch8bz<4qg+BEs0|0R8|9%6VmFRc6L|vJSopR`H5_v zvIhKZf!mDOgcrDy!dPT^u98sN=x6-^A8(gRcqS~atl~S}nd))#$Eu=b;3T)jcV^JL zFP}O#P4O}km-T@&}Ma6W#&bO~+T21itZO@Bfjb=NaN*O}|hNR1Dy_n23PGb5| z@+lNqkDq+=M(*}291oKtnfh5$7ld-LlrzT}atkwr5;+Z?zGH%;!T2hn_hy1?iHyW` zcAHXFJd%S#0I3@G8OU=?f&l1Ho!U0=r=Nz>uH3pVJcD{DXg4VYeVE>eusV_Ue(79^ z-i85(S!H!1)^|MiUu2>z6S|o%td>}7mHGhNY!rUt=gO zMc3eTSv*&hzqXXJituB;iT36u|HDU2U@;fRaioc4pc*fCK7R~9cm`PEbUuajTh>Y| z7L=|KniK!@K0Dz{Kkz$YH5_)nJ*9Sz^|G?OXz?U94!}5pi{*xd^j*%4UOpZwI?enh zLU_pvbv{MUO!#9{-i-7YIhN<7%LwcP>)*d4Nt84xf8*JdE)k%M*q za)!?&W0!abyIgX)f22UT{=iTNZvSaY2GGkRzeOp;hb^C4)eMMvSI#E)iMQyd6V#@fd}Z& z?yZxEqzOVY1M1E4jK&tnn}_UZt`Wqd*0$r>?NEp+Bv~5xF|Q@Ky@}h6%|!VVgTT$w z*Nm9q<~S06Gdt?GsM63K*H0q(!11pAm|@R;^?A@{vbk<$_D#6nX<;L|w7jU&W4~n* zVR3bDhNz`jOn&#GJ&-+@#l8wT%LPlZmDRVWpX$}sO%sm>^x;#1$;L4fbbuK;<0x5V zV5aqjAz-*NuH+yZsrLB{?$!Uy7;N@i^L?o_>F;EM1C!(rANT2NE9c}vu_g(%Z|^;o zfnoW;*i0=?a(coBi{-OI$1tOn)uyhSrIU;pI%Svv9s3t&L!!QF=w~|Zbvk}P+IeOI zzppy4<&#d+;}Wrt#oG0BGzY0bjw&!P#gBg*9JW^U5d>u_v|ruoqT?6Zw+NU7hJ~%< zC_yAwN3^`tdb~NP2Y!8GLWe|-8|-e1w|mK@)ZV8dQ1*EG+svQj?19zpCdfux1MI2? z{#lCl(FMcl^zd^q#M@QwDVvPkB&L983uuzF118?nPlL93Cf;u^?S^{EOirflW0m|LQxBRsV%T@1=@BPXQkonqb6}*%e9I`a zV5OX~v zS-Rq^7t@Y!p&Yk4^d1_@;kQch5OaUb~v8vr$=NQbeG-XQc=Y+!Nzc)4|yW-cQ)d%kcGxAT&1lwL{+^hm4T63OO zfUHflKaAZ8zNxWO9x668X*04YTxol&FzG8DeiD68oCKdAbDMD=f( za7H5{BC9}w4OUw5NLL0H#g##&m8Nm*z^)i+%hV}1^yi1qDLo{`7K-lY&kEdjDBe7% z7XYOBY!P6bbQ)=uD^qi+|Cd;w6RqWx_h%+2lpY!b^;yxi#r*cg5k9XIna;2bU-mjz&mvSFzUg`q<6LNV)Q24# z%Guvb_@56*&}gLNYkbkVHUDrm1UJ|bhf|6iq8Sh7f0z~X{tQ)iFT~$q0J!M>(b0H_ zukTN8qE7QcSGe-b;T*i(?aYUc`mroMtJPEV2r4v>x;$x@ZvlAc9KfSG0uQEzbn2@y z4oc|UnZTeM2!xA9f~wiq+3UzJ$DcyzaNJ0f`y42fowlEl1!d&f-mmy$+>hGe9}*KB zc5TmK_NsMcLg_G({fCO&@Ts%eCg)y3gQmZnFEAr{!jr}*RTqF$mfB0E6UmS|5JWUq zS&q?7$y0koB{#cQ{=z-XgxQBDrht)cwVI509Zb8IORILa@|2=;c*d=b3u*LW#)z0Y z3ZYaJU4kkVx!}9?NM1zX=)=dpOe`kycbRDC1n9=I_!W}X@O#t2__c}UI_}~fo7_d8 z%05WCApHqPZqJX;_t(6wZ0FLr*N_sF<1J*XVwM*KlHoMj!DcZ{WFB+ZjSu0P7r5h3 ziw*)=jNRGx`-uy>v>}gs;@Q;!5x?A4Uyf^5IVD^b_La)pFFB?@=YxamXEF1=)x}Ei zjDe2}7Jj6?<9B(yk}ps%;fyy7x78^+d8w||vx+lMX+jLpC{mPjTc^dD8F9#{L}evd zxF?q7*60`5visxC3baFp@WbeO*YJ6%{ajXG9F7P*Uat>4_wpdyG)ymgmJuk>C@n?* zKlMHR|5l_(T%Qlo$htmKoBcSV!a`6`!mBr;t)Fp2pcnQ_1Y^x>CRn&NPt-=A3uk5F zM^7=vJngLwGqzYO;*{U~p}oW};$d4W+uWn^q2hL8t>5P{d3e8b&Y!=TGv{L_!!wg-ckkW3_b%6cZQh!h=&>+yF@ZoJ76bkJ z79bEc@JJ0}qzC@(M~t3;K>7v-_wQLnOl{6Yw%oM-LfIi8iVmCSZohg3nY%*$qKCh< z^kpf(_(ET4sS>|zL4IN!wK!A3Ke<1Y#4&hAr5F9CPdT;j(F*S z`CB;I4f5ib?8S-mIq;a{-^<7rACKeYDak=@o~b4AAQ4W1c=X5p+BVmekfcZFoz_Bz z6jQu2)71^j*Wcea|Lc*H#*~;{n5SJDidVOuc<+H4(U(aHGcjKf6rp==uCkrRq{caN zk>=B#|5{@W=k_umv|YEi-uSBKes`4HHyIUG+y5!jUrx1PW`$#kr0a<7D;44HsYf=- zSor1eI@o^<8HU^1?BkKhEms#@hpAOLu@6?@8;3gRJ3g11X_nY+jmMm^_~e{-Wk zPb6$vvTT1(Rkt=Y%sSpki;W*xRR!-qgnqiH4ss0TEp0L@ZO}SY| zrVBCIpuRuPxjc;4p7WX1H>R9rrJq!~Rt^r2$rC*wMK8SA{*&(l9oqcs6+mEO7l$G< zCEq{FD!IImSZKemE+7|Axr9@(H~2DWd2128w(iAtgds}oA7y6JGuC0H~>kEbdGj$|n_RFCY!PzE}c#l8-_f8IK&m0XU*mrIcI`var2kMGW$ zEAOL%BbYhrF}q_)GVj+qlh_%YAss9s-45ox^kwbr(2<+ATFmrIS|#7<)`hd_aK2&v zuX*U0s({5?=Oc57?!O1twf=_w#_8eT{Eh?8VLLG!x17_pic@H3(~9tqJeb*`wDd;D zT#T0DjAq4mn3=)uuYvgU<%8+WbECKd@$JqeVovJ8V9WQ~F2k=eP+!7MV7XFIpLdMG za5s8S1hUA{fA=@sD}Vm(PL1QQv%7DoKwdo$rscRK9z=~i4oHzbSlM5O#t6+V~pmS5f3c*Utm-)3YRCH~HU;^im#H`hF)FNt4` zI@#f~L43IIja>_Pv%J|XTDQ|T|15mnv0>M=+);56D(pZ?dJ!x?^t}0!kFnUWB&&&N z#=!-utPA1JpxhkkVVD)z&R^#9a1q~>4&+$t{R9TTK-p(|lxSWA!}k+r?JJ#>N_T?Q z3Id*-pLWY1jVsI$4D?WHDp(ZN>nkfTO~!IkJ5+L4=-If&Vx=7JWZTpQEPtc|c4rWU z3pgR+FHhsEItNxyJ1#l#M8nMM9@jlmLP@tqb9CYoc%B2Rf;U8uXKzB=F_4pOaKo^j z@>Xd&rRGhJ-5W{X;b_*J${~xNsB>Kjh5uCXv_NZ+4tUZpW!3B%zZvgX@#?mNM|9Lr zxtG1@lMkQ+q|ALsi;`&zRC`o^X`<1z)6lz;JT9y2d*7tyL_6mwlMeZy*9-dbGH@{O z_~%a`q&e`AHyB&g<7OlyJ$fiY8F!g9fXnxfwn7RST@VDUV)(UoHz6Fi%Wv^G;+8~Q z(C|eq0(3cM%w3}6wbr>@vLg;>tk**w*z_bHo9Q%M)v^Fgzsvr7QscW+Pg6IV!*7 zkP~;EMr=$#LiYTeswBw_d~5>@ln*86#Og`tAu@b(XX2TO4PZF?ufC^@gQa)pHUGJ+eqZH=B@S*DPN+*>8{>NGQXSIfMJ&43nKXTF z`SCZq?kcvj5Q}T4U$m@$ESQ;5ks?m`_UF|;%g(TrH1trCI)5u$+uQ2h6@N{|GP(}? zvBg*E0S{C3j}t3f8#b*4o+Z))imS!o{I7Ay(Llu>b6>H#eZNek%0}S~$I^17Gvz7m z`E0)S0Onf)YqkW#+z>O!uIIvtFGpA3}hkj=<|9 zQ;a<$m(NAu#~>tTj~ecE>;6xR8zW5=O~5m3OmfP*b(4yVm1F{Qs3FVNdI}@jdUZB)^vrJnrWAQU6jt4ukk%L zpRMx8o7IcYroy^;Y6o6O%7-7^W+5Iv!Ib~8;fO2aZHn@j*zio3o<&;hL>;@>T={Mn z<;yg`D=%-o{`O=q?}`c)EvVM@Pex&{g+0#RUk-}CG!g#sk~&_<;oGzn7w=6gm7J71 z8=Oj~LAg?rr^in$r?cxyOKr;|?#BFa@o1lCpiHXysG{9k(@I%2 zAoi?n>AkC^Tmboqpb2HOKBZO__)gO8;ok1ERPy7tPNo-H%ZApAS05nij4>)WOUgz^vGJwkWAOU-QSyqR+8 z+v;@eWYgK3k{zxES$$40B`{JS{GI);PJbwd>}cW2vybF{LM5=T=?;GtvT0 zyt+aAis)Ka%e%(S$vxhGDdsErkeg$TCorZpJ0w%|6OKI=w!?ticq6pv))~*Ubei~c zL}sHxvF%&P{h);<^RIZ&Bd)6jt6~ZN0Q=8JT3rksj-D+NG`K6PQo+ByOghzm&T=~} z<$YSAmssyFYcqS{)uK@p36!1squpmLLPwDtM@5u~LCX1hygXO|KFEEGCVq&W66y)zuRtgPh~UT+b*gXme*MYZ;UpR2)ASg`o^) z@RqW+t~zts%Nb7dO_V#wK|DIBdUc&g=cJe!sjw9=GVu4_Z~rn-sH@JjQ*q%ln@P!{Fqwwm#${*&#X zZr`%wJuB20uJtcN`$`W8W@KSK+E9{@R(B*Y;D66Oa?6RTMw7plmt!g0(nem{FLHa6SQ8^2wNe_nbnSb(+Xf8qW`c@g7J z+M0EV(sNN}jkVGu70STC4|giZH>RSuBoRhe*~otM}hI`tLuG z52X9`Ynm!(;ckg$Q5N6lK?dpL0wBzTbwkc~a@Av7%^E4sQ$?7~l)sxRn(+BMOReLj zs*k=QthVk)MFY3)1@L1*1R)F`iljFSiVbT8Ph7I0$b*9Kd(p+kwEVhIO z`z$ZgUPjma+LNun_U9H5iCd=y<@)Qyc`T%G(UAIHL;A{35iLI27;L=?^`@=S;!nhf zo%0aZ*@Q{Q%a^s3=bTTE6%4F{9_v}xM^-NU zNRl;ZJpTNMJB;SahDXlb-`wlcWk{8E=J2%_a7pW1i~To-4oYa^o-u-gZh{+l!p&52 zEE+BTdDcd?w>O;tI*kR)OkFt#Qsb;;g)C;4jETUwEI*E=?J(O3ac4<;pZT~FBNK<@_t$UkTCVFwCi42p0Y_2{GJ5w(R zHsQMcsu~m21_#B}H-+910Hc_Wv*{U5*yT<*K2OfXDWw<>SoqI=OhA&vL}yV?cc~(W zJ};GLbJYLP0#~-n@uDfwbW>`?B$rjejGk}$DV7V%>6xt!Cap^ss5=ss-ymcna!=P~a0# zvU{dDlts?>ewjzG?f8YzIfXoaX}8_q+vZ2&1)C-(gJ4b zs$q3a#O)g&;BYVwB}z#THW^f;8G9azZB1GSVJl<}>|NaN$NCqYPNm8Y*_Elzd!!g{ zKxj;z8M9@AW6_a0j3mzvm&yH`LSH03Mru4?y7Wc_2RRE@h;u7|HOgWEk%xs|uCs=E zv3*GWI)+!B^t*8t+LL7Nub545uJ)0tMNyMxZFLM7Y5ni*=l!|U4Ko=&J^?{Uc+xM$oG zT1KGuM28&`OyoFr4@!ft#>C;+54`eB$z(WbLz5Yh$Y!VkgSh-aT%HSQ3l7$;MzKHl zzSuw$C$^I9;TS)zzImwJ+NKtJNDB(RIkzY1ZdEpJU17sc|9oWthH8B_YaYE>Tkvc$ z7Kre`_1f5;f+HGB7cW%dgoE^kf7_Ui ziU$P}c63X6EH-*GPr2h|3j9-AsX3xYx!Y2;-Kz=2aIdPOM^(Q0sd-B2r=Q5qula&c z0y+fJUIN);%MYKrlXuJ2ONF+Sj)$SYHZF~nQT}MLu&@{eCxGRx#jWjaKYm;yX)s)x zFv=eNtxmDcc^=9pdKE}YOQKRruRK6@eJl^5rD6kZpT^ADw$2XvG!|xTHB_3Y{-IIX zk2@36G%Ij0Z&bSV#j9*?PtAq-LapVU2nW#~+UM2xa&=RNJqq#+N9{Pasi;A)<05*r zZuh$>uYSqU@0{?#p*O7#lQAagy#~swOOd=1kVmiMpAR0&e;yF$crA_tBSkLE06@~< z5+~qLiJ3C3Tgwa^U~gElq7G&mE3 zwNy_S^|`M?TDnT?g=&}>xLIf3ux6vZ2F=wehggO4rE93&Sm7Thmpnq{v;;6q6{ptAYyEJw2PCN}$JDEffSlYqTHt$kKKJf=00QPnZ9`DN zqOFOvxDu4s2g*;WRHzJ((eyRSO(1?yXz&hSj_5j3TGI}$L{_pE9kyMXf&^bNo;nUR zsNCkamUvY&*GZ$KaE#0l=gmx|j!279dBqR<<%eEHDA^B7u*Q77Gz*1V3yW|PnCBzu zU?nl1-E{aFL2{Wg)tEK^%INnk{PPSf^8^QyRUFm@SRF6lO-1C`#?<2tx+vm3X>%*o zAdY7H#Vd5>T~pdP&>gvBSczc~Qs-dWmgc37@BPco(Nrx}bgv4H*b;VGsoO7lQaS4YUE4(kwr+d(H`WmI)x#|&II4da?3lwR4Z2KgNPO*4A-)0h9%~@# zcB7L&DkbfQbsacIgX(fJm7f`iG4am<0PDzYzS)R`)o05+Oj(WtO?*gyrRHv@1x&*7 zhI^V_8BE)ygNCL$NSm3po!-?H4+6e)fAfPt+yY;1U^0EnAt@Y&3b>=AZ@%yKVKVs~aZj9y4}*h& zM5BiOq=;Xr7vX{sO>&P!o1Q~(c9rbuA;B0iSJ9r%uK`VGCTA9AEexm;7T-Y7La zAqERZwZ^Tz17Vcuo>y+JV#l|p1IuVQq=)-mYLl5-A;^q}18f^#!x6ki5Qq)dZJedd$H?dR<^8m2{a}3qZom zPf^xV@bQ#p!dMoWxX1;+{iL+g)cj`1(>sfHs~)NMmv+ccM zA{qK@VpDR7cm|F|cqUhxy_N!u8GeP?O*mtWKxooyn4`oo9hYALE8oHEW0Z?tXbHG4 zHtkMrESJb-6Zk>7(bE5RF%vh08W2{9ULaVMQd)(~jfXWzeS9x4`vmYSP5bv{b*sN_ z#cX4lUdBBWRe^<>tXUbds@EG8EMCn%1o}|5)qc$8;}@e1jrBuVJnktL4egACGtsCk zt03QGNLqd{44MTT=9YV+dFzlF!XkZji0Of1&f!PH<%El?>gNK#15&w}$k`AvIx3KJ z?5%wWT#=O5ER_!%kI9i2(EwiQ`Z4$KKP;>VVA8Q8a_EuX-KNEsl4a(`fJFcb}uQj+-ixG?S`+a0w)4Uk3#BjiRO`pL6V~jKhUd|8%lO94Wqg2x*QE# zP|A}}NMddhSX@-ucu5!!P~9x`jUUrw1jPYPxX5DY9dL?|OHSVzj`{9gpaRq^E*;87 z>rm%qCBnPs#RL77Wi6S09)vK^f*x%$A)IcnI|nKmx{?|!t&mn$00MBNk!=Tuanhy%7e#qP-}+M49PHt@lGu5On|nxM^dLjR%K571Op z9U&6-0=FLPR$chh0kXWvH=&5CW6#s0YDA)#+FN}c%hShhPjd%z$uJbJaN_sl)8N{ zKCU-~KAK5|P$05V*nqaTbs>SBDQ~9tONt)W`Gy)1bzi;{n<_d01Y=>{f07lcy50#y zb&9R)><+afNhyWFUwhP{fKeh|pfbN%#`_>s5&TIb=5O=n?njiP%7V0|;!Ta{;rQTLsBjXa!4RJJpD&?B?3e^O=Fu( zVH}3R)#lciPTAMCv@b!RmUi%yn(FAr7N?tK2ZMapq>)ynH1z%ul0BH{z}--+idWr+ z#CJG~$_GA|RsCE1*Ce{H|FFlVf8eRY^1a;1=5FS_U@4n0Vk@$u$&t9zRw{G+&B@6h zRb8Tbq5ewhPP({Xp4A+#?;@e%_uu$k%hqc1Ms_zmYm&Wp2*E!(JxQ`xD-jt3fMwSr{;LUlmM&mm?Fh_XX4)$ zr^n8=?TxS8?@Gd=)_=Sbi(E_07x@HUj`#M(;|~#sxwieE60$0;&R>Z{@Q&fsazz3( z^nyI^yCAD7B<4p~R-Z>BMIOIMQ}Y}Zl^RLlHW?{zO|~T*yy?e@C_EHtS1yam;}`nc zmyV2qw=zyB{nR(Qx6`mIt31XVX=dsYoRqTg?KH-8Pot*pCj6OrfWbehn4Y8Ox@bxm zd*ErLT&aHoq1Qh0o=KfNtIx8rw;1AmzbWx90j{blmz?p?<3(a3htHTl2jXZOoB7-; zLDMu_ux}-Lf1saYJ3bd&w%gi~a8>&*Bgm)bUEu|t&cy-;;s7)QmF78YuPkNb&oi+U zxjSr+Iwp4_e}^}>_yq{)#HH?NcaF(NEv$riaC?5?pXSVgZQ%5v)ZhM1_qmDaw@ppsZaAaLkgt**xP)ff9*-;IR)S)h!*!JKASH)j91a~|-; zJ#jcIkwcy>Q^|kQxGkOI*)f-R+s6dPCoaZrjo!Qd#mXT7qVP>wLi!i7ekE4Ei#LVC z%X{T@8wdIM@)s?G*3#kKERPqzPIxK-1TPi{PV!3yk|`gyzESQGiPx`n_l}^2MZ8g7 zST3AU##yBc1%1`(c1AkJW8x+wHAvymbI{;k?i8GmjvDv4@j19!L=?0-v%GVfxpz12YF1YXfn$QpSIKS``W{}CSG<+Zrh zjU}xvDB-g6Zk_$viVoX$q!>KhYBY_XMYoYwLBQk&=mz&ZA3l(`QJ1<(n-wZ|t$2-; zebcr!YB?$fZqh|tP0Z&??e}b{dWfMBS){8b20bWBA1I+xm=AFTr%UXwFV$d&Y}=y0 z(4R!Hx3&Mn+xhi`iB7#X^h?rF`oNz=*;0F^xM<;Gef*M5^IyGj<5H104fyQss zn3r|%=B9gaRf8+b(F{a5CFG0L$=0Df*vG6l>N){Xep^wWYNsLaEa~3aVY;n`zYXa5 zO#m7W8p=J|gBZ?4@%l}yCN0_>&$>WJyZvE5?BSNsKmt8sB{}u}mr%o@xnE3Ae=z}e z*wX1vgu#Wm5}BFfPc1A*zX6{=s8|XHG7vtcML+gnWrL|ww;a2&JB+virlX@(>|>kr zbJfHQf9Q6s4qihq zZ5QzePU5sAYs5AMe@RrsP76wefj67%sa9Q1jUo77Sh0~z_77~-TA=pgrR?+z+C?k= zLP9&b>P+zfIQ#ZRp7L(=$+F+>3+BdDz#bHl>Oc7npYQgZ&Ih=F=8Uwrz*#GmK2&wN zvefE^-!S`$E0#%V0U`pdl#N1`J>iUw9|XeI(95RO^!=?WOI-cB=2HyKFfaV|Otrt? zBa7-0gj}9oU31`*!{8j;lA`}5Z)PdpUU{;uSp`}z2DSVu3+*b8+lVa)&p8cBH}quR zjf9=2mRalK_}(#a-{0DjAC1gQTwwt;zsxYgyR!Dn9Bua#a3NLicb4*zvNeYm6V2&P z7M3N+G|!{}Z#7mk&oB1Y1Gd@%y=6%**1p*dpz)=uglH|j)E3^5_t z&p#>n2~IG7Joj=bpMC`7y~O@K6*ZnV8EIOy5-2iVT6w6ewc&P*;3eKx`lifTnTn-Z z?C-Mn^p>_jLRBZfon&W>5x?vzTHneo`7rL@&o|y|N`4j4OmZ3Ex|w@b3y5*P*9}Hg zk0nI0Dm$$mPm3dLBgG$iL7oqUtGp6pX*ocp{HABAmFdcO-?u)!Lo_@LED@}DhPR~B z4SuVpiTbWSo41-pZpgDFE7S=ZSoN%0Bdzu^S2dB8;~~n(pqA#XI1tD$fjXt}ivBR7 zWcX~l?lnlAv@`V?*pSQA!-$pgxa63Vn`eN-0LrnCIo&^>Gc^>p@_oS8p5ecpb@bXy z>&?Lx|E$s0hCkhd2z3S#d4j*4&#~TN_8Z|Bfc-w0w2$`1&35;9)vYi!3aC)&%A!-I zzMX`}w4OYRX!W!Dlt{^@3wJL$_4lopm&pBqUE2@Aj*EFi?wQ~0-(%bWvIch@t;WLH zmoBJ34ZPBhl?rg7Lr9V_cdTu&N$=0kCzoR1zfOvThS01EBT~_R1*Pas*73g(leG6` z!3KivUfYq6)C^1v{Eg03TWo5s!w0AEM}HNT)7p&ZE>r>4*tc~jXFTCzNsB8FTk+JP z@8fjh#F(jy@}pQ|ie8W(zju1}c0t~>uYXCfjaYwoHR8!&ZMlnMU|{WAzq;GfM&Kh! zf+gYB8^vid#=mJ=q080+Zbqo5l=7>VTQ@+h&5sR^@0oOgTjht95&nEP{E`mkWrKnI zwr}^e)=QF{d-5y>0v>^n)XdEOyJ#1WzU3Uf7`Xpj4r5~)y9lg{A)0`&K=@X**;0aL zz3&c=mjoC6lN`WvuDZ8!Eo=fB9!%{Pz{iiIp{2c!fgdD|4PhczU&*4|0<7c3q&$Kx zQhGW+`d#TStm)hQT1oFR-Le{3wv3x=dOO|TmHOVWZ@{_ySelaG#rsXWsHn}{0{$UU zdNF>$7SFYp-5I(UY|0~@)gy#jWcNy+bJJwm+PPFjY5>=tjq^;mi30$OwrTRG-;pG_ z;?uhRbpR-SU|u*2Ye63uVZmjR4TBzt+inQz~2d@>mS0awCa&4qB&E-uc9ii@AQ+l0qx1mXiyUQR!0cE8n)GVL$90dkAY8y&jpBB>zzyL@(=)s}`m+ z*u0QmweY!fk`2W~PaMI)o^I3AHFofYb3S|Nn2CAsS|Q2vN4e9m4|>He3bPQ1vQ|hP z!bg~Q!*&hcCnlFwn)zgP|HWwoQh}`pK(Z!;wQenh_e6=VDVF;nETR#z7+Cd zS8B$9SnCW1^-iM`S~FUwl=AS(q{Y|B--@J!>GG_cz|^KhwaCXNU-F!%pFaAKT4Nqk zc^{&!i-opiT9UPm)B03)k5WVXbHc$@m6a&iSH-SU3&eW1r6w}G^_ay$PDOV|1O&>G zW306?H5mST(Tt=R%_cV++7LZpX-f0$zEsk*95vV8FX;HH-;LVhQ_q?x0-|K2%Q0}| z_hilJzxnmYqG$q`*u#}`1PfoE>25JE)5V#cUI4Z4yEsJ#JsO8I%0=0wr2+`9RMyxV zjMBr5v%egfa^P>M1OP|i;sI=Fa%3ubvyj#IRl4TSn;_7HsKHH%Ik{3xtg0)=**L|g zw5<~5Qk}}AgK|*McX!!KR-ssyAe~UvYu5odbfRHKHLU%t!I*1gq>t-LkL;+MF8f+{oSil8 zw_ts%DsAh0tu;FN&RYyFz)Q{WLn*3+(JamV%g1QxxkJzC!lP(dNz^q&TZx|L$%$?4 znGLg*c*f*D1Hc3FbJ~Hfq_u_KsK@?0S7%91xv%+c``uLd&_C&hsYb^%RDfk3tua3C z_pb4mUl>;bj0Ex>M#-AtZ*#KYRd=Qs8!|t@;iKoEw83`AZ3JQ+sdAlA>l@xV#ZZ+K zNpTJl9}x_WgnZrbUh>qlc%Ar|wT0~?!T3N$oR;H{gR z>VdG`wOZfzuggP1X{Yj!G`rKm$)RSh=KI7_T$GqW-yyt-Zm<^u8Zn|O>+_V1JPmRVn}bY6<+@5w2Sm39 z9nPe&=vX)NGuA=>(+elz5to;-<<>Yz z@%zf>q+DOr@Y?>lfgj6-v7AnSU3(myoMH?TdW}+xNZsxA)i*x&14J{9r-99d`s(^etn2{lU#THTX zy&_>#m1NUpT^;o=q0_Y%)A8W==YBUtznZw7Y#OH&c08N3Pmg*zO_jB#YNxnN7?lg{ zNKw7(?Nfz<5f34*W#ftK9o=Z>?$*s4>F)}Fqr?NM{X=JMfhRdwN^O*HRMoT!DRfXOnK&SEMa;t(v3u3Ng{V?=XyP+%{rYls|Yl z6=0T9SMUB>u`{SV23v)Gx&vL$C&ZZ6yrF0B-W)k#eW?W)QM|mgx*11KWF~T~uPK8J z*sa4IrM4t$**yA-XHspglXSp0ys3k^yPtbd0;HrZwxH24F2y#sdE|_xDiuH zd>gKn_*^m5#uA%Ds*ms-A;SD`1B6Ek{evCl*3;-@5ggy!4 z^AW6#LTW&z81#ANTQ`GfLQ@TJVB>)>-1 z89XOEX|Wl3pR9r8XDop*8UvsU@giQ8LK0}*&FNlST#dCy7}>~rb;ZXC0?@^HrBL{6 zUaDqvMet;~8Wx;|{SyrXFroA87rvT}_)c~Kz~KY{!1h`Ev7DW_x`9cT$kK`Ru?`sh zy8nJ?Q^KaNYp1pzvM6Yt7k<7nvaOWbn!9)ImR80Rf^&`#HR%J(URI3%rrvLiae27a z@=)(4G-dt0KH`o~zZg`rafy1fu^;oFN#4ZX5U?+MgG&_mDPSKnVgZS14(`6azz&wD z8>!{oUhAYB;h2M#vb|U?;mhV)jYMMC<>Z8EfqGz!REdZl4v$aOK<@M!cuwJmMoUbO zoA;T$!E^D^;A*?u(Vw|NV{it{!(CRG9DJ}Kh$U8Ccyg!%RC z7yN@imkK{s*yUN>r~+92x!{CP5xS9UP_L-QrE{_m!B+6S`YK}6SEYuAm4uB<;10s1?Et8DFQ zZEF`mXaZ2rlC(?_bONaV`0E&DPR+skmoy1q?COVd(t3n5r$-9DJDGf}%j_#?l<92a z(E$O;V{)BM!)#cxV8=Q@^9c}lNEv~JtlXXce0rcL1rmJw46g2Y??&GgLiKZ2{!xP^ zyK5Ib?Rqy zpD|5SvZ{|7bpC5jALpb{AQV1V(EUlXB2@eV2w2OkpKq-s+)25zn#M%k8V&CZ&dkISt*%qn7^#5WBrm`Xi)v#!4QHPAqX4kX|7r_> z9(Z{;(*4z8Xt9GmsjsnTY1szJo0qM%l5XSbb#yFuP2va4PZhlxeMn+Z6vzyLJ9iQT zcp%m~#sD__v&V*3l zY5iXIXITF8@=~?<9o=*X<2RcvHD1emDwN{6C(BEPyN@Sg93O4`GwYv`P$&7=J@E?I zI^b^FCF-306q?Ybt>&`p4}wjy_Aev=Ojhi5YC-uob5`O5+GrTxv(2r=lsQ$&2Uo(e2Uo`e7fRx#PXCKO3$kl*Tt##?KF zb;5>!Rx1afpzpP{Zlw=j0MsM!w|n@^c8@@ld#9l!{PUn)9kNtjq1LjQs@VEe-F-Bp z*C)XAUNaU}pi@6>GtJ6kfI(OU`t4FbM)_wgv>~*$Zgz|B6E~(aD9I+uhy7fjP^Jxp*{aQTN>GxiH=i<1+7O~~eLo$sjc?&uV2 z6+(ItC!W#2aWXfP9}W-BeEo)AXcdSa1AWfDjQSakL-6XR%1${}3=TFq24_TL0IrP_ z>Isli%qbt%aQ)q|Mf_HN84KgRZHX{j?94YIk9x~1xLDj*zuLl;!%kmt?~K&w9Z*Syn03ZU*!!U2KLmorWE(idshZ`vM#TNTWH zkb%tx59@`FY=-+rJk3QVB=G^&Zr}SHG3%qaw6>2)nY*jmmML~D2ZtMZdVC|j@RQsA zz!?Q;w~xlhy>n=w!Kf3hAb;iHBSffOzCGZ5rv3)CnE*+d-*L&^!HjWhfcN5^eaQYk z$@Dx?1~F1ua!|i!AQ4e~WV^QJMJe^|o&qlXrhnPO9Kw>E9WrnzafCi0MG=1@FGI*b z-i58P+lr?fB1%4#0EB1|8+CC~ok^pg1FObvY4BR8qdI!xi6!~>RJV?!J7ll1+;F)X zeSF4;2WOCzVRm&6t?Kb%&HyX$yrmkg$296F9B`j?Wv93cd3P8`Q+s<3)g>KwUd0~O zxZ;gi*Tt5`S?=a<|2Kan z4Iag1tXP7ZXRtEy@IdtI+ww5~JqWiMvyrW)kL2dY8sSh zFG|J&IqoZ9<5YkOj>kYLMt#}U!ICVj;@iik1^RriXN{lMkN4g@n3!pvbA-WU?RJOJ z0+h0dpaXTZIIwsQH|PN(gb_jYMASI~w}u=;^2jV#66r#CsHh(4Lhf~yx&)TmiT6hE zUf_Kdhj2ItBy_up!w`;*ca&#_A${;Vo}E5l;114u82dxdNGCllkPj3fQK1?alTmhJ zn(Wn27+%GRiIY1m>=~q7O3Tn>c7TkSq67;9tN}qqRzn7%$Lm^gZTvDog&SnG8wSF& zQO@`JY}8W;1d_P5_>>#2N~&%C9iUI<1ulet7upU^9;=zAE+^!RZvC4?&j^~?0T^2lx<;>36aX4?JQBcZL z7GQCN&m!d3(gK&{+M)!Xq|;1~j)G317eOo`|e#l2Rsxww{$1ScxZz z{cXRe30S8eW(8mz{(!V)`)Pv#pzysTM2g!Yn@(QO^+o({1KPUN-plKs&`ut`La_c%-Xy35hSpY|<&|FybZK zHuXDb384CzG3Z9fxVWct@FZ(mZ(*Ygz)ws)MpwS=O+jzJ4}AE#^3tm~j@_)aB_QZ( zJTblExl8AK(oq&V-Fhei@2@8DK}n>Z0GY#)o3A-j)`1&HsY5BzWFVQeFU_f+yI1Fc zT9o$s%z2AH$(?5hY3t(=YSl(QL#_9&mek<^dMa??0Ihw`HkQ~w6*P?k?qH~pJ9RP2 z@sCS~hGck1J0k%32iCo!IY@!>XLzd#6Ni9XqwqE16U;7SQ~(BE)nA? zN1u41ZtThA=a;nf!l;IysBdTJjrxp+P4v6wMb&9@fI_KZ2ZTS_z?H6{&T}LzX0jh( z@ZyG&NhtQtuTdxTp=0vn_(i~>qhZvllzSk&;6OKZ&B*{E!zY=ie&~n#b)Umuo7 zYRd{3x+Oj3IQA>L^Kz(ce_|?7KF@Uf5E7f$+fDj267SV0WKXL@KzY+tC08hwn z_dejy`n+rYo_6o$JN%lL(o#tu#^3)R_}C;-BC8i*1zfr`B~0swQKyx^{`? z46GN{Xq3!JHR_zHs~dj19(PG=)mMZ41FmmO;2iA>8!dB1pl5ku7quM2siTU8r}AlLtMUr98mDX>_O0$^JzeI*_sWAqw_{28 z;XMrrdxRYqB`;t@#DQsPFNcyy|$3v+Z?o=&v_f+~n5`Cr<$6b#OB$uKkexbzTLT z_`c~3O{yEms6OeJT*7K zU=ZHh5K{v;c^Ug*o4u8F^}wi?v-s?Na-uKD$9H z2vrVr1bv-+=p4P_?*Cm;7Qh&O_}QeCuuItBGr42SDBu1vG;N|u0LM4opu~;{pto&% zbY;i7wGIV&y}|YXs~fn?!Lhcjb7}Y%sTKGo2DnJ%b3uvDj{xz`O%luIzIUz7Mv?y_ zwP%QOubf4SPpF?$9d4E#yLT7-RLkXtp{}nL7+}Hgs^-32js$)?A#iq9KRzYKzy^#f zA2M#3L4X5}P_e}6EMm_t;BMpw8cyu{{QyE?6|ZaPhKBF8Zor=#mXCc3@d6K3G~?sq zqkV@0ntjS7SAa;>44@YuYu~H3sim%JOlhB*1a;{%QLD?B{4xk?slan$Ystl5$OZe? z#x}UJM3Giv8y}Y?_U8dMbMf@nC=ay7l~vlg;-_%mYH(knMKwm|yaM3oq}RTuZlL>K z)S}M8^8eZ#l~1+tfA`v+N;8^eZL?(O=CqS)hSe+vwv`XohwYeKCb#Pvcw?XPY58yw zS1C#Pa2a(Yw^PfEfpzC3!!ndb93k5ndZ4Fm2j?utG-e>3xpNh + + + + + + + + + + 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 ( +