From a47c4a03984a1f9888a24f4e26ce86b6a1349e0a Mon Sep 17 00:00:00 2001 From: AB Date: Thu, 24 Sep 2026 14:35:05 +0300 Subject: [PATCH] Init --- .dockerignore | 7 + .env.example | 18 + .github/workflows/check.yml | 53 + .gitignore | 7 + AGENTS.md | 44 + Cargo.lock | 3531 ++++++++++++ Cargo.toml | 33 + Dockerfile | 15 + README.md | 44 + deploy/gke.yaml | 94 + deploy/migrate.yaml | 46 + docs/architecture.md | 120 + docs/deployment.md | 16 + docs/development.md | 62 + docs/product.md | 177 + docs/proposals/workspace-templates.md | 86 + docs/testing.md | 118 + package-lock.json | 60 + package.json | 10 + scripts/dev.ps1 | 49 + scripts/stop-dev.ps1 | 13 + src/absence.rs | 714 +++ src/audit.rs | 90 + src/configuration.rs | 374 ++ src/demo-holidays-2026.csv | 34 + src/demo.rs | 373 ++ src/demo_runtime.rs | 259 + src/domain.rs | 706 +++ src/engine.rs | 836 +++ src/error.rs | 64 + src/generation.rs | 1299 +++++ src/generation_service.rs | 163 + src/holidays.rs | 179 + src/i18n.rs | 71 + src/lib.rs | 19 + src/main.rs | 106 + src/migrations.rs | 18 + src/migrations/m_0001_initial.rs | 425 ++ src/migrations/m_0002_constraints.rs | 44 + src/migrations/m_0003_calendars.rs | 38 + src/migrations/m_0004_user_preferences.rs | 15 + src/migrations/m_0005_variables.rs | 17 + src/migrations/m_0006_demo_lifecycle.rs | 28 + src/migrations/m_0007_language.rs | 17 + src/migrations/m_0008_workspaces.rs | 30 + src/models.rs | 114 + src/providers/jsm.rs | 690 +++ src/providers/mod.rs | 305 ++ src/service.rs | 975 ++++ src/store.rs | 398 ++ src/web.rs | 841 +++ src/worker.rs | 76 + static/app.css | 196 + static/app.js | 335 ++ static/binding-popover.html | 31 + static/generation-map.html | 16 + static/generation-map.js | 95 + static/generation-modals.html | 73 + static/generation-page.html | 58 + static/generation-preview.html | 8 + static/generation.js | 249 + static/i18n.js | 35 + static/index.html | 930 ++++ static/locales/messages.json | 5957 +++++++++++++++++++++ static/resource-details.html | 20 + static/resource-inventory.html | 43 + static/validation-status.html | 6 + static/vendor/ALPINE-LICENSE.md | 21 + static/vendor/README.md | 5 + static/vendor/alpine.js | 5 + static/vendor/cytoscape.LICENSE | 19 + static/vendor/cytoscape.js | 31 + tests/browser-absence.cjs | 78 + tests/browser-advanced-demos.cjs | 12 + tests/browser-binding-popover.cjs | 1 + tests/browser-binding-preview.cjs | 34 + tests/browser-calendars.cjs | 108 + tests/browser-configuration.cjs | 92 + tests/browser-cover.cjs | 95 + tests/browser-coverage-status.cjs | 10 + tests/browser-diagnostics.cjs | 2 + tests/browser-generation-map.cjs | 13 + tests/browser-generation-scroll.cjs | 2 + tests/browser-generation.cjs | 84 + tests/browser-navigation.cjs | 25 + tests/browser-repeat-defaults.cjs | 1 + tests/browser-resource-details.cjs | 1 + tests/browser-rule-result.cjs | 14 + tests/browser-setup-layout.cjs | 2 + tests/browser-variable-editor.cjs | 3 + tests/browser.cjs | 89 + tests/generation.rs | 690 +++ tests/integration.rs | 1998 +++++++ tests/support/browser.cjs | 8 + tests/support/fake_jsm.rs | 170 + tests/support/mod.rs | 135 + 96 files changed, 25721 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .github/workflows/check.yml create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 deploy/gke.yaml create mode 100644 deploy/migrate.yaml create mode 100644 docs/architecture.md create mode 100644 docs/deployment.md create mode 100644 docs/development.md create mode 100644 docs/product.md create mode 100644 docs/proposals/workspace-templates.md create mode 100644 docs/testing.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/dev.ps1 create mode 100644 scripts/stop-dev.ps1 create mode 100644 src/absence.rs create mode 100644 src/audit.rs create mode 100644 src/configuration.rs create mode 100644 src/demo-holidays-2026.csv create mode 100644 src/demo.rs create mode 100644 src/demo_runtime.rs create mode 100644 src/domain.rs create mode 100644 src/engine.rs create mode 100644 src/error.rs create mode 100644 src/generation.rs create mode 100644 src/generation_service.rs create mode 100644 src/holidays.rs create mode 100644 src/i18n.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/migrations.rs create mode 100644 src/migrations/m_0001_initial.rs create mode 100644 src/migrations/m_0002_constraints.rs create mode 100644 src/migrations/m_0003_calendars.rs create mode 100644 src/migrations/m_0004_user_preferences.rs create mode 100644 src/migrations/m_0005_variables.rs create mode 100644 src/migrations/m_0006_demo_lifecycle.rs create mode 100644 src/migrations/m_0007_language.rs create mode 100644 src/migrations/m_0008_workspaces.rs create mode 100644 src/models.rs create mode 100644 src/providers/jsm.rs create mode 100644 src/providers/mod.rs create mode 100644 src/service.rs create mode 100644 src/store.rs create mode 100644 src/web.rs create mode 100644 src/worker.rs create mode 100644 static/app.css create mode 100644 static/app.js create mode 100644 static/binding-popover.html create mode 100644 static/generation-map.html create mode 100644 static/generation-map.js create mode 100644 static/generation-modals.html create mode 100644 static/generation-page.html create mode 100644 static/generation-preview.html create mode 100644 static/generation.js create mode 100644 static/i18n.js create mode 100644 static/index.html create mode 100644 static/locales/messages.json create mode 100644 static/resource-details.html create mode 100644 static/resource-inventory.html create mode 100644 static/validation-status.html create mode 100644 static/vendor/ALPINE-LICENSE.md create mode 100644 static/vendor/README.md create mode 100644 static/vendor/alpine.js create mode 100644 static/vendor/cytoscape.LICENSE create mode 100644 static/vendor/cytoscape.js create mode 100644 tests/browser-absence.cjs create mode 100644 tests/browser-advanced-demos.cjs create mode 100644 tests/browser-binding-popover.cjs create mode 100644 tests/browser-binding-preview.cjs create mode 100644 tests/browser-calendars.cjs create mode 100644 tests/browser-configuration.cjs create mode 100644 tests/browser-cover.cjs create mode 100644 tests/browser-coverage-status.cjs create mode 100644 tests/browser-diagnostics.cjs create mode 100644 tests/browser-generation-map.cjs create mode 100644 tests/browser-generation-scroll.cjs create mode 100644 tests/browser-generation.cjs create mode 100644 tests/browser-navigation.cjs create mode 100644 tests/browser-repeat-defaults.cjs create mode 100644 tests/browser-resource-details.cjs create mode 100644 tests/browser-rule-result.cjs create mode 100644 tests/browser-setup-layout.cjs create mode 100644 tests/browser-variable-editor.cjs create mode 100644 tests/browser.cjs create mode 100644 tests/generation.rs create mode 100644 tests/integration.rs create mode 100644 tests/support/browser.cjs create mode 100644 tests/support/fake_jsm.rs create mode 100644 tests/support/mod.rs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..375fd11 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +target +opsgenie-master +.git +.env +.env.* +node_modules +test-results diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5b349ea --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +DATABASE_URL=postgresql://meguri:change-me@127.0.0.1:5432/meguri +MEGURI_BIND=127.0.0.1:3000 +MEGURI_PUBLIC_URL=http://127.0.0.1:3000 +MEGURI_ROLE=all +MEGURI_ALLOW_SIGNUP=false +MEGURI_SEED_DEMO=false +# Optional separately provisioned empty demo database. Protected databases are never reset. +# MEGURI_DEMO_DATABASE_URL=postgresql://demo:change-me@127.0.0.1:5432/meguri_demo +MEGURI_DEMO_WORKSPACE_TTL_SECONDS=86400 +MEGURI_DEMO_ACCOUNT_TTL_SECONDS=172800 +MEGURI_DEMO_REQUEST_TTL_SECONDS=7200 +MEGURI_DEMO_EVENT_TTL_SECONDS=21600 +MEGURI_DEMO_PURGE_GRACE_SECONDS=600 +MEGURI_SKIP_MIGRATIONS=false +RUST_LOG=meguri=info,cot=warn +# Tenant-specific JSM deployment secrets. Replace UUID_WITHOUT_DASHES with uppercase tenant ID. +# MEGURI_JSM_UUID_WITHOUT_DASHES_EMAIL=integration@example.com +# MEGURI_JSM_UUID_WITHOUT_DASHES_TOKEN=from-your-secret-manager diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..e56a079 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,53 @@ +name: Checks +on: [push, pull_request] +permissions: + contents: read +jobs: + test: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:17 + env: + POSTGRES_PASSWORD: test-only + POSTGRES_DB: meguri_test + ports: ["5432:5432"] + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 5s --health-timeout 5s --health-retries 10 + env: + MEGURI_TEST_DATABASE_URL: postgresql://postgres:test-only@127.0.0.1:5432/meguri_test + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@1.97.0 + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + - run: cargo fmt --check + - run: cargo clippy --locked --all-targets -- -D warnings + - run: cargo test --locked + - run: cargo test --locked --test integration -- --ignored --test-threads=1 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npx playwright install --with-deps chromium + - name: Browser and API checks + env: + DATABASE_URL: postgresql://postgres:test-only@127.0.0.1:5432/meguri_test + MEGURI_SEED_DEMO: "true" + MEGURI_ALLOW_SIGNUP: "true" + MEGURI_BROWSER_CHANNEL: chromium + run: | + cargo build --locked + target/debug/meguri > server.log 2>&1 & + for i in $(seq 1 60); do curl -fsS http://127.0.0.1:3000/health/ready && break || sleep 1; done + npm run test:browser + - uses: actions/upload-artifact@v4 + if: always() + with: + name: browser-evidence + path: | + test-results/ + server.log diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea91788 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/target +/opsgenie-master/ +.env +.env.* +!.env.example +/node_modules/ +/test-results/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..713b61c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,44 @@ +# Working on Meguri + +Read `docs/product.md`, `docs/architecture.md`, `docs/development.md`, and `docs/testing.md` before changing behavior. They are the maintained specification. Update them with a behavior change, not after it. Keep this entry point short; put detailed decisions in `docs/`. + +## Product boundaries + +Meguri is the source of truth for on-call schedules, approved replacements, escalation policies, and their provider configuration. External providers receive the desired configuration and execute alerting. Meguri must never become an alert receiver or implement escalation timers/incident notifications. + +All built-in UI text, labels, placeholders, accessible names and errors belong in the in-memory catalog `static/locales/messages.json`, with complete English, Russian and French entries. Templates and presentation code must use catalog keys, never inline human phrases. User-entered names/data remain unchanged. Store user language separately from scheduling state; unset follows a supported browser language, otherwise English. Source comments and repository documentation are English. Speak to collaborators in their language. Use ordinary forms, not a user-facing DSL. + +## Module boundaries + +- `domain`: typed state and permissions; no HTTP, database, or provider dependencies. +- `engine`: deterministic schedule calculation with an explicit clock/range; no I/O. +- `store` / `service`: Cot ORM and atomic PostgreSQL statement boundaries, authorization, optimistic versions, approval workflow, audit, durable sync requests. +- `providers`: provider translation and HTTP only. Scheduling decisions belong in the engine. Never silently omit an unsupported provider feature. +- `worker`: retryable reconciliation, drift checks, publication checkpoints. Multiple replicas must not publish a workspace concurrently. +- `web`: thin authenticated handlers. Every workspace operation checks its workspace scope on the server. +- `static`: localized HTML, CSS, Alpine.js. Never inject user text with `innerHTML` or `x-html`. + +## Invariants + +1. Workspace permissions are checked on every API and background operation. Membership in one workspace never grants access to another. Workspace owners administer only their workspace. There is no workspace hierarchy or implicit team. +2. A request is not an override until all required participants approve or a user with the force permission applies it. Every action is audited; its comment is optional, including forced application. Revalidate ownership, availability, and overlaps at application time. A subsequent approved replacement supersedes only overlapping effective spans; preserve original requests and history. +3. Intervals are half-open `[start, end)`. Every schedule has its own handover in the workspace's IANA zone, snapshotted in workspace history. User timezone affects display only and defaults to the browser zone. Reject ambiguous/nonexistent local handovers instead of guessing. Never substitute a fixed UTC offset. +4. Base rotations have an explicit ordered participant list and anchor. An override does not advance or reset the base rotation. +5. Dependent coverage uses the effective fallback schedule at its own handover, including approved replacements. Country holidays skip only people in that country; general rotations remain continuous. Legacy global holidays retain direct-fallback behavior. Detect dependency cycles. Preserve approved history when rules change. +6. Save state, audit, and the sync request in one transaction. Use revision checks to reject stale edits. Never hold a workspace write lock while calling a provider. +7. External drift blocks publication by default. Overwrite is an explicit workspace policy. Reconcile only owned resources. Persist successful remote writes so partial failures can recover. +8. Provider secrets come from the deployment secret store/environment, never API responses, audit snapshots, examples, or logs. +9. Report approval and publication as separate statuses. Never label an unverified external write as synchronized. +10. Scheduling contacts never gain login or workspace access through an email entry. Country defaults to CY. Imports use the same country codes and are add-only, server-previewed, and revision-checked. Resolve external identities exactly before remote writes; never guess accounts from similar names. + +## Required workflow + +Implement a small coherent change; add meaningful regression tests for scheduling, authorization, workflow, or reconciliation behavior it changes. Run `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test`, and the PostgreSQL integration suite described in `docs/development.md`. Inspect the actual browser for UI work. Do not add tests that only mirror constants or formatting. + +Do not silently skip unavailable checks: record the limitation. Keep `Cargo.lock` committed for this application. New schema changes get a new migration; never edit an applied migration. Do not commit credentials, local database files, build output, or `opsgenie-master/`. + +The ignored Terraform directory is reference material only. Store anonymized behavioral examples and tests in the repository so other agents do not depend on it. Preserve its business patterns, not its historical workarounds (manual DST tables, cached remote on-call lookup, multiple Terraform applies). + +## Adding a provider + +Document capability differences, credential requirements, identity mappings, ownership conventions, pagination, retries, idempotency, and drift semantics. Implement the shared provider contract, a fake-server contract test, and a dry-run path before enabling real writes. Do not hide capabilities behind vendor-specific scheduling logic in the engine. Live integration tests require a dedicated external test account; ordinary tests must never call a real provider. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..35dc6d0 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3531 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "askama" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6024d73179f43f15ccd2b881bfea6fee7f3a46ec53f33b52210dea749ebebaa4" +dependencies = [ + "askama_macros", + "itoa", + "percent-encoding", + "serde", + "serde_json", +] + +[[package]] +name = "askama_derive" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071ee5ebf2138e3ad180e0aacf6940c2cab5e6d8333741d9925c7bee2b153f39" +dependencies = [ + "askama_parser", + "memchr", + "proc-macro2", + "quote", + "rustc-hash", + "syn 3.0.6", +] + +[[package]] +name = "askama_macros" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "643e1c7cbb6aec1d920332fe51a7c0d8219e273dcb8602db03f5263e4d16487b" +dependencies = [ + "askama_derive", +] + +[[package]] +name = "askama_parser" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c5ae75772275d268b03ab8bdccdd12117b6169ee23256942b34e46c9f476583" +dependencies = [ + "rustc-hash", + "unicode-ident", + "winnow", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.1", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf 0.12.1", +] + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "codemap" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e769b5c8c8283982a987c6e948e540254f1058d5a74b8794914d4ef5fc2a24" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "cot" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4885a939f3f5911649f77328b1059a42b347d4159c460b8e91e624507fb49211" +dependencies = [ + "ahash", + "askama", + "async-trait", + "axum", + "blake3", + "bytes", + "chrono", + "chrono-tz", + "clap", + "cot_core", + "cot_macros", + "derive_builder", + "derive_more", + "email_address", + "form_urlencoded", + "futures-core", + "futures-util", + "grass_compiler", + "hex", + "http", + "http-body-util", + "humantime", + "indexmap", + "is_terminal_polyfill", + "mime", + "mime_guess", + "multer", + "password-auth", + "pin-project-lite", + "sea-query", + "sea-query-sqlx", + "securer-string", + "serde", + "serde_json", + "sqlx", + "subtle", + "thiserror", + "time", + "tokio", + "toml", + "tower", + "tower-sessions", + "tracing", + "url", +] + +[[package]] +name = "cot_codegen" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc699bdab0ce9c3c5b78fa498071d07762ba451f0c8e27ea3c11e9b77f4722ca" +dependencies = [ + "darling 0.23.0", + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "cot_core" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6b20b72eb437a91f2876377a14edb358b1ba7cac016a90fcb0cc424ea5833bc" +dependencies = [ + "askama", + "axum", + "backtrace", + "bytes", + "cot_macros", + "derive_more", + "form_urlencoded", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "indexmap", + "serde", + "serde_html_form", + "serde_json", + "serde_path_to_error", + "sync_wrapper", + "thiserror", + "tower", + "tower-sessions", +] + +[[package]] +name = "cot_macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7321f4664bea3ba6c2138343fa88e9b9c3e8e1d7ad68ff8ddad7d4c7ec771435" +dependencies = [ + "askama_derive", + "cot_codegen", + "darling 0.23.0", + "heck", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crossbeam-queue" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.119", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +dependencies = [ + "serde", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + +[[package]] +name = "encoding_rs" +version = "0.8.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5ef0006ac9ab233c38522f5ae99cae3625151de8f706cacee1cba4b8e2832a" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" + +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "grass_compiler" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d9e3df7f0222ce5184154973d247c591d9aadc28ce7a73c6cd31100c9facff6" +dependencies = [ + "codemap", + "indexmap", + "lasso", + "once_cell", + "phf 0.11.3", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "humantime" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lasso" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e14eda50a3494b3bf7b9ce51c52434a761e383d7238ce1dd5dcec2fbc13e9fb" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + +[[package]] +name = "meguri" +version = "0.1.0" +dependencies = [ + "argon2", + "chrono", + "chrono-tz", + "cot", + "csv", + "email_address", + "http-body-util", + "reqwest", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror", + "tokio", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + +[[package]] +name = "multiversion" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ca4bea16ffc3f443cf7d866912118196bfef4c6a1556ca00f9f9b00bb43f7c" +dependencies = [ + "multiversion-macros", +] + +[[package]] +name = "multiversion-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d416831a7317ef4b08bee00b69cbbb9c8763da7959a7026244d6266869f9c83" +dependencies = [ + "proc-macro2", + "quote", + "rustversion", + "syn 3.0.6", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-auth" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2a4764cc1f8d961d802af27193c6f4f0124bd0e76e8393cf818e18880f0524" +dependencies = [ + "argon2", + "getrandom 0.2.17", + "password-hash", + "rand_core 0.6.4", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared 0.12.1", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.8", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.3", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sea-query" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "546040c653a705e60ec65ecd3191a809603734bebbc225775916dea9ae409b31" +dependencies = [ + "chrono", +] + +[[package]] +name = "sea-query-sqlx" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4eaa419cdb9157da1361186b1959983eb2ea0dcb9a3c69dc45c449ecb2af8fef" +dependencies = [ + "sea-query", + "sqlx", +] + +[[package]] +name = "securer-string" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5639ae7dad6b48a14450ec0383c6edd45d569b5cc22d3d7a9b838115c4d5bc5" +dependencies = [ + "libc", + "subtle", + "zeroize", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_html_form" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f0346d7a342ab90f405cfc08f25d15075f944f42fcabbc5eac923829fa6d228" +dependencies = [ + "form_urlencoded", + "indexmap", + "serde_core", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "sqlx" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" +dependencies = [ + "base64", + "bytes", + "cfg-if", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.16.1", + "hashlink", + "indexmap", + "log", + "memchr", + "percent-encoding", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", +] + +[[package]] +name = "sqlx-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.119", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" +dependencies = [ + "cfg-if", + "dotenvy", + "either", + "heck", + "hex", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.119", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" +dependencies = [ + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest 0.11.3", + "dotenvy", + "either", + "futures-core", + "futures-util", + "generic-array", + "log", + "percent-encoding", + "serde", + "sha1", + "sha2 0.11.0", + "sqlx-core", + "thiserror", + "tracing", +] + +[[package]] +name = "sqlx-postgres" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "rand 0.10.3", + "serde", + "serde_json", + "sha2 0.11.0", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" +dependencies = [ + "atoi", + "chrono", + "flume", + "form_urlencoded", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "sqlx-core", + "thiserror", + "tracing", + "url", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "1.1.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +dependencies = [ + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.15+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-cookies" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "151b5a3e3c45df17466454bb74e9ecedecc955269bdedbf4d150dfa393b55a36" +dependencies = [ + "axum-core", + "cookie", + "futures-util", + "http", + "parking_lot", + "pin-project-lite", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "tracing", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tower-sessions" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "518dca34b74a17cadfcee06e616a09d2bd0c3984eff1769e1e76d58df978fc78" +dependencies = [ + "async-trait", + "http", + "time", + "tokio", + "tower-cookies", + "tower-layer", + "tower-service", + "tower-sessions-core", + "tower-sessions-memory-store", + "tracing", +] + +[[package]] +name = "tower-sessions-core" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "568531ec3dfcf3ffe493de1958ae5662a0284ac5d767476ecdb6a34ff8c6b06c" +dependencies = [ + "async-trait", + "base64", + "futures", + "http", + "parking_lot", + "rand 0.9.5", + "serde", + "serde_json", + "thiserror", + "time", + "tokio", + "tracing", +] + +[[package]] +name = "tower-sessions-memory-store" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "713fabf882b6560a831e2bbed6204048b35bdd60e50bbb722902c74f8df33460" +dependencies = [ + "async-trait", + "time", + "tokio", + "tower-sessions-core", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..37d7403 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "meguri" +version = "0.1.0" +edition = "2024" +rust-version = "1.97" +description = "Provider-independent on-call scheduling and configuration control plane" +publish = false + +[dependencies] +argon2 = "0.5.3" +cot = { version = "=0.7.0", default-features = false, features = ["postgres", "json", "test"] } +chrono = { version = "0.4.45", features = ["serde"] } +chrono-tz = "0.10.4" +csv = "1.4" +email_address = "0.2" +reqwest = { version = "0.12.28", default-features = false, features = ["json", "rustls-tls"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +sha2 = "0.10.9" +thiserror = "2.0" +tokio = { version = "1.53", features = ["full"] } +tower-http = { version = "0.6", features = ["trace", "limit"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +uuid = { version = "1.23", features = ["v4", "serde"] } + +[dev-dependencies] +http-body-util = "0.1" +tower = { version = "0.5", features = ["util"] } + +[profile.release] +lto = "thin" +strip = true diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a86bc8d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM rust:1.97-bookworm AS build +WORKDIR /build +COPY Cargo.toml Cargo.lock ./ +COPY src ./src +COPY static ./static +RUN cargo build --locked --release + +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/* +COPY --from=build /build/target/release/meguri /usr/local/bin/meguri +COPY static/vendor/ALPINE-LICENSE.md /usr/share/licenses/meguri/alpine-MIT.md +USER 65532:65532 +ENV MEGURI_BIND=0.0.0.0:3000 +EXPOSE 3000 +ENTRYPOINT ["/usr/local/bin/meguri"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..1982a2a --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +# Meguri + +A shared source of truth for on-call rotations, replacements, escalation policies, and routing. Rust + Cot 0.7 (HTTP, ORM, migrations), PostgreSQL, and locally vendored Alpine.js. The interface is English. Providers receive calculated intervals and execute notifications/escalation timers. + +## Start locally + +On Windows with the PostgreSQL server binaries installed: + +```powershell +./scripts/dev.ps1 +``` + +Open http://127.0.0.1:3000. Explicit demo accounts: `alice`, `bob`, `carol`; password `meguri-demo-only`. Stop with `./scripts/stop-dev.ps1`. Pass `-PostgresBin PATH` if PostgreSQL is elsewhere. Local data and logs live in ignored `target/local/`. + +On other platforms, create a PostgreSQL database, set the environment variables in `.env.example`, then run `cargo run --locked`. Use `postgresql://` URLs. Enable `MEGURI_ALLOW_SIGNUP=true` to create accounts; `MEGURI_SEED_DEMO=true` works only on loopback. Environment files are not automatically loaded. + +## Implemented + +- Independent workspaces, local accounts, granular workspace grants, and durable audit/history. +- Calendar-day rotations with IANA timezones, ordered participants, anchors, absence fallback, holidays, and escalation backup schedules. +- Replacements and atomic reciprocal swaps; confirmation by the other employee or forced application with a reason. +- Durable publication, optimistic revisions, worker leases, retries, checkpoints, external drift protection, and a downloadable preview. +- Mock provider and a JSM Operations API adapter for schedules, escalations, and routing. + +## Working through agents + +Start with [AGENTS.md](AGENTS.md). Describe intended behavior and examples to the agent; it must update the specification, implementation, and relevant tests together. [Product](docs/product.md), [architecture](docs/architecture.md), [test strategy](docs/testing.md), [development](docs/development.md), and [deployment](docs/deployment.md) are maintained project inputs. + +```text +cargo fmt --check +cargo clippy --all-targets -- -D warnings +cargo test +cargo test --test integration -- --ignored --test-threads=1 +``` + +The integration command requires `MEGURI_TEST_DATABASE_URL` for a disposable PostgreSQL database. CI runs it explicitly; ordinary unit tests need no database or external account. Browser checks are in `tests/browser.cjs`. + +## Current boundaries + +OIDC identity linking has a schema boundary but no login adapter yet. JSM has local HTTP contract coverage and still needs acceptance testing against a dedicated JSM test team and your subscription. GKE manifests are deployment templates, not an already deployed environment. No alert ingestion or notification engine is included. + +The ignored `opsgenie-master/` directory is local reference material. Its behavioral examples are captured in the product contract and tests; application and CI do not depend on it. + +Demo mode is disposable: `MEGURI_SEED_DEMO=true` resets only positively marked demo data on restart and expires temporary resources while running. Unmarked/normal databases are protected and trigger an isolated demo database with a permanent warning. See [demo lifecycle and retention settings](docs/development.md#demo-lifecycle). Alice leads two generated examples; Bob demonstrates Engineering on-call, Carol daily Support shifts. All use `meguri-demo-only`. diff --git a/deploy/gke.yaml b/deploy/gke.yaml new file mode 100644 index 0000000..539a872 --- /dev/null +++ b/deploy/gke.yaml @@ -0,0 +1,94 @@ +# Render placeholders before use. Apply the migration Job first, wait for completion, +# then apply this file. See docs/deployment.md for IAM, secrets and ingress. +apiVersion: v1 +kind: ServiceAccount +metadata: + name: meguri + annotations: + iam.gke.io/gcp-service-account: MEGURI_GSA@PROJECT_ID.iam.gserviceaccount.com +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: meguri-config +data: + MEGURI_BIND: 0.0.0.0:3000 + MEGURI_PUBLIC_URL: https://meguri.example.internal + MEGURI_ROLE: all + MEGURI_SKIP_MIGRATIONS: "true" + MEGURI_ALLOW_SIGNUP: "false" + MEGURI_SEED_DEMO: "false" + RUST_LOG: meguri=info,cot=warn +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: meguri +spec: + replicas: 2 + selector: + matchLabels: {app: meguri} + template: + metadata: + labels: {app: meguri} + spec: + serviceAccountName: meguri + terminationGracePeriodSeconds: 60 + securityContext: + runAsNonRoot: true + seccompProfile: {type: RuntimeDefault} + initContainers: + - name: cloud-sql-proxy + restartPolicy: Always + image: gcr.io/cloud-sql-connectors/cloud-sql-proxy:2.14.1 + args: ["--private-ip", "--structured-logs", "--port=5432", "--health-check", "--http-address=0.0.0.0", "--http-port=9090", "PROJECT_ID:REGION:INSTANCE"] + securityContext: + allowPrivilegeEscalation: false + capabilities: {drop: [ALL]} + resources: + requests: {cpu: 100m, memory: 128Mi} + limits: {memory: 256Mi} + startupProbe: + httpGet: {path: /startup, port: 9090} + periodSeconds: 2 + failureThreshold: 30 + containers: + - name: meguri + image: REGION-docker.pkg.dev/PROJECT_ID/REPOSITORY/meguri:RELEASE + ports: [{name: http, containerPort: 3000}] + envFrom: + - configMapRef: {name: meguri-config} + - secretRef: {name: meguri-secrets} + securityContext: + readOnlyRootFilesystem: true + allowPrivilegeEscalation: false + capabilities: {drop: [ALL]} + resources: + requests: {cpu: 250m, memory: 256Mi} + limits: {memory: 512Mi} + startupProbe: + httpGet: {path: /health/ready, port: http} + periodSeconds: 2 + failureThreshold: 60 + readinessProbe: + httpGet: {path: /health/ready, port: http} + livenessProbe: + httpGet: {path: /health/live, port: http} +--- +apiVersion: v1 +kind: Service +metadata: + name: meguri +spec: + type: ClusterIP + selector: {app: meguri} + ports: [{port: 80, targetPort: http}] +--- +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: meguri +spec: + minAvailable: 1 + selector: + matchLabels: {app: meguri} diff --git a/deploy/migrate.yaml b/deploy/migrate.yaml new file mode 100644 index 0000000..73a48db --- /dev/null +++ b/deploy/migrate.yaml @@ -0,0 +1,46 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: meguri-migrate-RELEASE +spec: + backoffLimit: 0 + activeDeadlineSeconds: 300 + ttlSecondsAfterFinished: 86400 + template: + spec: + restartPolicy: Never + serviceAccountName: meguri + securityContext: + runAsNonRoot: true + seccompProfile: {type: RuntimeDefault} + initContainers: + - name: cloud-sql-proxy + restartPolicy: Always + image: gcr.io/cloud-sql-connectors/cloud-sql-proxy:2.14.1 + args: ["--private-ip", "--structured-logs", "--port=5432", "--health-check", "--http-address=0.0.0.0", "--http-port=9090", "PROJECT_ID:REGION:INSTANCE"] + securityContext: + allowPrivilegeEscalation: false + capabilities: {drop: [ALL]} + resources: + requests: {cpu: 100m, memory: 128Mi} + limits: {memory: 256Mi} + startupProbe: + httpGet: {path: /startup, port: 9090} + periodSeconds: 2 + failureThreshold: 30 + containers: + - name: migrate + image: REGION-docker.pkg.dev/PROJECT_ID/REPOSITORY/meguri:RELEASE + envFrom: + - configMapRef: {name: meguri-config} + - secretRef: {name: meguri-secrets} + env: + - {name: MEGURI_ROLE, value: migrate} + - {name: MEGURI_SKIP_MIGRATIONS, value: "false"} + securityContext: + readOnlyRootFilesystem: true + allowPrivilegeEscalation: false + capabilities: {drop: [ALL]} + resources: + requests: {cpu: 250m, memory: 128Mi} + limits: {memory: 256Mi} diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..72f7899 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,120 @@ +# Architecture + +See [workspace variables and visual matrix rules](proposals/workspace-templates.md) for design rationale; implemented behavior is specified in product.md. + +A modular Rust application serves the web/API and runs a durable publication worker. The same executable can run either or both roles. PostgreSQL is the sole persistence and coordination service. Production targets GKE with Cloud SQL for PostgreSQL; no Redis or message broker is required. + +## Persistence and concurrency + +Users, sessions, workspaces, memberships, audit, history, provider checkpoints, and sync jobs are Cot ORM models. Bounded typed documents use serialized JSON in text columns with a PostgreSQL JSON validity constraint and optimistic revision. Cot 0.7 lacks JSON/UUID model fields, so UUIDs also use validated strings. Do not introduce another ORM for these types. + +Cot 0.7 has no transaction API. `Store::commit` uses one parameterized data-modifying PostgreSQL CTE through Cot `Database::raw_with`: compare-and-swap checks revision and authorization, then writes membership, history, audit, and the durable sync request atomically. A constraint failure rolls the entire statement back. Ordinary reads, sessions, and checkpoints use Cot queries/models. Pending requests do not change effective coverage. Published state is separate from desired state. + +The worker conditionally claims a five-minute lease token. Publication has a four-minute deadline and HTTP requests a twenty-second timeout; no document lock spans HTTP. Verified operations get checkpoints. Completion updates status and audit atomically, guarded by the lease token. Newer revisions remain queued when older runs succeed or fail. Jobs retry with bounded backoff; fifteen-minute reconciliation extends the horizon and detects drift. Crashed workers leave recoverable leases. This is not a distributed fencing protocol: read-back/conflict handling remains essential after process suspension or an uncertain remote timeout. + +Run Cot migrations once in a deployment job, then start replicas with `MEGURI_SKIP_MIGRATIONS=true`. The initial migration creates frozen Cot models; the second adds scoped foreign keys, constraints, and indexes in one atomic PostgreSQL block. A JSM destination can have only one local workspace binding. Never edit applied migrations. + +## Scheduling model + +Rotation and priority-owner schedules are separate typed variants. Priority-owner schedules reference another schedule as fallback; the dependency graph must be acyclic. Evaluation splits at local handovers and override boundaries. Resolve a schedule's approved overrides before letting other schedules depend on it. Adjacent equivalent intervals merge; the result records the assignment reason. + +The engine receives explicit state and time bounds. It must produce the same result regardless of database, network, or provider. Approved changes and dated revisions support historical inspection; future configuration changes must not rewrite past history. + +`configuration` contains pure dependency-aware removal planning and route permutation operations. Services authorize, revalidate the plan against the submitted revision and explicit current time, then commit through the existing history/audit/outbox boundary. Providers still receive ordinary schedules, policies, and consecutively ordered routes. + +`absence` computes pure, bounded multi-schedule replacement plans and before/after summaries. Range inputs use each schedule's own handover. Requests reference the absence that produced them, permitting approval to revalidate original ownership against the same document with that absence removed, while checking substitute availability against current state. Automatic coverage fallback remains an engine rule. Draft previews never write; absence and all generated requests commit as one state transition. Unavailable assignments are diagnostics, never eligible future publication periods, including single-participant rotations. + +`generation` is a pure typed data/binding interpreter and materializer. `generation_service` validates one workspace revision and authorizes that workspace, then atomically persists its variables, generated resources, history, audit and sync request through the same commit boundary. Provider calls remain outside activation. Workspace documents retain exact variables, rule definitions, and resource ownership manifests so expansion is reproducible. Legacy migration 5 introduced variables; migration 8 consolidates them into the workspace document. No applied migration is changed. + +## Identity and permissions + +Session cookies are HttpOnly, SameSite=Lax, and Secure under HTTPS. Mutations require same-origin requests and a session CSRF token. Passwords use Argon2. Resource lookup always includes workspace context and server-side membership. External identity linking is a future adapter, never a replacement for internal user IDs. + +Replacement preview is a read-only service using the same engine, revision, participant and permission checks as submission. Calendar actions use server-generated interval boundaries; inclusive range inputs are converted through schedule handovers on the server. Volunteering changes the initiator, not the approval requirements. Comments remain in the backward-compatible reason/force_reason fields but are optional action annotations. Current-shift clipping uses one explicit server timestamp per validation/application. + +## Provider contract + +The `Provider` trait consumes canonical `Publication` data and a checkpoint store. Plugins are Rust modules linked at build time, without an unstable dynamic-library ABI. New providers add a typed integration variant, capability validation, dispatcher constructor, module, and contract tests. No engine changes should be needed. + +JSM publishes finite single-person rotations with exact timestamps (maximum 500 per schedule). Escalations resolve local IDs to remote IDs; routing priorities become consecutive indices. Deterministic ownership names allow recovery when a create succeeds but its local checkpoint is lost. Read-back checks managed fields, arrays, timestamps, and external overrides. Only checkpointed owned resources may be deleted. Workspace-specific environment credentials never enter documents or audit. + +Reconciliation covers configuration, not user notification preferences, forwarding rules, or delivered notifications. JSM has no cross-resource transaction here: partially published revisions stay failed until retries complete. A remote edit between GET and PATCH cannot be fenced with the local lease. A dedicated-team acceptance test must compare actual provider timelines as well as CRUD. + +JSM REST references (verified September 2026): +- https://developer.atlassian.com/cloud/jira/service-desk-ops/rest/v2/api-group-schedules/ +- https://developer.atlassian.com/cloud/jira/service-desk-ops/rest/v2/api-group-schedule-rotations/ +- https://developer.atlassian.com/cloud/jira/service-desk-ops/rest/v2/api-group-schedule-overrides/ +- https://developer.atlassian.com/cloud/jira/service-desk-ops/rest/v2/api-group-escalations/ +- https://developer.atlassian.com/cloud/jira/service-desk-ops/rest/v2/api-group-routing-rules/ + +Random recipients and escalation repeats require JSM Premium/Enterprise. Provider validation must not pretend a feature is available merely because it is representable in JSON. + +JSM documents `repeat.waitInterval=0` as disabling repeats even when count is nonzero; the reference Terraform uses this combination. Enabling repeats requires a positive interval. A disabled local count maps to provider count 1 / interval 0 because the provider count minimum is 1. The reference's immediate step uses delay 0; verify it in live acceptance because the published schema lists a minimum of 1. Never silently change immediate notification to a one-minute delay. +## Scheduling metadata and import boundaries + +WorkspaceDocument owns versioned scheduling people, country holidays, and per-schedule handovers. Contacts do not become authentication principals. Workspace-directory reads are workspace-scoped; identity IDs cannot be invented or linked through unverified email. CSV parsing and comparison are pure functions; preview is read-only, and import rechecks the workspace revision and commits history, audit, and outbox together. Working intervals, variables, schedules and access updates share the same workspace revision; working-interval changes atomically update its route snapshots. + +Workspace creation atomically creates its document, owner membership, initial history, audit and sync request. There is no parent entity or hidden default team. Migration 8 replaces empty legacy tenant/team tables and refuses populated legacy databases; demo provenance checks run before migration. + +Migration 3 upgrades old JSON snapshots without changing old handover boundaries or global-holiday meaning and introduces workspace working intervals. Migration 4 adds user country (CY) and an optional display timezone (null follows the browser). Current scheduling state copies the workspace timezone; historical documents retain the timezone effective at their revision. Country-profile changes propagate as dated workspace revisions, while display-timezone preferences remain outside the engine. + +Email-only recipients resolve through [Jira user search](https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-user-search/#api-rest-api-3-user-search-get) with an exact, active, unique email match. The authenticated service account needs Browse users and groups permission and appropriate Jira API scopes in addition to Operations access. Profile visibility may hide emails; explicit Atlassian account IDs remain the fallback. All required recipients resolve before the first provider write. Tests inject a localhost directory and never query real employee data. + +## Demo runtime boundary + +`demo_runtime` chooses the database before Cot migrations. A separate `public.meguri_runtime_guard` records last startup mode; proof and reset recheck it under a PostgreSQL advisory transaction lock. Empty-DB claiming checks user relations. Reset enumerates only Meguri application tables, uses no CASCADE, and preserves migration/provenance metadata. Distinct application names detect another active client. A protected configured DB triggers a separate database and an immutable process notice surfaced by `/api/session`; no credentials are exposed. Normal startup marks its database normal and never schedules cleanup. + +Migration 6 adds nullable demo deletion timestamps and seed pins. Cleanup gates every write on demo provenance and uses explicit creation timestamps and a supplied clock. Workspaces expire as complete configuration aggregates, revisions invalidate in-flight edits, listings/access exclude expired resources, and a later CTE deletes dependent rows in one statement. Request expiry saves a new historical document and outbox job. History compaction retains a boundary snapshot. A minute timer refreshes expired seed workspaces through the ordinary services; only the five baseline account identities are permanent during a run. External provider writes are disabled in demo at both configuration and dispatch boundaries. + +## Presentation catalog + +Migration 7 adds nullable en/ru/fr user language. A LazyLock Rust catalog and eagerly loaded browser catalog share messages.json. Parameter substitution is single-pass text, never HTML. API diagnostics retain English compatibility and are localized at presentation. Account language updates use the profile write path without scheduling/outbox changes. Navigation uses browser history and ordinary links; stale workspace responses cannot replace the currently selected workspace. + +Cover request date/time inputs are resolved through the engine's IANA wall-clock resolver on the server. Optional clocks preserve legacy schedule-handover API behavior when omitted. Preview and mutation share this normalization path and the same ownership/availability validation. + +Audit reads enrich payloads from scoped history snapshots and current directory fallbacks without rewriting stored events. Workspace mutation audit timestamps reuse the effective timestamp returned by the history CTE, ensuring the event resolves against its own revision. + + +Applied requests retain original legs and optionally store effective_legs (None means original legs for legacy documents; an empty list means fully superseded). Application subtracts new spans from older active spans, preserving both sides of partial intervals. Engine, provider identities and configuration protections use effective spans; all updates share the existing revision-checked transaction. + +Binding preview uses a presentation-only formatter registry for people, schedules, escalation policies and routing rules. A map built from the already authorized client state resolves known IDs/exact emails to shallow name/type/identity objects in a recursive walk. Unknown references and scalar values remain unchanged; generated-output metadata stays an example. No extra network queries or changes to binding values, generator types or saved JSON. + +Generator issues carry optional rule/block/variable UUIDs for navigation, independent of localized paths. The client never publishes when navigating diagnostic links. Diagnostics always stay within the authorized workspace. CoverRange optionally contains exact UTC bounds and a schedule ID, validated inside the absence and declared duty days. Overlap checks compare effective intervals per schedule; exact ranges on distinct schedules may coexist. Existing bulk duty-day ranges keep their behavior. + +CoverRange.local carries optional schedule scope plus local date/time bounds. The engine resolves both via the workspace IANA zone, rejecting DST gaps/folds and invalid bounds. Existing day-only and exact-UTC API ranges remain compatible; the UI emits local ranges exclusively. + +Coverage diagnostics distinguish pending approval from missing substitutes. Pending request boundaries split uncovered intervals so partial requests never disguise remaining gaps. The UI shows substitutes and outstanding approvers, links to requests, and keeps publication blocked until approval. Browser regression covers partial pending coverage, remaining gaps, approver filtering, and responsive spacing. + +Schedule setup places narrower variable and generation-rule panels beside a stacked availability sidebar. Absences and holidays remain open; their lists scroll internally. Country holiday groups retain accordions inside the bounded list, with add/import actions opening dialogs. Responsive layout moves the sidebar below the editors. Browser checks cover column position, bounded holiday expansion, visible actions and mobile width. + +Generated routing placement is shown only when manual routes and an enabled route-generating rule coexist. Options name the manual routes in evaluation order; the stored insertion index is unchanged. Browser checks verify hidden controls for generated-only setups and named options/order for mixed routing. + +Resource inventory is a distinct UI component (`resource-inventory.html`). All expanded resource kinds share `resource-details.html`: labeled icon rows, consistent spacing, sequential connectors, and a common source-rule control. Presentation rows and monochrome SVG paths come from pure UI helpers; schedules, routing order and provider state are unchanged. Check rotation, fixed owner, priority fallback, escalation timing/repeats and routing conditions on desktop/mobile. + +Expanded resources use compact type-specific horizontal widgets instead of parameter tables: rotation metadata plus ordered people, priority people plus fallback, escalation steps plus repeat footer, routing conditions plus destination. Shared icon, chip and connector styles keep them consistent. Chains wrap within narrow screens; source links and edit actions retain their common placement. + +The generation-rule editor is a generic technical constructor: visible iteration aliases and collection bindings, an explicit optional condition, and typed output blocks. New rules start without implicit project/responsible-variable detection or generated blocks. Variable names carry no semantic meaning. Compact controls retain the existing typed binding editor, value preview, live server diagnostics and draft semantics. Check no-iteration rules, nested collection bindings, conditions and multiple output types on desktop/mobile. + +Binding selection uses one anchored, viewport-clamped popover beside the originating control; the constructor remains visible and keeps its scroll position. All source kinds, nested lookups/lists, paths, text affixes and live value previews remain supported. Literal escalation delays are directly editable. Done/Escape closes the popover and returns focus. Server and change previews are collapsed summaries with counts and bounded change lists. Browser checks cover context retention, source editing, nested bindings, keyboard dismissal and mobile bounds. + +Generation rule results use the actual authorized server expansion, including schedules, escalation policies and routes, with resolved names and compact settings. The panel is always present with explicit checking, invalid, disabled and empty states; it respects iteration filters and does not simulate generation in the browser. Preview data contains only scheduling resources and scoped people, never integration configuration. + +## Workspace aggregate + +`WorkspaceRow` stores one `WorkspaceDocument` and one revision. The document owns working interval, drift policy, variables, rules, schedules, people, holidays, absences, requests and provider configuration. `WorkspaceMemberRow` stores per-user grants and ownership; there is no parent entity. History, audit, jobs and checkpoints reference one workspace ID. Generator activation and working-interval updates use the ordinary atomic workspace commit. JSM team IDs identify external provider destinations, not internal teams. + +Variable list summaries render nested nodes by type. Object entries prefer a nonempty text `name` or `title`; otherwise they show a bounded key/count summary. People resolve through the scoped directory. Summaries never read a container’s null scalar slot as its content. + +Variable editing uses a bounded, progressively disclosed tree: only the root starts expanded, object rows show names and collection summaries, and nested values expand in place. Values mode hides type/key controls; Structure mode exposes them without losing edits. Duplicate assigns fresh IDs recursively; ordering and live server validation remain intact. Check nested editing, duplication, mode switching and narrow viewports. + +Variable-tree indentation uses stylesheet depth classes, not inline custom properties (blocked by the application CSP). All row contents share the inset, with vertical guides for ancestor levels and smaller but preserved mobile steps. + +Generation map opens from Generation rules as a read-only, draggable/zoomable Cytoscape canvas. Server preview returns actual iteration counts, input contexts, predicate outcomes and produced IDs; no scheduling evaluation is duplicated in JavaScript. Iterated outputs start collapsed into a group; inspecting a node or pass reveals source data and verified resources. Invalid drafts show diagnostics rather than stale resources. The map is a captured draft preview refreshed explicitly, and never applies changes. Bundled Cytoscape 3.33.1 is served locally with its MIT license; no CDN or new framework is required. Browser coverage checks groups, details, source navigation, pan/zoom and mobile layout; engine regression verifies traced pass results. + +The generation map arranges numbered rules in saved execution order from left to right, joined by solid sequence arrows. Each rule owns a vertical column of iterations, conditions and outputs. Dashed secondary edges show data dependencies rather than execution order. Repeated output groups use offset background cards; decorative layers follow dragging and do not receive selection. Browser checks verify rule ordering, aligned output columns and stack movement. + +Map input details display evaluated server values for each block and pass, never serialized bindings. Shared values are deduplicated across passes; differing values are explicitly counted. Generated references collapse to type/name cards, and people resolve by display name. Empty binding metadata and raw reference UUIDs are not presented. Condition operands and escalation recipients/delays come from the same resolver used during expansion. + +The map’s all-passes input overview lists deduplicated upstream sources, not expanded output settings: variables with used binding addresses and resource references grouped by producing block. References internal to the selected whole rule are omitted; selecting an individual output block shows its dependencies. Selecting a concrete pass still exposes its evaluated field values. + +Expanded map resources use type-specific labeled fields and the same compact reference cards as inputs, never JSON dumps. Priority schedules show ordered people, fallback and zoned handover; rotations add anchor/cycle; policies show recipient/timing/condition and repeat behavior; routes show conditions, working window and destination. Reference names resolve from the preview snapshot. Browser regression checks visible names, participant order and absence of UUID metadata. diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..9213d05 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,16 @@ +# GKE and Cloud SQL + +Target: GKE, Cloud SQL for PostgreSQL 17, and Cloud SQL Auth Proxy using Workload Identity. PostgreSQL is the durable queue and state store; no Redis is required. Templates are in `deploy/`. They have not been applied to a cloud project. + +1. Provision a private-IP Cloud SQL instance, database and application login; enable backups/PITR. Use a VPC-native GKE cluster with access to the database VPC. Configure Workload Identity and grant its Google service account Cloud SQL Client. Follow [Google's GKE connection guide](https://cloud.google.com/sql/docs/postgres/connect-kubernetes-engine). +2. Build `Dockerfile`, push your image, and replace project, region, instance, release, service-account, image, and public URL placeholders. Review/pin the proxy image for your rollout; the template uses the version in Google's documented example. Native sidecars are required. Docker/GKE deployment must be validated in your environment. +3. Create the Kubernetes service account and ConfigMap from `gke.yaml`, then the `meguri-secrets` Secret through your deployment secret manager. It needs `DATABASE_URL=postgresql://USER:PERCENT_ENCODED_PASSWORD@127.0.0.1:5432/meguri`. Proxy-to-database transport is encrypted; loopback stays inside the pod. Add tenant-specific JSM `_EMAIL` / `_TOKEN` variables only for configured tenants. Never commit rendered secrets. +4. Apply only `migrate.yaml`, wait for its successful completion, then apply the Deployment, Service and PDB. Migrations use the same image with `MEGURI_ROLE=migrate`. Replicas have `MEGURI_SKIP_MIGRATIONS=true`; do not race migrations during rollout. +5. Add your internal HTTPS ingress. `MEGURI_PUBLIC_URL` must exactly match the browser origin (scheme, host, port); mutations and cookies depend on it. The default Service is ClusterIP. Keep access inside your organization while OIDC is being added. To bootstrap local accounts, temporarily enable signup in that restricted environment, create accounts, then disable signup. Never enable demo seeding outside loopback. +6. Run health checks, a two-user replacement, a tenant-isolation check, and the provider acceptance checklist in `testing.md`. Enable real JSM integration only after preview and acceptance. + +The default Deployment has two replicas serving both web and workers. Conditional database leases serialize each team's publication. Roles can later be split into `web` and `worker` deployments without changing the binary. On interruption, a completed remote step remains checkpointed; an unfinished lease expires after five minutes. + +Monitor failed/conflict sync jobs, oldest `last_success`, and `window_end` approaching the current time. Meguri publishes a finite horizon and must keep running to extend it. Configure operational monitoring around these values; Meguri does not send incident notifications itself. Readiness checks PostgreSQL; liveness checks the process. + +The current polling worker and history/audit reads target team-scale workloads. Benchmark and add query pagination/queue batching before broad multi-tenant SaaS rollout. Configure ingress-level rate limits in addition to per-process login throttling. Password recovery, session administration, OIDC, history archival, and live provider acceptance are subsequent operational work. diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..126e002 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,62 @@ +# Development + +Requirements: Rust 1.97 (Cot itself requires 1.94), PostgreSQL 17+, and a browser. Node is only needed for browser automation or upgrading vendored browser assets; serving the application has no Node build step. + +Use `.env.example` as the list of supported settings. Environment files are not loaded automatically. Start a dedicated PostgreSQL database, set `DATABASE_URL`, then run `cargo run`. Database migrations run before the server accepts requests. + +For an isolated Windows development database, use `scripts/dev.ps1`. It uses installed PostgreSQL binaries, creates data under ignored `target/`, binds the database and app to loopback, enables registration, and explicitly seeds demonstration accounts. `scripts/stop-dev.ps1` stops only the processes started for this workspace. + +Required checks: + +```text +cargo fmt --check +cargo clippy --all-targets -- -D warnings +cargo test +``` + +PostgreSQL integration tests require `MEGURI_TEST_DATABASE_URL` pointing at a disposable database, with CREATEDB for the isolated demo-lifecycle tests. Run `cargo test --test integration -- --ignored --test-threads=1`. Most tests create isolated workspaces/users; lifecycle tests create and remove uniquely named databases to test protection, reset, retention, and reseeding. Never use a production database. + +Browser checks: sign in; inspect the calendar; create a request as one user and approve it as another; verify the publication status separately; verify that a user outside the workspace cannot read it. Use both desktop and narrow viewports. A test failure is not a reason to relax an authorization or scheduling invariant. + +## Operational limitations + +For automated browser checks, run `npm ci`, `npx playwright install chromium`, then `npm run test:browser` against a seeded local app with signup enabled. By default the tests use installed Chrome; set `MEGURI_BROWSER_CHANNEL=chromium` for downloaded Chromium. The original suite creates its own workspace. `tests/browser-calendars.cjs` creates an isolated test account and checks email participants/absences, country defaults, reactive CSV import, workspace working hours, policy flows, and display-timezone isolation. `tests/browser-configuration.cjs` uses another isolated account to check removal dependencies/redirects, native routing drag/drop, saved positions, insertion/removal renumbering, and mobile controls. Screenshots go under ignored `test-results/`. + +Windows development needs complete PostgreSQL server binaries and their `share/` data files, not a client-only installation. Pass `-PostgresBin` to use a portable distribution. The loopback-only local database uses trust authentication and is not a production configuration. The script serves a copy of the executable so Cargo can rebuild while it runs. + +Read [testing.md](testing.md) when changing rules and [deployment.md](deployment.md) before preparing GKE deployment. + +## Demo lifecycle + +`MEGURI_SEED_DEMO=true` enables disposable demo mode, not just seed insertion. It is limited to loopback and a single `all` or `web` process. Startup verifies the previous database mode before migrations, resets only positively marked demo application data, migrates, and seeds two examples. Normal startup records `normal`, including migration/worker roles. A missing or unreadable provenance marker never grants deletion authority. The existing development `meguri` DB will therefore be preserved on its first transition to this mode. + +When the configured DB is protected, Meguri selects a separate `meguri_demo__` DB. The application login needs CREATEDB, or an administrator must supply an empty `MEGURI_DEMO_DATABASE_URL` with suitable permissions. Credentials are never shown in the browser/log notice. Protected fallback candidates are skipped, never dropped or overwritten. Isolation failure aborts startup. Do not manually mark a production database as demo. Another active client blocks reset; stop the old demo process before restarting. + +The permanent header explains creation-based expiry and any protected-DB fallback. Defaults: workspaces 24 hours; visitor accounts 48 hours; requests 2 hours; audit/history 6 hours; physical purge 10 minutes after soft deletion. Configure seconds with the `MEGURI_DEMO_*` settings in `.env.example` (60 seconds–30 days). Sweeps run every minute. Nested configuration expires with its owning aggregate to preserve dependencies. Seed accounts remain; baseline workspaces are reseeded after expiry. No cleanup runs in normal mode. Demo rejects real provider integrations/publication. + +Alice (`meguri-demo-only`) leads both examples. Bob belongs to Engineering; Carol belongs to Support. Engineering uses `projects`, `responsible`, and `on_call`; Support has independent `queues`, `agents`, and `lead` variables. All generated state is activated through the regular preview/commit code path with the mock provider. + +Additional browser suites: `browser-absence.cjs` checks collapsed holidays, DD-MM-YYYY input, live bulk absence impact, range replacements and force; `browser-generation.cjs` checks demos, their permanent notice and isolated memberships, visual data/binding editing, early errors, preview without writes, stable generated IDs, and mobile forms. Restart after browser tests to return to the four clean examples. + +OIDC is intentionally deferred; local password authentication is implemented first. No external JSM account is needed to run the mock provider. JSM writes require explicit integration configuration and workspace-scoped environment credentials. Contract tests use a fake HTTP server, never a production account. + +Workspace configuration edits take effect when saved. Historical queries use saved configuration revisions. There is no user-configurable delayed activation of a whole configuration revision in the first release. + +`tests/browser-cover.cjs` exercises daily volunteering/approval and optional comments in a separate disposable workspace, with desktop and mobile screenshots. Configuration navigation is grouped under Setup. + +Navigation and language coverage: tests/browser-navigation.cjs checks direct menu/workspace links, reload and browser history, saved language versus browser preference, Russian/French display, safe interpolation, catalog completeness and the prohibition on literal template copy. It runs in npm run test:browser. + + +Demo workspaces import `src/demo-holidays-2026.csv` through the regular holiday service: 15 Cyprus dates supplied by the project owner and 18 Japanese holidays/days off from https://www8.cao.go.jp/chosei/shukujitsu/gaiyou.html (2026 table, verified 2026-09-24). This is a fixed 2026 example, not a recurring calendar or an annual-date extrapolation. + +Demo workspaces: Acme Engineering (project priorities + weekly cover), Customer Support (daily queues), Regional Platform (dictionary regions → nested enabled services, region-specific handovers/rotation lengths, shared commander), Commerce Operations (product → tier dictionary lookup, variable escalation delays/repeats/working windows, shared weekly fallback). Alice owns all four; Bob is a member in Acme/Regional, Carol in Support/Commerce. Regional times use the workspace scheduling zone, not a per-region zone. Disabled Sandbox and Legacy export illustrate filtering; enabling the latter requires adding its missing retired tier or choosing an existing tier. All examples use normal generation preview/apply and demo cleanup; provider remains local preview. + +The main Acme Engineering demo contains six services (Payments, Search, Billing, Identity, Data platform, Developer portal), each with two or three ordered owners drawn from five login members: alice, bob, carol, dave, erin. All five share the weekly Monday 11:00 Time off rotation. Dave and Erin only access Acme; Carol also retains Support/Commerce access. Other demo workspaces remain separate. Login password remains `meguri-demo-only`. + +## Single workspace schema + +Migration 8 replaces the former tenant/team tables with workspaces and workspace memberships. It requires an empty legacy schema; it refuses to erase a populated non-demo database. Demo startup resets only provenance-verified demo data before migrations and recreates four independent workspaces. No history conversion or runtime compatibility layer is provided. All settings and generation state share the workspace revision. API paths are `/api/workspaces/{workspace}`; navigation uses `?workspace=...`. PostgreSQL integration tests use the dedicated `meguri_workspace_test` database. + +The default Engineering example stores projects as one list of dictionaries: each entry has `name` and an ordered `responsible` person list. The Project iteration reads both fields directly from its current entry; no parallel responsible lookup variable is needed. The independent `on_call` list still defines the shared weekly rotation. + +Generation map uses locally vendored Cytoscape.js 3.33.1 (`static/vendor/cytoscape.js`, adjacent MIT license), with no CDN requests or Node build requirement. Its preset lane layout and inspector live in `static/generation-map.js` / `.html`; engine trace data is returned by the authorized generation-preview endpoint. Run `node tests/browser-generation-map.cjs` against the disposable demo for nested iterations, filtering, diagnostics, read-only behavior, canvas interactions and mobile layout. diff --git a/docs/product.md b/docs/product.md new file mode 100644 index 0000000..963cbf8 --- /dev/null +++ b/docs/product.md @@ -0,0 +1,177 @@ +# Product contract + +Meguri is a multi-tenant on-call configuration service. Workspace is the single tenant boundary: there are no nested teams. A user may belong to several independent workspaces. Workspace grants separately control reading, schedule editing, replacement requests, forced application, membership administration, provider configuration, and audit access. Workspace owners administer their workspace and its drift policy. + +## Scheduling + +- Ordered rotations with a local anchor date, local handover time, and a duration in calendar days (weekly defaults to seven). +- Project primary coverage: the first available person in an ordered responsibility list (not a rotation), then the current effective fallback rotation if everyone is off. Absence and country holidays skip each person independently. The single UI/domain type is Owners by priority. Legacy two-person schedules normalize to an ordered pair when read, including history; IDs, handovers and fallback references are preserved. Legacy generation blocks normalize to priority blocks with an ordered binding list, retaining dynamic primary/secondary expressions. +- Country holidays do not interrupt ordinary 24/7 rotations. Absence makes an employee unavailable in every schedule, including single-participant rotations and approved replacement assignments. Uncovered assignments remain visible as diagnostics and block future publication until an available replacement is arranged; the provider must never receive a new plan assigning an absent employee. +- Absence dates are inclusive and refer to duty days beginning at each schedule's handover, not midnight. An absent general on-call user is reported as a coverage problem; there is no invented automatic replacement. +- Absence planning accepts a small list of half-open date/time ranges and replacement people, scoped to one schedule or all affected schedules. Legacy API duty-day ranges remain supported. Each range replaces only the absent employee's actual affected shifts across rotations. Project coverage uses its existing secondary/fallback automatically; an optional range setting uses the selected replacement for affected project coverage too. Unrelated owners and base rotation anchors remain unchanged. The server previews actual before/after intervals, automatic changes, proposed replacements, gaps, and direct/team escalation warnings before saving. +- Absence is recorded immediately. Explicit replacements are grouped into requests per range, with one confirmation covering all affected schedules; they become effective only after all required approvals. Leads can force them with an optional comment. Saving absence, contacts, pending/applied requests, audit, history, and outbox is atomic and revision-checked. Approval revalidates ownership, availability, and overlaps. Pending replacements cannot make an incomplete plan publishable; existing external configuration remains until successful publication. Direct user/team escalation recipients are outside schedule substitution and are highlighted explicitly. +- Replacements may cover any future interval, or the remaining part of a current shift. A reciprocal swap is one request containing two legs and applies atomically. Its approval must come from the other affected employee. A lead may apply without confirmation using the force grant. The action comment is optional, including forced application. +- An interval ends exactly when the next starts. An override does not alter the rotation sequence. Overlapping approved overrides are rejected. +- Local wall-clock handovers follow IANA timezone rules. Ambiguous or nonexistent handovers are validation errors. Historical assignments are retained through dated configuration revisions and approved override records. + +A member may give their own shift or volunteer to cover a colleague; every non-forced leg must involve the actor. All other affected people approve. Current-interval save and approval clip to the server clock without rewriting history. Fully expired ordinary replacement/swap legs prevent the entire application. Approval-based requests require workspace access for both participants; contacts need a lead's explicit forced action. + +Every action has an optional comment (for example, a task number or vacation). It appears on the approval request and in history, not as a direct message. Empty comments stay empty. + +## Provider boundary + +Meguri computes the assignments and manages escalation and routing configuration. Providers execute alerts, notifications, escalation delays, and acknowledgment. Meguri has no incident processing loop. + +The JSM adapter must support schedule identity mapping, generated coverage, escalation steps and repeats, and routing rules. Advanced escalation options depend on the external plan; unsupported combinations must fail visibly. No credentials are stored in workspace documents. + +External edits are conflicts by default, not silent imports and not silently overwritten. A workspace may opt into overwrite of Meguri-owned resources. Unmanaged external resources remain outside Meguri ownership. + +## Variables and generation + +Workspace variables have arbitrary user-defined names and nested typed values: text, person/email, number, boolean, date, time, duration, list, and dictionary/record. Lists and dictionary entries have stable internal IDs separate from their values and keys. A visual generation rule contains ordered iterations, optional conditions, and schedule/escalation/routing output blocks. Fields bind to constants, variables, loop values/keys, dictionary lookups, fields/list positions, or other outputs; names can add fixed prefixes/suffixes. No executable user code is accepted. + +Editing is a draft with debounced server validation and a complete create/update/remove preview. A missing dictionary key, empty participants, duplicate IDs/keys, bad types, invalid references, unavailable permissions, or excessive expansion prevents activation and identifies the offending rule/field/item. Variable changes recompile the consuming rules in the same workspace. Activation requires schedule-edit permission and compares the single workspace revision atomically; no other workspace is read or changed. + +Generated resources are ordinary schedules and provider configuration with recorded source ownership. IDs derive from workspace, rule, stable iteration entries, and output block, preserving history/overrides across rename and reorder. Edit their source through the generator, not the individual generated resource. Current/future approved replacements prevent destructive generated schedule removal. Pending requests referencing removed schedules are cancelled explicitly in the previewed change. Generated routing stays in a contiguous ordered block at a chosen position among manual rules. Saved workspace history includes exact source variable snapshots and rule definitions. + +Generation is bounded: at most 100 variables, 10,000 data nodes, four iterations per rule, 50 rules, 200 schedules, 200 policies, and 500 routing rules per workspace. Expansion fails atomically on excess; provider limits still apply. Generated contacts use exact existing workspace-email identities or deterministic email-contact IDs and never gain login/workspace access. + +## User experience + +Localized UI (English, Russian, French): calendar and coverage, schedule configuration, request inbox, account settings, workspace permissions, provider publication status, and audit trail. Saving/approval succeeds independently of external availability. Publication failures and drift must remain visible. + +Public holidays are collapsed by country by default, with the total count beside the country name. Human-readable dates use DD-MM-YYYY, including editable date fields; timestamps additionally show the user's local time and UTC offset. API/storage dates remain ISO. CSV imports accept ISO or DD-MM-YYYY dates. + +Schedule removal shows a server-generated dependency summary before applying. Users can remove its escalation steps when every affected policy retains at least one step, or redirect schedule references to another existing schedule. Coverage fallbacks require redirection. Future applied replacements block removal; pending requests touching the removed schedule are cancelled as whole requests. History is retained. Removal, dependency changes, cancellation, audit, and publication are committed together against the reviewed revision. + +Routing rules are evaluated from top to bottom. Drag-and-drop and keyboard/touch move buttons save the ordered list of rule IDs; the server requires an exact permutation and assigns consecutive priorities. The rule editor selects an insertion position instead of exposing conflicting numeric priorities. Reordering requires schedule-edit permission and rejects stale versions. + +Local password authentication is the initial identity adapter. User IDs, memberships, sessions, and audit identities are provider-independent; later OIDC identities bind `(issuer, subject)` to an existing local user ID. Demo mode must be explicitly enabled and uses only the local preview provider. + +Daily use starts on the calendar with incoming requests and one-click Accept/Decline, outgoing pending requests, and the selected schedule's current/next personal shift. Schedules are searchable. Clicking a duty day prefills owner, schedule and exact local boundaries. Own days offer searchable teammates; other people's days preselect the signed-in employee. An exact incoming request can be accepted directly from that day. Split days explicitly choose a segment; past days are read-only. Start/end dates and clock times are always visible and are validated on the server before send. Reciprocal swaps are optional and have their own explicit start/end boundaries. Force permission adds a separate Apply without approval action. + +Infrequent administration lives under Setup. Dialogs name their action. Project-to-people dictionaries use ordered person chips, name/email search and spreadsheet paste; simple lists use direct rows. The complete typed structure editor remains available for arbitrary nested data. Both editors share server validation and activation preview. + +## Disposable demonstration mode + +Before migrations or deletion, startup checks a separate database provenance marker recording the last runtime mode. Only positive previous-demo provenance permits resetting application data. An unmarked database, a normal-mode database, or an unreadable marker is protected. Demo startup switches to a separately named database (or an explicitly supplied empty demo database), with a permanent prominent header and server-log warning requiring administrator attention. An empty database can be claimed only after proving it has no user relations. If isolation cannot be established, startup fails without changing protected data. No DROP DATABASE, schema deletion, or TRUNCATE CASCADE is used. Migration history and runtime metadata survive resets; another active database client blocks reset. + +During a run, a minute-based maintenance task soft-deletes visitor workspaces and their configuration graphs after 24 hours from creation, and visitor accounts after 48 hours. They disappear from access/listing immediately; dependent data is physically removed after a ten-minute grace period, preserving referential integrity. Nested schedules, contacts, variables, rules, absences, and memberships expire with their owning workspace rather than leaving broken references. Requests expire independently after two hours from creation; audit/history have six-hour retention with a historical boundary snapshot retained. Editing never extends creation-based lifetime. Times are configurable. Demo accounts remain usable; seeded workspaces refresh after expiry. The permanent header describes retention and restart reset. Normal mode never runs demo retention. + +The minimal examples are Acme Engineering / Project matrix (six services with two or three ordered owners during working hours and a shared weekly Monday 11:00 Time off rotation) and Customer Support / Daily support (daily 09:00 shifts and a 15-minute escalation). Alice leads both, Bob, Dave and Erin demonstrate Engineering swaps; Carol participates in Engineering and demonstrates Support approvals. The Commerce example includes an email-only contact without login access. + +## Anonymized reference behavior + +Alice, Bob, and Carol rotate weekly from Monday at 11:00. Payments primary is Alice and secondary is Bob. If Alice is absent on a duty day, Bob covers Payments; if both are absent, that day's effective general on-call employee covers it. Public holidays follow each person's country; when both are off, coverage falls back to general on-call. A confirmed replacement of Carol by Alice on the general rotation must also change any project coverage that falls back to that rotation. + +Project escalation: primary at minute 0, secondary at minute 10, all team members at minute 30, if unacknowledged. General escalation: on-call at minute 0, random team member at minute 30, named lead at minute 45. Both may repeat 10 times, reset recipient states, and leave the alert open. Working-hours routes select project policies on weekdays 11:00–19:00; a catch-all route selects the general policy. Development alerts may be routed to no notification. +## People, calendars, and working intervals + +Scheduling people are separate from login accounts. A workspace can select its members or add an email-only contact without creating an account or granting access. Their name, email, and ISO country belong to the versioned workspace state. Country defaults to Cyprus (CY); the UI offers all 249 ISO country codes. Existing login IDs remain stable; an email is never proof of identity. Only authenticated, authorized users can approve requests; an email-only recipient requires a lead's explicit forced application until a verified account-link workflow exists. Selecting a workspace user does not grant them workspace access. + +Each schedule has its own local handover time (11:00 by default) in the workspace's IANA timezone. Duration is still measured in calendar days. Dependencies resolve their own handover boundaries. Workspace working days, start/end time, and IANA timezone are shared by all routing rules with “Only during working hours” enabled. Changing this workspace interval atomically updates workspace snapshots/revisions and queues publication. Old migrations preserve historical handovers and timezone values. + +A user's display timezone defaults to the browser's IANA zone and can be overridden in account settings. It never enters scheduling state. Calendar intervals and activity timestamps show local time and UTC offset; configuration dates and time inputs explicitly use the workspace/country calendar. Profile country changes update that user's scheduling-country snapshots and publication requests atomically; display-timezone changes do not enqueue publication. + +Holidays have a date, name, and ISO 3166-1 alpha-2 country. Coverage skips a primary or secondary only when that person's country has a holiday (or they are absent); then it falls back to general on-call. Ordinary rotations remain continuous 24/7, including holidays. Legacy global holidays remain explicitly marked “All countries” and keep their original fallback behavior. A legacy person's unset country defaults to Cyprus. + +CSV imports use `country,date,name` headers, ISO or DD-MM-YYYY dates, and country codes such as AM and CY. Pasting previews the missing holidays against current server state without saving. Import is add-only and revision-checked: identical duplicates are skipped, different names for an existing country/date are reported and preserved, and invalid rows block the whole import. A stale preview must be refreshed. + +JSM schedule participants use Atlassian account IDs. A manual account-ID mapping takes precedence; otherwise the provider resolves a person's email against the Jira user directory before any remote writes. Only a unique exact active match is accepted. Hidden, missing, or ambiguous addresses block publication with an actionable error. Meguri does not provision Atlassian accounts or configure their phone numbers/contact methods. + +## Localization and setup navigation + +English, Russian and French are bundled in a single runtime catalog. The account language preference overrides the browser; an unset preference uses the first supported browser language, then English. Language affects presentation only, never schedule calculation or publication. User-entered values and third-party diagnostics remain opaque. All built-in text, including accessible labels and validation messages, must come from catalog entries. + +Menu entries are real links carrying view and workspace. Reload, new tabs and Back/Forward restore the accessible workspace and section. Unknown sections fall back to Calendar; unauthorized workspace identifiers never grant access. Legacy schedules/policies/generation links open Schedule setup. + +Schedule setup combines variables, generation rules, schedules, escalations and routing. Manual resources come first; generated resources follow a separator with a distinct style and source badge. Generated resources cannot be edited directly: their source button opens the owning rule. Routing badges retain actual evaluation priority even though resources are grouped by origin. Generation edits use ordinary inline values and direct variable/dictionary selections, with examples; custom loops, conditions and bindings remain available. Examples are illustrative; debounced server preview is authoritative before activation. + +Uncovered future assignments show the employee, schedule and exact interval in the user's timezone with an action to arrange cover. Provider failure diagnostics include the first affected assignment. The UI lists at most 50 intervals and shows their total. + +Cover requests expose start/end dates and clock times directly. Ask for cover defaults to one calendar day at the workspace working interval start, in its IANA zone. Clicking an existing duty segment preserves that exact segment; edits use explicit endpoints. Display timezone never changes input interpretation. Reciprocal legs have their own dates/times. The server resolves wall-clock endpoints and rejects ambiguous/nonexistent local times. Generation binding/person editors replace the long form with a focused subview and return to the originating field. Validation state and the first error stay visible in the sticky footer; all errors expand there. + + +Generated names using an iteration value/key display the source token and editable suffix together in a single bordered row; an existing prefix is retained and displayed in that row too. Binding semantics and generated identities remain unchanged. + +Generation value editors show a reactive, read-only, formatted JSON example in a compact scrollable monospace textarea. Iterated values use the first available row with its context shown; missing values clear the preview. Invalid field types still display the selected raw data while server validation remains authoritative. Generated references show resource metadata explicitly labeled as an example, not the resolved identifier. Preview output is bounded to 20,000 characters. + +Resource source pickers distinguish existing schedules/policies from generated schedules/escalations in their labels. A priority-owner or rotation schedule is also a valid fallback; fallback cycles remain prohibited by server validation. + +The generic binding source is “From a generated resource”; contextual choices say “Generated schedule” or “Generated escalation”, contrasted with existing resources. A short hint explains resolution during generation and current-item scope. + +Generation pickers offer direct references only to manual schedules and escalation policies, in both simple and advanced editors. Generated instances are reached through generator outputs, never duplicated as existing choices; new escalation-step defaults follow the same rule. Legacy saved direct references are retained, with an editor warning to select a generator source or manual resource rather than silently changing their scope. + +Variable and iteration bindings share a monospace template notation throughout generation forms: `{ var.responsible[Project.value] }`, `{ Project.key }`, and one-based nested paths such as `{ var.people[1] }`. Names requiring escaping use quoted bracket notation. Prefixes/suffixes remain outside braces. This is presentation only, not an editable expression language; binding data and server interpretation are unchanged. + +Template notation remains visible in all form controls, but inputs, selects and buttons keep their standard styling. The monospace token background is reserved for non-interactive text blocks and template previews. + +Returning from generation value/person editors restores the parent modal scroll after the main form is visible and laid out; focus restoration must not move the viewport. + +Audit details resolve known person, schedule, escalation, routing, variable and rule IDs into name/id pairs. Names use workspace-scoped historical snapshots up to the event time, retaining last-known labels for removed resources; current directory/state fills gaps when history is unavailable. Unknown IDs and free-text comments remain unchanged. Display timestamps use the viewer timezone, with original immutable JSON and UTC timestamps available in a separate disclosure. + +Variables expose one keyed collection type: Dictionary / object. Legacy `record` nodes normalize recursively to `dictionary` during deserialization, preserving node IDs, keys, values, nesting and generation references, including stored history. No separate record type appears in the editor. + +Generated-resource references show both their resource type and name/template directly on binding buttons, including escalation recipients, without opening the editor. + +Schedule setup begins with small collapsed absence/holiday widgets, followed by variables and generation rules and a compact expandable review/apply bar. There is no anchor menu or starter-example action; demo seeding supplies examples. One resource inventory groups manual resources first and generated resources after a divider. Schedules, escalation policies and routing rules use the same collapsed rows, with type/name in the summary, generated star and distinct color, and details/actions inside. Routing retains explicit evaluation positions and ordering controls. Three creation controls above the inventory create schedules (with a type chooser), policies and routes. Generated resources remain read-only with a source-rule action. + +An approved replacement can itself be replaced from the calendar, using the current effective owner. Pending requests leave the existing cover active; approval or authorized force replaces only the selected interval. Original requests and audit history remain intact. Ownership and substitute availability are rechecked on application. + +Rule summaries show iteration sources in template notation only when iterations exist, and count/type summaries for outputs. Collapsed resource rows include concise rotation/owner, escalation-step or route-condition details. Every generated resource has the same top-right source-rule link inside its expanded body, labeled with the rule name; viewers without edit permission see the attribution without an enabled edit action. + +Schedule types are Rotation and First available. A permanent assignee uses a one-participant rotation. The generation block picker groups schedules, escalation policies and routing rules separately. The former fixed-assignee type is removed without compatibility or history conversion. + +Data previews expand known person/resource references into compact named objects (with original identity retained), including nested lists and dictionaries. This is a readable display of references, not a conversion of stored values to objects. + +Validation is a permanent status/details control in edit forms and generation setup. Live previews show checking/valid/error states; forms without a preview explicitly say not checked yet and validate on save. Diagnostic details are collapsed, with scoped source-rule/variable edit actions that retain the current local draft. Secondary generation errors are marked as possible consequences. Absence impact rows offer Choose a substitute: it adds an exact, schedule-specific interval and opens the person picker; other schedules and automatic project fallbacks remain unchanged. + +Absence ranges use one editor for manual creation and impact-row shortcuts: schedule scope (one or all affected), start/end dates and times in the workspace zone, and substitute. Endpoints are half-open. Shortcut ranges remain fully editable. All-schedule ranges intersect the absence window of each schedule; explicit single-schedule ranges must fit its absence window. The absence itself retains inclusive duty-day dates. + +Coverage diagnostics distinguish pending approval from missing substitutes. Pending request boundaries split uncovered intervals so partial requests never disguise remaining gaps. The UI shows substitutes and outstanding approvers, links to requests, and keeps publication blocked until approval. Browser regression covers partial pending coverage, remaining gaps, approver filtering, and responsive spacing. + +Schedule setup places narrower variable and generation-rule panels beside a stacked availability sidebar. Absences and holidays remain open; their lists scroll internally. Country holiday groups retain accordions inside the bounded list, with add/import actions opening dialogs. Responsive layout moves the sidebar below the editors. Browser checks cover column position, bounded holiday expansion, visible actions and mobile width. + +Generated routing placement is shown only when manual routes and an enabled route-generating rule coexist. Options name the manual routes in evaluation order; the stored insertion index is unchanged. Browser checks verify hidden controls for generated-only setups and named options/order for mixed routing. + +The workspace-variable list scrolls within a 360px maximum height; its heading and add action remain outside the scroll area. The list supports keyboard scrolling and retains natural height when short. + +Generation-rule summaries explicitly prefix rotation and priority output types with a localized Schedule label, distinguishing schedules from escalation policies and routing rules. + +The generation-rule list uses the same bounded, keyboard-scrollable area as variables. Its add action and routing placement control remain outside the scrolling list. + +Resource inventory is a distinct UI component (`resource-inventory.html`). All expanded resource kinds share `resource-details.html`: labeled icon rows, consistent spacing, sequential connectors, and a common source-rule control. Presentation rows and monochrome SVG paths come from pure UI helpers; schedules, routing order and provider state are unchanged. Check rotation, fixed owner, priority fallback, escalation timing/repeats and routing conditions on desktop/mobile. + +Expanded resources use compact type-specific horizontal widgets instead of parameter tables: rotation metadata plus ordered people, priority people plus fallback, escalation steps plus repeat footer, routing conditions plus destination. Shared icon, chip and connector styles keep them consistent. Chains wrap within narrow screens; source links and edit actions retain their common placement. + +The generation-rule editor is a generic technical constructor: visible iteration aliases and collection bindings, an explicit optional condition, and typed output blocks. New rules start without implicit project/responsible-variable detection or generated blocks. Variable names carry no semantic meaning. Compact controls retain the existing typed binding editor, value preview, live server diagnostics and draft semantics. Check no-iteration rules, nested collection bindings, conditions and multiple output types on desktop/mobile. + +Binding selection uses one anchored, viewport-clamped popover beside the originating control; the constructor remains visible and keeps its scroll position. All source kinds, nested lookups/lists, paths, text affixes and live value previews remain supported. Literal escalation delays are directly editable. Done/Escape closes the popover and returns focus. Server and change previews are collapsed summaries with counts and bounded change lists. Browser checks cover context retention, source editing, nested bindings, keyboard dismissal and mobile bounds. + +Output block names use one standard input and the shared ellipsis binding picker. Plain literal names are editable inline; bound names show their complete expression including affixes, with binding edits confined to the popup. Separate suffix/prefix fields are no longer shown in the constructor. + +The rule editor materializes absent optional escalation repeat count/wait fields as literal zero in its draft, matching server defaults. Explicit bindings and invalid values remain unchanged for validation; opening/cancelling does not mutate saved rules. + +Server-preview changes use labeled green/create, blue/update, red/remove and gray/unchanged badges. The expanded list scrolls at 320px (280px on mobile), with a compact footer toggle. + +The main Acme Engineering demo contains six services (Payments, Search, Billing, Identity, Data platform, Developer portal), each with two or three ordered owners drawn from five login members: alice, bob, carol, dave, erin. All five share the weekly Monday 11:00 Time off rotation. Dave and Erin only access Acme; Carol also retains Support/Commerce access. Other demo workspaces remain separate. Login password remains `meguri-demo-only`. + +Generation rule results use the actual authorized server expansion, including schedules, escalation policies and routes, with resolved names and compact settings. The panel is always present with explicit checking, invalid, disabled and empty states; it respects iteration filters and does not simulate generation in the browser. Preview data contains only scheduling resources and scoped people, never integration configuration. + +Numeric, date and time controls use compact value-sized widths throughout forms. Generator literal fields keep their binding button adjacent; dynamic references retain space for descriptive names. Date controls include room for the DD-MM-YYYY value and calendar button. + +Generation field labels occupy their own grid row; each value and its picker share the next row. Boolean inputs stay checkbox-sized beside their labels, including working-hours bindings. + +The default Engineering example stores projects as one list of dictionaries: each entry has `name` and an ordered `responsible` person list. The Project iteration reads both fields directly from its current entry; no parallel responsible lookup variable is needed. The independent `on_call` list still defines the shared weekly rotation. + +Variable editing uses a bounded, progressively disclosed tree: only the root starts expanded, object rows show names and collection summaries, and nested values expand in place. Values mode hides type/key controls; Structure mode exposes them without losing edits. Duplicate assigns fresh IDs recursively; ordering and live server validation remain intact. Check nested editing, duplication, mode switching and narrow viewports. + +Generation map opens from Generation rules as a read-only, draggable/zoomable Cytoscape canvas. Server preview returns actual iteration counts, input contexts, predicate outcomes and produced IDs; no scheduling evaluation is duplicated in JavaScript. Iterated outputs start collapsed into a group; inspecting a node or pass reveals source data and verified resources. Invalid drafts show diagnostics rather than stale resources. The map is a captured draft preview refreshed explicitly, and never applies changes. Bundled Cytoscape 3.33.1 is served locally with its MIT license; no CDN or new framework is required. Browser coverage checks groups, details, source navigation, pan/zoom and mobile layout; engine regression verifies traced pass results. + +The generation map arranges numbered rules in saved execution order from left to right, joined by solid sequence arrows. Each rule owns a vertical column of iterations, conditions and outputs. Dashed secondary edges show data dependencies rather than execution order. Repeated output groups use offset background cards; decorative layers follow dragging and do not receive selection. Browser checks verify rule ordering, aligned output columns and stack movement. + +Map input details display evaluated server values for each block and pass, never serialized bindings. Shared values are deduplicated across passes; differing values are explicitly counted. Generated references collapse to type/name cards, and people resolve by display name. Empty binding metadata and raw reference UUIDs are not presented. Condition operands and escalation recipients/delays come from the same resolver used during expansion. + +The map’s all-passes input overview lists deduplicated upstream sources, not expanded output settings: variables with used binding addresses and resource references grouped by producing block. References internal to the selected whole rule are omitted; selecting an individual output block shows its dependencies. Selecting a concrete pass still exposes its evaluated field values. + +Expanded map resources use type-specific labeled fields and the same compact reference cards as inputs, never JSON dumps. Priority schedules show ordered people, fallback and zoned handover; rotations add anchor/cycle; policies show recipient/timing/condition and repeat behavior; routes show conditions, working window and destination. Reference names resolve from the preview snapshot. Browser regression checks visible names, participant order and absence of UUID metadata. diff --git a/docs/proposals/workspace-templates.md b/docs/proposals/workspace-templates.md new file mode 100644 index 0000000..98b65d1 --- /dev/null +++ b/docs/proposals/workspace-templates.md @@ -0,0 +1,86 @@ +# Proposal: workspace data and schedule templates + +This is a design-rationale document. Current behavior is defined in product.md and architecture.md: workspace is the sole scope, with no nested teams or cross-workspace bindings. Historical sketches below are not an implementation contract. + +Status: the first implementation is available. See `product.md` for the maintained contract. This document preserves the broader design rationale; reusable parameterized template libraries, derived variables, AND/OR predicate groups, and separate row-level grants remain future work. + +Implemented: arbitrary typed trees, stable list/dictionary entry IDs, up to four nested iterations, one optional predicate per rule, constant/variable/item/key/lookup/output bindings, text prefixes/suffixes, ordered priority coverage and other schedule outputs, policies, routing, debounced server validation, impact preview, and atomic activation of variables and generated configuration within one workspace. Edit/activation uses EditSchedules permission in that workspace. Singleton outputs may be shared between rules; iterated outputs stay scoped to their row. Limits are 200 schedules, 200 policies, and 500 routes; a 30-project matrix is covered by regression tests. + +## Intended workflow + +A workspace member with the appropriate grants creates their own named variables and builds reusable rules from visual blocks. Neither variable names nor record fields are fixed to projects/primary/secondary. For example, one person can define a shared list of on-call emails, a team-lead value, and a dictionary mapping each project to a different ordered list of responsible people. Another team can model regions, services, or shifts with different fields. The product supplies types and operations, not a mandatory project schema. + +In the UI there are three views: **Variables**, **Rules**, and **Preview**. Lists are editable rows; dictionaries and records are tables/forms with user-defined columns. Rules contain “For each”, “If”, and “Create …” blocks. Each field offers “Fixed value” or “Use variable”; the picker shows compatible variables and fields of enclosing loop items. A breadcrumb such as “Current project → Responsible people” replaces an expression to type. List selection can choose the whole list, first person, or person at a specified position; dictionary selection can look up a key from another variable/current item. Missing positions/keys require an explicit fallback or produce a row-specific validation error. A rule can be saved as a reusable template, but an administrator does not have to prebuild every template for users. + +Changing an input automatically regenerates the draft preview, showing resource counts and errors beside the originating row/block. Applying activates one consistent revision and queues publication. Automatic draft calculation must not publish an unreviewed destructive edit. No Rust, JavaScript, Terraform, or textual expression language is required. + +### Separate project list and responsibility dictionary + +The user's concrete input model is two independent variables, represented here as data examples, not a configuration language the UI requires: + +```json +{ + "projects": ["A", "B", "C"], + "responsible": { + "A": ["alice@example.com", "bob@example.com"], + "B": ["carol@example.com", "alice@example.com"], + "C": ["bob@example.com", "carol@example.com"] + } +} +``` + +The visual rule is **For each item in projects → Create rotation**. Its name binds to the current project; its participants bind to **Variable responsible → Entry with key: current project → Entire list**. This is semantically `responsible[current_project]`, built by selectors rather than typed syntax. Anchor, shift length, and handover can be fixed values or additional bindings. One schedule block creates A, B, and C rotations with different participant sequences; list order is the rotation order, not an escalation order. Extra escalation/routing blocks are optional. + +This lookup joins collections by their values, never their row positions. A reordered responsibility dictionary has no effect. Duplicate project values, a missing dictionary key, an empty participant list, repeated participants, or an invalid email fail preview with the project and binding identified; there is no implicit fallback to another project. Unused responsibility entries may be shown as unused but create no schedules. A deliberate “Use default when key is absent” binding is possible; it must be visible in the rule. The entire activation fails on invalid rows rather than publishing a partial matrix. + +Generated schedule identity follows the stable entry ID in the iterated project list and the create block ID. Editing/reordering the participant list changes future base ownership without recreating that schedule; it never resets the stored rotation anchor or drops applied replacements. Renaming a project keeps its hidden entry ID, but the user must update the dictionary lookup key as part of the same valid draft. Tests must distinguish reordering projects, reordering dictionary entries, and reordering a project's participants: only the last changes that rotation's ownership sequence. + +Another supported layout is a single table of records. It is optional: users never have to merge the separate variables above into this format. + +| Stable key | Project name | Primary | Secondary | Handover | Target team | +|---|---|---|---|---|---| +| first | First | Alice | Bob | 11:00 | Platform | +| second | Second | Carol | Alice | 09:00 | Platform | + +The column types refer to real people, teams, times, and other resources. A dropdown selects references; a free-form name is not an identity. A useful starter type is “Person”: an existing workspace user or an email contact without login access. Plain strings and lists of strings are still allowed. When used as recipients, email strings need an explicit validated conversion to people; non-email strings are not guessed to be usernames. The team can edit allowed cells and add/remove rows without editing the rule structure. + +For the minimal dictionary example, a single block can iterate `projects`, where each entry is a project key and its ordered list of people. Inside it, “Create rotation” takes its title from the current key and its participants from the current value; a second “Create rotation” takes the shared team lead as its only participant; “Create escalation” refers to these two outputs; “Create routing rule” matches the current project and points at that escalation. Thirty dictionary entries then produce thirty connected groups from the same four create blocks. If each project needs separate primary/secondary participant lists, change the entry to a record with those fields and bind two rotation blocks to them. Both are data models the user can choose. + +Shared resources can be created outside a loop and referenced inside it, for example one single-participant rotation for the team lead instead of thirty identical rotations. Multiple rules consuming the same dictionary keep independent instance/output identities and explicit precedence; a lookup alone does not create a resource. + +“Create alert” is deliberately not an action here: the generated routing/escalation configuration tells the external provider how to process alerts. Meguri continues to own configuration, not incident delivery. + +## Four boundaries + +1. **Workspace data.** Versioned, typed values: string, boolean, number, date, local time, duration, working interval, person/team/schedule reference, list, record, and dictionary. Time-of-day, duration, and timestamp are distinct types. A local time needs an explicit workspace/interval IANA zone before it can identify a schedule boundary; user display timezone is never an input. “Dictionary” means a declared schema or `map`, not arbitrary unvalidated JSON. Secrets remain in deployment secret storage. +2. **Rules, templates, and bindings.** Persist a versioned, typed block tree with stable block IDs: `ForEach(collection, item)`, `If(predicate)`, and `Create(kind, fields)`. Value nodes are constants, variable references, loop-item/record fields, dictionary lookup, explicit default, name formatting, or references to a create block's output. A picker creates these nodes; users do not write the internal representation. Initial predicates can be equality, contains, and empty/not-empty with AND/OR. Each create block is a stable output slot (schedule, escalation, route). References use IDs and loop keys rather than interpolated display names. Reusable templates parameterize these blocks; an instance binds values and allowed target teams. Renaming a variable does not break references. +3. **Pure expansion and validation.** Resolve source variables and any explicitly derived values as an acyclic dependency graph, expand bounded loops into ordinary domain objects, resolve output references, then run existing schedule/dependency/provider-capability validators. Nested collection iterations may use outer fields; a Cartesian product is an explicit operation with a visible output count. Scope checking prevents a reference to an item outside its loop; referencing a conditional output requires compatible guards or an explicit fallback. No arbitrary code, eval, recursive templates, network calls, clock-dependent expressions, or unbounded loops. Reject cycles, invalid types, duplicate keys, missing identities, illegal grants, excessive nesting, and output limits before activation. Freedom to compose variables and blocks does not require a general-purpose programming language. +4. **Activation and publication.** Materialize the candidate team documents and a diff of create/update/remove operations. Show affected teams and dependent policies, and preserve an expansion manifest. After review, compare all input revisions and atomically persist the source revision, affected team snapshots, history, audit, and outbox jobs. External providers receive the same canonical publication contract as today. External writes can still partially fail; report their status separately from activation. + +## Source of truth and stable identity + +The source is the active combination of variable revisions, template versions, instance bindings, manual resources, and approved replacements. Generated team documents are immutable derived snapshots, never an independently editable second source of truth. Keep the exact inputs/manifest for reproducing a past expansion without loading today's variables. + +Generated identity is based on `(instance UUID, stable loop-entry ID path, output block ID)`, for example via namespaced UUIDs. Renaming a project, variable, dictionary key, or reordering the table must not recreate schedules, lose provider mappings, reset a rotation anchor, or orphan replacement history. Every output records its source instance, entry path, block, and version. Lists and dictionaries have hidden persistent entry IDs separate from labels, keys, and ordinal positions. Dictionary keys remain unique for lookup; changing a referenced lookup key requires validation, while generated identity stays stable. Copying a block/entry deliberately creates a new ID. Rotation anchors are explicit stored inputs or creation-time values preserved in the manifest, never recomputed from today's date. + +Generated objects show “Managed by template … / project …” and link to their source row. Direct edits redirect to the matrix or template. Permit only explicitly declared per-row parameters. If later needed, “Detach” copies the effective configuration into manual ownership with a recorded identity/ownership transition; it is not a silent local patch that the next expansion overwrites. + +## Deletions, replacements, and rule order + +Removing a row previews every removed output and its inbound references. References from surviving/manual resources require an explicit replacement or edit; never silently remove unrelated policies. Current/future approved replacements block destructive changes to their schedule. Pending swaps are cancelled only as an explicit part of the reviewed activation. Historical snapshots remain intact. Changing participants or anchors warns about changed future owners and preserves applied overrides using existing engine rules. + +Order is semantic because routing uses first match. A template instance owns an ordered block of generated rules, with declared order within each row and a stable order of rows. Drag the block among manual rules; edit row/template order at its source. Do not derive priority from dictionary iteration, UUID ordering, or names. Cross-instance precedence is an explicit workspace/team setting. + +## Permissions and multi-team changes + +Separate rights to create/edit variables and their schema, edit data rows, build rules/templates, and activate changes. These grants can be delegated to team members, not only workspace administrators. Shared templates can restrict which columns/rows and target teams editors manage. A row cannot name an arbitrary tenant or team. Scheduling a person still does not grant them access. Workspace-wide data is intentionally readable to its workspace audience; never place team-private data in a globally readable variable. + +Editing shared inputs produces a draft. Activation requires authority over every affected team, or approvals from those teams before one activation; do not partly activate an input revision. The diff lists the complete impact even when one row looks harmless. Source and team revision checks reject stale approvals. Keep activation local and bounded; no database write lock spans provider HTTP calls. + +## Small first version + +Start with user-defined primitive variables, people/email lists, records and dictionaries; constant/variable/current-item bindings; one `for each` collection; schedule + escalation + route create blocks; stable identities; preview/diff; and atomic activation for one target team. A starter “Project on-call” template is a convenience, not a special hard-coded model. This first version already handles different responsible lists per project plus shared team-lead/default values. Add conditional/nested iterations and reusable parameterized templates next, then explicit Cartesian products and multi-team activation. Reuse the existing engine, provider contract, and outbox. Do not start by prescribing a fixed table or introducing a programming language. + +Before rollout, size domain quotas for this use case: the current limit is 50 schedules per team, so 30 projects with two schedules each already exceeds it. Establish tested resource/expansion budgets and provider-specific limits, show counts before activation, and reject excess atomically. Raising a cap alone is insufficient without measuring engine, history size, and publication cost. + +Required tests: deterministic expansion; stable identity across rename/reorder; matrix limits and key collisions; type/reference/cycle validation; scope enforcement; deleting rows with inbound references or approved replacements; stale previews and concurrent activation; history reproducibility; no changes to unrelated manual resources; routing precedence; retrying publication of the same expansion. diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..276f044 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,118 @@ +# Test architecture + +The daily cover workflow has a PostgreSQL regression for read-only preview, volunteering, owner approval, denied self-approval/third-party transfers, force permissions, empty comments and current-interval clipping. `tests/browser-cover.cjs` creates its own workspace and checks calendar → offer → owner acceptance, own-day request → exact-request acceptance, the privileged third action without a comment, early range errors, comment visibility, display timezone and mobile layouts. Browser navigation opens Setup explicitly; daily flows do not need it. + +Scheduling errors are product failures. A change to ownership, time boundaries, approvals, access, or synchronization must include a regression at the boundary that could fail. Test observable outcomes rather than repeating an implementation formula. + +| Layer | Location | Dependencies | Contract | +|---|---|---|---| +| Pure engine | `src/engine.rs` tests | None | Owners and intervals, DST, fallback, pending/applied overrides, validation | +| Configuration graph | `src/configuration.rs` tests | None | Dependency removal/redirect, last-step and fallback protection, pending swap cancellation, approved-history retention, exact route permutation | +| CSV import | `src/holidays.rs` tests | None | Country/date keys, quoting, BOM, duplicates, conflicts, invalid rows | +| Provider compilation | `src/providers/` tests | None | Unsupported capabilities fail; normalization detects changed ownership | +| Application/persistence | `tests/integration.rs`, `tests/support/` | Disposable PostgreSQL | Real Cot migrations, scoped authorization, concurrent edits, atomic audit/history/outbox, approval revalidation | +| HTTP provider contract | Integration suite + stateful local fake | PostgreSQL + loopback | Wire methods/payloads, partial 429 failure, checkpoint recovery, read-back, drift, external overrides, owned-only deletion | +| Browser/API | `tests/browser.cjs`, `tests/browser-calendars.cjs`, `tests/browser-configuration.cjs` | Seeded local app + Chromium | Forms, calendar, approval, workspace isolation, CSRF/origin, people/imports/timezones, removal dependencies, native routing drag/drop and position editing, desktop/mobile | +| Live acceptance | Dedicated JSM test team | Test credentials | Actual provider timeline, subscription-specific escalations and routing; never ordinary CI | + +## Invariants + +- Every schedule covers the requested range without gaps or overlaps. Tests exercise a year across five zones and five shift lengths. +- Spring and autumn weekly shifts can be 167 or 169 hours while handing over at 11:00 local. Exact boundaries belong to the incoming employee. Invalid local handovers fail. +- Overrides change only their interval, not the base sequence. Pending requests have no effect. Overlapping approved state is an error. +- Coverage uses secondary, then the effective fallback including overrides. Country holidays skip only affected people; legacy global holidays select fallback directly. Cycles fail validation. +- Swaps apply both legs or neither. Requesters cannot approve for someone else. Application rechecks availability and ownership. Force requires a grant and is audited; comments are optional. +- One of two edits with the same revision wins. A failed constraint leaves document, revision, history, audit, and job unchanged. +- Schedule removal rewrites references, cancels affected pending requests in full, and commits one history/audit/outbox change. It cannot erase a current/future approved replacement or leave an empty escalation or broken fallback. +- Route ordering requires every current ID exactly once. Missing, repeated, foreign, stale, or unauthorized requests change nothing. Changing priority preserves conditions/recipients and survives reloads. +- Publication snapshots cannot read newer revisions. Past absence flags do not block a now-correct future plan. +- Competing workers cannot claim the same unexpired lease. Retrying a lost checkpoint does not create duplicate remote resources. +- External changes and overrides block writes/deletion by default; unmanaged resources survive reconciliation. + +## Running and extending + +`cargo test` runs pure tests and explicitly reports database tests as ignored. CI also runs `cargo test --test integration -- --ignored --test-threads=1` with PostgreSQL 17. Fixtures use UUID names and never truncate shared tables. Use a disposable database. Run this suite serially because worker tests inspect the global queue. Worker tests explicitly prioritize only their own job so a bounded batch cannot skip it because of jobs left by a prior run. + +The fake models JSM responses and is not a substitute for live acceptance. New endpoints need verification against official documentation, a wire assertion, and a failure/recovery test. Do not make the fake accept an invented contract to get tests green. + +Before enabling a provider in production: publish a preview to its dedicated test team, compare the actual timeline, repeat unchanged, interrupt a partial publication, introduce external drift, verify conflict and explicit overwrite, and exercise escalation/routing on the target subscription. Record evidence and limitations. +## Additional regression cases + +Cover independent handovers and dependent fallback boundaries, DST rejection, country-specific coverage, scoped directory selection, email contacts without access, import quoting/BOM/duplicates/errors/stale previews, atomic working-interval propagation, and exact JSM email resolution before writes. Browser coverage includes person selection by email, absence selection, reactive CSV preview/import, and readable escalation/routing flows on desktop and mobile. + +## Variables, absence planning, and demo lifecycle + +`src/absence.rs` tests range splitting across independent handovers, automatic project promotion, uncovered diagnostics, preserved owners, and replacement validation. `tests/generation.rs` checks deterministic 30-project expansion, stable IDs on rename/reorder, typed dictionary errors, loop scope, fallback cycles, protected applied replacements, workspace-specific IDs, three-person priority, country holidays, and a Monday rotation crossing autumn DST. + +The PostgreSQL suite checks read-only previews, grouped absence approvals, atomic workspace-variable activation, permission isolation, stale reviews, concurrent activation, country snapshots, and no contact-to-login grants. Demo tests use uniquely named disposable databases with CREATEDB: unmarked/normal protection, safe isolation, application-only reset with unrelated sentinel data preserved, repeated migration/seed, creation-based soft deletion, later physical purge, and reseeding. Never repoint these tests at a production DB. + +The two additional browser suites (`browser-absence.cjs`, `browser-generation.cjs`) exercise actual forms and early server errors, persistent/reloaded state, the permanent demo banner and login guide, generated source editing, country accordions, DD-MM-YYYY, bulk replacements, and desktop/mobile layouts. Inspect screenshots rather than trusting DOM assertions alone. + +Localization/navigation regressions check supported browser fallback, persistent account preference, parameter safety, complete catalogs, no literal template text, direct links, reload, workspace changes and browser history. Generation browser checks retain server errors, atomic activation and stable generated IDs while exercising inline controls and the source-owner link. + +Exact-time cover regressions exercise same-day partial cover, workspace clock defaults despite a different display zone, server ownership checks, malformed clock values, DST folds/gaps and 23/25-hour calendar days. Generator browser tests assert that binding edits replace the main form and live validation remains inside the viewport even at the top of a long form. + + +Legacy compatibility regressions deserialize two-person schedules and dynamic generation bindings into priority-owner resources while preserving participant order, resource identity, fallback, handover and routing references. + +`tests/browser-binding-preview.cjs` checks reactive formatted binding values beside server type errors, clearing missing selections, first-row lookup context and mobile sizing without saving draft changes. + +`browser-generation-scroll.cjs` checks scroll restoration from an editor near the bottom of a long rule on desktop/mobile and verifies template styling is limited to non-interactive blocks. +`audit` unit tests preserve comments and unknown IDs while resolving references. PostgreSQL regression checks historical names after rename, raw UUID preservation and cross-workspace lookup isolation. + +`browser-setup-layout.cjs` checks compact review state, country holiday counts, collapsed widgets, import access and mobile width. Demo holiday imports also run through integration seed/reset checks. + + +Repeated-cover regressions check approval gating, partial interval splitting, preserved original legs, stale-owner rejection, forced corrections and calendar interaction on an already replaced day. + +The setup inventory browser checks cover collapsed resource rows, the schedule-type creation chooser, top utility widgets, desktop/mobile sizing, and absence of the old anchor/example controls. Configuration browser checks expand manual rows before deletion/reordering and retain drag-and-drop routing coverage. + +Advanced demo regression checks dependent iteration filtering, inherited regional handovers/participants, tier-based escalation settings and working windows, shared tier edits with stable resource IDs, and early validation of a newly enabled entry with a missing tier. Integration seed/reset verifies all four workspaces. + +`browser-advanced-demos.cjs` opens both advanced generation editors, verifies clean server previews, and checks the demo workspace access matrix for Alice, Bob and Carol. + +Setup layout checks also verify iteration-source summaries, omission of zero iterations, collapsed resource descriptions and source-rule navigation for all three resource kinds. + +Diagnostics regression covers bad participant data, primary/secondary errors, scoped source links, draft-preserving navigation, always-visible status and recovery to valid. Absence tests cover exact interval selection, independent schedule ranges, rejection of overlap/out-of-absence bounds, and unchanged bulk range behavior. + +`browser-diagnostics.cjs` exercises invalid participant data, rule/variable links with local draft retention, and recovery to a valid status on mobile. `browser-absence.cjs` additionally selects an uncovered impact row and checks exact schedule/time bounds before the existing bulk workflow. PostgreSQL tests verify exact absence coverage remains pending until approval and leaves surrounding hours uncovered. + +Unified absence editor tests change the start time of a shortcut-created range, verify resulting server boundaries, and use the same date/time controls for manual ranges. Unit coverage includes local range scope, half-open bounds and DST rejection. + +Coverage diagnostics distinguish pending approval from missing substitutes. Pending request boundaries split uncovered intervals so partial requests never disguise remaining gaps. The UI shows substitutes and outstanding approvers, links to requests, and keeps publication blocked until approval. Browser regression covers partial pending coverage, remaining gaps, approver filtering, and responsive spacing. + +Schedule setup places narrower variable and generation-rule panels beside a stacked availability sidebar. Absences and holidays remain open; their lists scroll internally. Country holiday groups retain accordions inside the bounded list, with add/import actions opening dialogs. Responsive layout moves the sidebar below the editors. Browser checks cover column position, bounded holiday expansion, visible actions and mobile width. + +Generated routing placement is shown only when manual routes and an enabled route-generating rule coexist. Options name the manual routes in evaluation order; the stored insertion index is unchanged. Browser checks verify hidden controls for generated-only setups and named options/order for mixed routing. + +Resource inventory is a distinct UI component (`resource-inventory.html`). All expanded resource kinds share `resource-details.html`: labeled icon rows, consistent spacing, sequential connectors, and a common source-rule control. Presentation rows and monochrome SVG paths come from pure UI helpers; schedules, routing order and provider state are unchanged. Check rotation, fixed owner, priority fallback, escalation timing/repeats and routing conditions on desktop/mobile. + +Expanded resources use compact type-specific horizontal widgets instead of parameter tables: rotation metadata plus ordered people, priority people plus fallback, escalation steps plus repeat footer, routing conditions plus destination. Shared icon, chip and connector styles keep them consistent. Chains wrap within narrow screens; source links and edit actions retain their common placement. + +The generation-rule editor is a generic technical constructor: visible iteration aliases and collection bindings, an explicit optional condition, and typed output blocks. New rules start without implicit project/responsible-variable detection or generated blocks. Variable names carry no semantic meaning. Compact controls retain the existing typed binding editor, value preview, live server diagnostics and draft semantics. Check no-iteration rules, nested collection bindings, conditions and multiple output types on desktop/mobile. + +Binding selection uses one anchored, viewport-clamped popover beside the originating control; the constructor remains visible and keeps its scroll position. All source kinds, nested lookups/lists, paths, text affixes and live value previews remain supported. Literal escalation delays are directly editable. Done/Escape closes the popover and returns focus. Server and change previews are collapsed summaries with counts and bounded change lists. Browser checks cover context retention, source editing, nested bindings, keyboard dismissal and mobile bounds. + +`browser-repeat-defaults.cjs` verifies absent escalation defaults render as editable numbers, edited values survive reopening the draft, and explicit dictionary lookups remain intact. + +The main Acme Engineering demo contains six services (Payments, Search, Billing, Identity, Data platform, Developer portal), each with two or three ordered owners drawn from five login members: alice, bob, carol, dave, erin. All five share the weekly Monday 11:00 Time off rotation. Dave and Erin only access Acme; Carol also retains Support/Commerce access. Other demo workspaces remain separate. Login password remains `meguri-demo-only`. + +Generation rule results use the actual authorized server expansion, including schedules, escalation policies and routes, with resolved names and compact settings. The panel is always present with explicit checking, invalid, disabled and empty states; it respects iteration filters and does not simulate generation in the browser. Preview data contains only scheduling resources and scoped people, never integration configuration. + +Permanent assignees are tested as single-participant rotations, including absence, replacement approval and publication blocking. The removed fixed-assignee schedule and generation types are rejected rather than normalized. + +Workspace isolation regressions exercise independent memberships and variables, a single revision for configuration and working-time changes, read-only generation preview, stale review rejection, atomic activation/history/outbox, and owner settings permissions. + +The default Engineering example stores projects as one list of dictionaries: each entry has `name` and an ordered `responsible` person list. The Project iteration reads both fields directly from its current entry; no parallel responsible lookup variable is needed. The independent `on_call` list still defines the shared weekly rotation. + +Variable editing uses a bounded, progressively disclosed tree: only the root starts expanded, object rows show names and collection summaries, and nested values expand in place. Values mode hides type/key controls; Structure mode exposes them without losing edits. Duplicate assigns fresh IDs recursively; ordering and live server validation remain intact. Check nested editing, duplication, mode switching and narrow viewports. + +Generation map opens from Generation rules as a read-only, draggable/zoomable Cytoscape canvas. Server preview returns actual iteration counts, input contexts, predicate outcomes and produced IDs; no scheduling evaluation is duplicated in JavaScript. Iterated outputs start collapsed into a group; inspecting a node or pass reveals source data and verified resources. Invalid drafts show diagnostics rather than stale resources. The map is a captured draft preview refreshed explicitly, and never applies changes. Bundled Cytoscape 3.33.1 is served locally with its MIT license; no CDN or new framework is required. Browser coverage checks groups, details, source navigation, pan/zoom and mobile layout; engine regression verifies traced pass results. + +The generation map arranges numbered rules in saved execution order from left to right, joined by solid sequence arrows. Each rule owns a vertical column of iterations, conditions and outputs. Dashed secondary edges show data dependencies rather than execution order. Repeated output groups use offset background cards; decorative layers follow dragging and do not receive selection. Browser checks verify rule ordering, aligned output columns and stack movement. + +Map input details display evaluated server values for each block and pass, never serialized bindings. Shared values are deduplicated across passes; differing values are explicitly counted. Generated references collapse to type/name cards, and people resolve by display name. Empty binding metadata and raw reference UUIDs are not presented. Condition operands and escalation recipients/delays come from the same resolver used during expansion. + +The map’s all-passes input overview lists deduplicated upstream sources, not expanded output settings: variables with used binding addresses and resource references grouped by producing block. References internal to the selected whole rule are omitted; selecting an individual output block shows its dependencies. Selecting a concrete pass still exposes its evaluated field values. + +Expanded map resources use type-specific labeled fields and the same compact reference cards as inputs, never JSON dumps. Priority schedules show ordered people, fallback and zoned handover; rotations add anchor/cycle; policies show recipient/timing/condition and repeat behavior; routes show conditions, working window and destination. Reference names resolve from the preview snapshot. Browser regression checks visible names, participant order and absence of UUID metadata. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..0138e50 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,60 @@ +{ + "name": "meguri-browser-tests", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "meguri-browser-tests", + "devDependencies": { + "playwright": "1.62.1" + } + }, + "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/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..b496bb3 --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "name": "meguri-browser-tests", + "private": true, + "scripts": { + "test:browser": "node tests/browser-generation-map.cjs && node tests/browser-variable-editor.cjs && node tests/browser-rule-result.cjs && node tests/browser-repeat-defaults.cjs && node tests/browser-binding-popover.cjs && node tests/browser-resource-details.cjs && node tests/browser-coverage-status.cjs && node tests/browser-diagnostics.cjs && node tests/browser-advanced-demos.cjs && node tests/browser.cjs && node tests/browser-calendars.cjs && node tests/browser-configuration.cjs && node tests/browser-absence.cjs && node tests/browser-generation.cjs && node tests/browser-cover.cjs && node tests/browser-navigation.cjs && node tests/browser-binding-preview.cjs && node tests/browser-generation-scroll.cjs && node tests/browser-setup-layout.cjs" + }, + "devDependencies": { + "playwright": "1.62.1" + } +} diff --git a/scripts/dev.ps1 b/scripts/dev.ps1 new file mode 100644 index 0000000..cbfd6e0 --- /dev/null +++ b/scripts/dev.ps1 @@ -0,0 +1,49 @@ +param([switch]$DatabaseOnly, [int]$DatabasePort = 55432, [int]$Port = 3000, + [string]$PostgresBin = 'C:\Program Files\PostgreSQL\17\bin') +$ErrorActionPreference = 'Stop' +$repo = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +$portableBin = Join-Path $repo 'target/postgresql/pgsql/bin' +if (!$PSBoundParameters.ContainsKey('PostgresBin') -and (Test-Path (Join-Path $portableBin 'postgres.exe'))) { $PostgresBin = $portableBin } +if (!(Test-Path (Join-Path $PostgresBin '../share/postgres.bki'))) { throw 'A complete PostgreSQL server distribution is required. Pass -PostgresBin PATH; client-only installations lack share/postgres.bki.' } +$localDir = Join-Path $repo 'target/local' +$dataDir = Join-Path $localDir 'postgres' +New-Item -ItemType Directory -Force $localDir | Out-Null +if (!(Test-Path (Join-Path $dataDir 'PG_VERSION'))) { + & (Join-Path $PostgresBin 'initdb.exe') -D $dataDir -U meguri --auth=trust --encoding=UTF8 --locale=C + if ($LASTEXITCODE) { throw 'initdb failed' } +} +& (Join-Path $PostgresBin 'pg_ctl.exe') -D $dataDir status *> $null +if ($LASTEXITCODE) { + $pgArgs = @('-D',('"'+$dataDir+'"'),'-l',('"'+(Join-Path $localDir 'postgres.log')+'"'),'-o',('"-h 127.0.0.1 -p '+$DatabasePort+'"'),'-w','start') + $pgStart = Start-Process -FilePath (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList $pgArgs -WindowStyle Hidden -PassThru + $pgStart.WaitForExit() + if ($pgStart.ExitCode) { throw 'PostgreSQL did not start; inspect target/local/postgres.log' } +} +foreach ($dbName in @('meguri','meguri_workspace_test')) { + $exists = & (Join-Path $PostgresBin 'psql.exe') -h 127.0.0.1 -p $DatabasePort -U meguri -d postgres -Atc "SELECT 1 FROM pg_database WHERE datname='$dbName'" + if ($exists -ne '1') { + & (Join-Path $PostgresBin 'createdb.exe') -h 127.0.0.1 -p $DatabasePort -U meguri $dbName + if ($LASTEXITCODE) { throw 'Could not create development database' } + } +} +@{ postgresBin=$PostgresBin; databasePort=$DatabasePort; port=$Port } | ConvertTo-Json | Set-Content (Join-Path $localDir 'settings.json') +$env:DATABASE_URL = "postgresql://meguri@127.0.0.1:$DatabasePort/meguri" +$env:MEGURI_TEST_DATABASE_URL = "postgresql://meguri@127.0.0.1:$DatabasePort/meguri_workspace_test" +if ($DatabaseOnly) { Write-Output "Database ready. Test URL: $env:MEGURI_TEST_DATABASE_URL"; return } +$pidFile = Join-Path $localDir 'app.pid' +$appExecutable = Join-Path $localDir 'meguri-dev.exe' +if (Test-Path $pidFile) { + $old = Get-Process -Id ([int](Get-Content $pidFile)) -ErrorAction SilentlyContinue + if ($old -and $old.Path -eq $appExecutable) { throw 'Meguri is already running. Run scripts/stop-dev.ps1 first.' } +} +Push-Location $repo +try { cargo build --locked; if ($LASTEXITCODE) { throw 'Build failed' } } finally { Pop-Location } +$env:MEGURI_BIND = "127.0.0.1:$Port" +$env:MEGURI_PUBLIC_URL = "http://127.0.0.1:$Port" +$env:MEGURI_ALLOW_SIGNUP = 'true' +$env:MEGURI_SEED_DEMO = 'true' +$env:MEGURI_ROLE = 'all' +Copy-Item -LiteralPath (Join-Path $repo 'target/debug/meguri.exe') -Destination $appExecutable +$app = Start-Process -FilePath $appExecutable -WorkingDirectory $repo -WindowStyle Hidden -PassThru -RedirectStandardOutput (Join-Path $localDir 'app.log') -RedirectStandardError (Join-Path $localDir 'app-error.log') +$app.Id | Set-Content $pidFile +Write-Output "Meguri starting at $env:MEGURI_PUBLIC_URL — demo: alice / meguri-demo-only" diff --git a/scripts/stop-dev.ps1 b/scripts/stop-dev.ps1 new file mode 100644 index 0000000..97602a2 --- /dev/null +++ b/scripts/stop-dev.ps1 @@ -0,0 +1,13 @@ +$ErrorActionPreference = 'Stop' +$repo = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +$localDir = Join-Path $repo 'target/local' +$pidFile = Join-Path $localDir 'app.pid' +if (Test-Path $pidFile) { + $app = Get-Process -Id ([int](Get-Content $pidFile)) -ErrorAction SilentlyContinue + if ($app -and $app.Path -eq (Join-Path $localDir 'meguri-dev.exe')) { Stop-Process -Id $app.Id } + Remove-Item -LiteralPath $pidFile +} +if (Test-Path (Join-Path $localDir 'settings.json')) { + $settings = Get-Content (Join-Path $localDir 'settings.json') | ConvertFrom-Json + & (Join-Path $settings.postgresBin 'pg_ctl.exe') -D (Join-Path $localDir 'postgres') -m fast -w stop +} diff --git a/src/absence.rs b/src/absence.rs new file mode 100644 index 0000000..cedfa99 --- /dev/null +++ b/src/absence.rs @@ -0,0 +1,714 @@ +//! Pure absence impact planning. Explicit ranges expand across affected schedules. +use crate::{ + domain::*, + engine, + error::{Result, ensure}, +}; +use chrono::{DateTime, Duration, NaiveDate, Utc}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; +use uuid::Uuid; + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ExactCover { + pub schedule_id: Uuid, + pub start: DateTime, + pub end: DateTime, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct LocalCover { + #[serde(default)] + pub schedule_id: Option, + pub start: chrono::NaiveDateTime, + pub end: chrono::NaiveDateTime, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CoverRange { + #[serde(default)] + pub local: Option, + #[serde(default)] + pub exact: Option, + pub start: NaiveDate, + pub end: NaiveDate, + pub replacement: Uuid, + #[serde(default)] + pub replace_projects: bool, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Draft { + pub absence: Absence, + #[serde(default)] + pub ranges: Vec, +} + +#[derive(Debug, Serialize)] +pub struct Impact { + pub start_day: NaiveDate, + pub end_day: NaiveDate, + pub schedule_id: Uuid, + pub schedule_name: String, + pub start: DateTime, + pub end: DateTime, + pub before: Uuid, + pub after: Option, + pub status: String, +} + +#[derive(Debug, Serialize)] +pub struct Plan { + pub rows: Vec, + pub groups: Vec>, + pub warnings: Vec, + pub errors: Vec, + pub schedules: usize, + pub uncovered: usize, +} + +pub fn synthetic_request( + absence: Uuid, + legs: Vec, + now: DateTime, +) -> ReplacementRequest { + ReplacementRequest { + effective_legs: None, + id: Uuid::nil(), + absence_id: Some(absence), + requested_by: Uuid::nil(), + created_at: now, + reason: crate::i18n::text("err_absence_coverage_2fccc7").into(), + legs, + required_approvers: BTreeSet::new(), + approvals: BTreeSet::new(), + status: RequestStatus::Applied, + applied_at: Some(now), + forced_by: None, + force_reason: None, + } +} + +/// Validate against original ownership, but preserve all other absences and overrides. +pub fn validate_request( + doc: &WorkspaceDocument, + request: &ReplacementRequest, + members: &BTreeSet, + now: DateTime, +) -> Result<()> { + let absence = doc + .absences + .iter() + .find(|a| Some(a.id) == request.absence_id) + .ok_or_else(|| { + crate::error::Error::Validation( + crate::i18n::text("err_this_absence_no_longer_exists_8d51ae").into(), + ) + })?; + ensure( + !request.legs.is_empty() && request.legs.len() <= 500, + crate::i18n::text("err_absence_coverage_supports_1_500_affected_interva_d61c7a"), + )?; + let mut original = doc.clone(); + original.absences.retain(|a| a.id != absence.id); + for leg in &request.legs { + ensure( + leg.from_user == absence.user_id, + crate::i18n::text("err_absence_coverage_can_replace_only_the_absent_emp_a99c60"), + )?; + ensure( + leg.start >= engine::schedule_handover(doc, leg.schedule_id, absence.start)? + && leg.end + <= engine::schedule_handover( + doc, + leg.schedule_id, + absence.end + Duration::days(1), + )?, + crate::i18n::text("err_replacement_must_stay_inside_the_absence_8ec5a5"), + )?; + engine::validate_legs(&original, std::slice::from_ref(leg), members, now)?; + } + for (index, leg) in request.legs.iter().enumerate() { + ensure( + request.legs[..index].iter().all(|other| { + other.schedule_id != leg.schedule_id + || other.end <= leg.start + || leg.end <= other.start + }), + crate::i18n::text("err_absence_replacement_intervals_overlap_d053f4"), + )?; + } + Ok(()) +} + +fn range_interval( + doc: &WorkspaceDocument, + range: &CoverRange, + schedule: &Schedule, + now: DateTime, + absence: &Absence, +) -> Result, DateTime)>> { + if let Some(local) = &range.local { + if local.schedule_id.is_some_and(|id| id != schedule.id) + || (local.schedule_id.is_none() + && matches!(schedule.kind, ScheduleKind::Priority { .. }) + && !range.replace_projects) + { + return Ok(None); + } + let start = engine::local_time(doc, local.start.date(), local.start.time())?; + let end = engine::local_time(doc, local.end.date(), local.end.time())?; + let first = engine::schedule_handover(doc, schedule.id, absence.start)?; + let last = engine::schedule_handover(doc, schedule.id, absence.end + Duration::days(1))?; + if local.schedule_id.is_some() { + ensure( + start >= first && end <= last, + crate::i18n::text("err_replacement_must_stay_inside_the_absence_8ec5a5"), + )?; + } + let span = (start.max(first).max(now), end.min(last)); + return Ok((span.0 < span.1).then_some(span)); + } + if let Some(exact) = &range.exact { + return Ok((exact.schedule_id == schedule.id).then_some((exact.start.max(now), exact.end))); + } + if matches!(schedule.kind, ScheduleKind::Priority { .. }) && !range.replace_projects { + return Ok(None); + } + Ok(Some(( + engine::schedule_handover(doc, schedule.id, range.start)?.max(now), + engine::schedule_handover(doc, schedule.id, range.end + Duration::days(1))?, + ))) +} + +pub fn plan( + doc: &WorkspaceDocument, + draft: &Draft, + members: &BTreeSet, + now: DateTime, +) -> Result { + let absence = &draft.absence; + ensure( + absence.start <= absence.end && (absence.end - absence.start).num_days() < 366, + crate::i18n::text("err_choose_an_absence_of_1_366_duty_days_090954"), + )?; + ensure( + absence.end >= engine::duty_day(doc, now)? + && absence.end <= now.date_naive() + Duration::days(366), + crate::i18n::text("err_absence_must_end_within_the_next_year_767689"), + )?; + ensure( + !doc.absences.iter().any(|a| { + a.id == absence.id + || (a.user_id == absence.user_id + && a.start <= absence.end + && absence.start <= a.end) + }), + crate::i18n::text("err_this_person_already_has_an_overlapping_absence_f17a77"), + )?; + ensure( + draft.ranges.len() <= 24, + crate::i18n::text("err_use_at_most_24_replacement_ranges_52203c"), + )?; + let mut candidate = doc.clone(); + candidate.absences.push(absence.clone()); + candidate.validate(members)?; + let people = candidate.person_ids(members); + let mut ranges = draft.ranges.clone(); + ranges.sort_by_key(|r| r.start); + for range in &ranges { + if let Some(local) = &range.local { + ensure( + range.exact.is_none(), + crate::i18n::text("absence_invalid_interval"), + )?; + if let Some(id) = local.schedule_id { + ensure( + doc.schedule(id).is_some(), + crate::i18n::text("err_schedule_does_not_exist_637210"), + )?; + } + let start = engine::local_time(doc, local.start.date(), local.start.time())?; + let end = engine::local_time(doc, local.end.date(), local.end.time())?; + ensure( + start < end + && end - start <= Duration::days(90) + && local.start.date() >= absence.start + && local.end.date() <= absence.end + Duration::days(1), + crate::i18n::text("absence_invalid_interval"), + )?; + } else { + ensure( + range.start >= absence.start + && range.end <= absence.end + && range.start <= range.end + && (range.end - range.start).num_days() < 90, + crate::i18n::text("err_each_replacement_range_must_cover_1_90_duty_days_f18f21"), + )?; + } + if let Some(exact) = &range.exact { + ensure( + doc.schedule(exact.schedule_id).is_some(), + crate::i18n::text("err_schedule_does_not_exist_637210"), + )?; + ensure( + exact.start < exact.end + && exact.start + >= engine::schedule_handover(doc, exact.schedule_id, range.start)? + && exact.end + <= engine::schedule_handover( + doc, + exact.schedule_id, + range.end + Duration::days(1), + )?, + crate::i18n::text("err_replacement_must_stay_inside_the_absence_8ec5a5"), + )?; + } + ensure( + range.replacement != absence.user_id && people.contains(&range.replacement), + crate::i18n::text("err_choose_a_different_workspace_user_or_email_conta_a0a168"), + )?; + } + for (i, range) in ranges.iter().enumerate() { + for other in &ranges[..i] { + for schedule in &doc.schedules { + if let (Some((a, b)), Some((c, d))) = ( + range_interval(doc, range, schedule, now, absence)?, + range_interval(doc, other, schedule, now, absence)?, + ) { + ensure( + b <= c || d <= a, + crate::i18n::text("err_replacement_ranges_must_not_overlap_bf6aad"), + )?; + } + } + } + } + let mut plan = Plan { + rows: vec![], + groups: vec![], + warnings: vec![], + errors: vec![], + schedules: 0, + uncovered: 0, + }; + for range in ranges { + let mut legs = vec![]; + for schedule in &doc.schedules { + let Some((start, end)) = range_interval(doc, &range, schedule, now, absence)? else { + continue; + }; + if start >= end { + continue; + } + let before = engine::timeline(doc, start, end)?; + for assignment in before + .assignments + .iter() + .filter(|a| a.schedule_id == schedule.id && a.user_id == absence.user_id) + { + legs.push(ReplacementLeg { + schedule_id: schedule.id, + start: assignment.start, + end: assignment.end, + from_user: absence.user_id, + to_user: range.replacement, + }); + } + } + if legs.is_empty() { + continue; + } + let request = synthetic_request(absence.id, legs.clone(), now); + match validate_request(&candidate, &request, members, now) { + Ok(()) => { + engine::supersede_overrides(&mut candidate, &request.legs); + candidate.requests.push(request); + plan.groups.push(legs); + } + Err(error) => plan.errors.push(format!( + "{} – {}: {error}", + range.start.format("%d-%m-%Y"), + range.end.format("%d-%m-%Y") + )), + } + } + for schedule in &doc.schedules { + let start = engine::schedule_handover(doc, schedule.id, absence.start)?.max(now); + let end = engine::schedule_handover(doc, schedule.id, absence.end + Duration::days(1))?; + if start >= end { + continue; + } + let before = engine::timeline(doc, start, end)?; + let after = engine::timeline(&candidate, start, end)?; + let old: Vec<_> = before + .assignments + .iter() + .filter(|a| a.schedule_id == schedule.id) + .collect(); + let new: Vec<_> = after + .assignments + .iter() + .filter(|a| a.schedule_id == schedule.id) + .collect(); + let mut bounds = BTreeSet::from([start, end]); + for a in old.iter().chain(&new) { + bounds.insert(a.start); + bounds.insert(a.end); + } + let bounds: Vec<_> = bounds.into_iter().collect(); + for pair in bounds.windows(2) { + let a = old + .iter() + .find(|a| a.start <= pair[0] && pair[0] < a.end) + .unwrap_or_else(|| { + panic!("{}", crate::i18n::text("err_timeline_covers_range_6c1844")) + }); + let b = new + .iter() + .find(|a| a.start <= pair[0] && pair[0] < a.end) + .unwrap_or_else(|| { + panic!("{}", crate::i18n::text("err_timeline_covers_range_6c1844")) + }); + if a.user_id != absence.user_id + && a.user_id == b.user_id + && a.unavailable == b.unavailable + { + continue; + } + let proposed = plan + .groups + .iter() + .flatten() + .any(|l| l.schedule_id == schedule.id && l.start <= pair[0] && pair[0] < l.end); + let status = if b.unavailable { + "uncovered" + } else if proposed { + "replacement" + } else { + "automatic" + }; + let after = (!b.unavailable).then_some(b.user_id); + if let Some(last) = plan.rows.last_mut() + && last.schedule_id == schedule.id + && last.end == pair[0] + && last.before == a.user_id + && last.after == after + && last.status == status + { + last.end = pair[1]; + last.end_day = engine::schedule_duty_day( + doc, + schedule.id, + pair[1] - Duration::nanoseconds(1), + )?; + continue; + } + plan.rows.push(Impact { + start_day: engine::schedule_duty_day(doc, schedule.id, pair[0])?, + end_day: engine::schedule_duty_day( + doc, + schedule.id, + pair[1] - Duration::nanoseconds(1), + )?, + schedule_id: schedule.id, + schedule_name: schedule.name.clone(), + start: pair[0], + end: pair[1], + before: a.user_id, + after, + status: status.into(), + }); + } + } + plan.schedules = plan + .rows + .iter() + .map(|r| r.schedule_id) + .collect::>() + .len(); + plan.uncovered = plan.rows.iter().filter(|r| r.after.is_none()).count(); + for policy in &doc.escalations { + if policy.steps.iter().any(|s| { + matches!(s.recipient,Recipient::User{id} if id==absence.user_id) + || matches!(s.recipient, Recipient::TeamAll | Recipient::TeamRandom) + }) { + plan.warnings.push(crate::i18n::message( + "err__p0_direct_user_team_notifications_are_not_chang_66a117", + &[("p0", (policy.name).to_string())], + )); + } + } + Ok(plan) +} + +#[cfg(test)] +mod tests { + use super::*; + fn fixture() -> ( + WorkspaceDocument, + Draft, + BTreeSet, + DateTime, + [Uuid; 3], + ) { + let people = [Uuid::new_v4(), Uuid::new_v4(), Uuid::new_v4()]; + let mut doc = WorkspaceDocument::new("Platform".into()); + doc.timezone = "UTC".into(); + let start: NaiveDate = "2027-01-04".parse().unwrap(); + let rotation = Uuid::new_v4(); + doc.schedules.push(Schedule { + id: rotation, + name: "General".into(), + handover: default_handover(), + kind: ScheduleKind::Rotation { + participants: vec![people[0]], + anchor: start, + shift_days: 7, + }, + }); + doc.schedules.push(Schedule { + id: Uuid::new_v4(), + name: "Backup".into(), + handover: "09:00:00".parse().unwrap(), + kind: ScheduleKind::Rotation { + participants: vec![people[0]], + anchor: "2026-09-21".parse().unwrap(), + shift_days: 7, + }, + }); + for i in 0..5 { + doc.schedules.push(Schedule { + id: Uuid::new_v4(), + name: format!("Project {i}"), + handover: default_handover(), + kind: ScheduleKind::Priority { + participants: vec![people[0], people[2]], + fallback: rotation, + }, + }); + } + let draft = Draft { + absence: Absence { + id: Uuid::new_v4(), + user_id: people[0], + start, + end: start + Duration::days(13), + }, + ranges: vec![ + CoverRange { + local: None, + exact: None, + start, + end: start + Duration::days(6), + replacement: people[1], + replace_projects: false, + }, + CoverRange { + local: None, + exact: None, + start: start + Duration::days(7), + end: start + Duration::days(13), + replacement: people[2], + replace_projects: false, + }, + ], + }; + ( + doc, + draft, + people.into(), + "2027-01-01T00:00:00Z".parse().unwrap(), + people, + ) + } + #[test] + fn two_ranges_cover_all_rotations_and_backups_and_promote_all_projects() { + let (doc, draft, people, now, ids) = fixture(); + let plan = plan(&doc, &draft, &people, now).unwrap(); + assert!(plan.errors.is_empty()); + assert_eq!(plan.schedules, 7); + assert_eq!(plan.uncovered, 0); + assert_eq!(plan.groups.len(), 2); + assert_eq!(plan.groups[0].len(), 2); + assert_eq!(plan.groups[1].len(), 2); + assert!(plan.groups[0].iter().all(|l| l.to_user == ids[1])); + assert!(plan.groups[1].iter().all(|l| l.to_user == ids[2])); + assert!( + plan.rows + .iter() + .filter(|r| r.schedule_name.starts_with("Project")) + .all(|r| r.after == Some(ids[2]) && r.status == "automatic") + ); + assert!(doc.absences.is_empty()); + assert!(doc.requests.is_empty()); + assert!( + plan.groups[0] + .iter() + .any(|l| l.start.time().to_string() == "09:00:00") + ); + } + #[test] + fn uncovered_ranges_unavailable_substitutes_and_explicit_project_cover_are_distinct() { + let (mut doc, mut draft, people, now, ids) = fixture(); + draft.ranges.clear(); + let uncovered = plan(&doc, &draft, &people, now).unwrap(); + assert_eq!(uncovered.uncovered, 2); + assert_eq!(uncovered.groups.len(), 0); + draft.ranges.push(CoverRange { + local: None, + exact: None, + start: draft.absence.start, + end: draft.absence.end, + replacement: ids[1], + replace_projects: true, + }); + let covered = plan(&doc, &draft, &people, now).unwrap(); + assert_eq!(covered.groups[0].len(), 7); + assert_eq!(covered.uncovered, 0); + doc.absences.push(Absence { + id: Uuid::new_v4(), + user_id: ids[1], + start: draft.absence.start, + end: draft.absence.start, + }); + assert!(!plan(&doc, &draft, &people, now).unwrap().errors.is_empty()); + draft.ranges.push(draft.ranges[0].clone()); + assert!(plan(&doc, &draft, &people, now).is_err()); + } + #[test] + fn planned_substitutions_preserve_other_owners_and_can_replace_current_cover() { + let (mut doc, draft, people, now, ids) = fixture(); + if let ScheduleKind::Rotation { participants, .. } = &mut doc.schedules[0].kind { + participants.push(ids[1]); + } + let covered = plan(&doc, &draft, &people, now).unwrap(); + assert_eq!( + covered.groups[1].len(), + 1, + "Second week already belongs to someone else on the rotation" + ); + let mut existing = synthetic_request( + Uuid::new_v4(), + vec![ReplacementLeg { + schedule_id: doc.schedules[0].id, + start: covered.groups[0][0].start, + end: covered.groups[0][0].end, + from_user: ids[1], + to_user: ids[0], + }], + now, + ); + existing.absence_id = None; + doc.requests.push(existing); + let preview = plan(&doc, &draft, &people, now).unwrap(); + assert!(preview.errors.is_empty()); + assert_eq!(preview.uncovered, 0); + assert!( + preview + .groups + .iter() + .flatten() + .all(|leg| leg.from_user == ids[0]) + ); + assert!( + doc.requests[0].effective_legs.is_none(), + "Preview must not modify the approved request" + ); + } + #[test] + fn local_ranges_share_scoping_bounds_and_reject_dst_discontinuities() { + let (mut doc, mut draft, people, now, ids) = fixture(); + draft.ranges = vec![CoverRange { + local: Some(LocalCover { + schedule_id: Some(doc.schedules[0].id), + start: "2027-01-04T12:30:00".parse().unwrap(), + end: "2027-01-05T14:00:00".parse().unwrap(), + }), + exact: None, + start: draft.absence.start, + end: draft.absence.end, + replacement: ids[1], + replace_projects: false, + }]; + let result = plan(&doc, &draft, &people, now).unwrap(); + assert_eq!(result.groups[0].len(), 1); + assert_eq!( + result.groups[0][0].start, + "2027-01-04T12:30:00Z".parse::>().unwrap() + ); + draft.ranges[0].local.as_mut().unwrap().schedule_id = None; + assert_eq!(plan(&doc, &draft, &people, now).unwrap().groups[0].len(), 2); + draft.ranges.push(draft.ranges[0].clone()); + assert!(plan(&doc, &draft, &people, now).is_err()); + draft.ranges.pop(); + doc.timezone = "Europe/Nicosia".into(); + for date in ["2027-03-28", "2027-10-31"] { + let day: NaiveDate = date.parse().unwrap(); + draft.absence.start = day - Duration::days(1); + draft.absence.end = day + Duration::days(1); + let local = draft.ranges[0].local.as_mut().unwrap(); + local.start = day.and_hms_opt(3, 30, 0).unwrap(); + local.end = day.and_hms_opt(5, 0, 0).unwrap(); + assert!(plan(&doc, &draft, &people, now).is_err()); + } + } + + #[test] + fn exact_impact_cover_is_scoped_and_rejects_overlaps_or_outside_bounds() { + let (doc, mut draft, people, now, ids) = fixture(); + draft.ranges.clear(); + let impact = plan(&doc, &draft, &people, now).unwrap(); + let gaps: Vec<_> = impact + .rows + .iter() + .filter(|r| r.status == "uncovered") + .collect(); + assert!(gaps.len() >= 2); + let gap = gaps[0]; + assert_eq!( + gap.end_day, draft.absence.end, + "Merged impact retains its final duty day" + ); + let exact = ExactCover { + schedule_id: gap.schedule_id, + start: gap.start + Duration::hours(2), + end: gap.end - Duration::hours(2), + }; + draft.ranges.push(CoverRange { + local: None, + start: gap.start_day, + end: gap.end_day, + replacement: ids[1], + replace_projects: false, + exact: Some(exact.clone()), + }); + let proposed = plan(&doc, &draft, &people, now).unwrap(); + assert!(proposed.errors.is_empty()); + assert_eq!(proposed.groups[0].len(), 1); + let leg = &proposed.groups[0][0]; + assert_eq!( + (leg.schedule_id, leg.start, leg.end), + (exact.schedule_id, exact.start, exact.end) + ); + let other = gaps[1]; + draft.ranges.push(CoverRange { + local: None, + start: other.start_day, + end: other.end_day, + replacement: ids[2], + replace_projects: false, + exact: Some(ExactCover { + schedule_id: other.schedule_id, + start: other.start, + end: other.end, + }), + }); + assert!(plan(&doc, &draft, &people, now).unwrap().errors.is_empty()); + draft.ranges.push(draft.ranges[0].clone()); + assert!(plan(&doc, &draft, &people, now).is_err()); + draft.ranges.pop(); + draft.ranges[0].exact.as_mut().unwrap().start = gap.start - Duration::hours(1); + assert!(plan(&doc, &draft, &people, now).is_err()); + } +} diff --git a/src/audit.rs b/src/audit.rs new file mode 100644 index 0000000..73d1de2 --- /dev/null +++ b/src/audit.rs @@ -0,0 +1,90 @@ +//! Read-only audit presentation; persisted event payloads are never rewritten. +use crate::domain::WorkspaceDocument; +use serde_json::{Value, json}; +use std::collections::BTreeMap; + +pub type Names = BTreeMap; + +pub fn remember(names: &mut Names, doc: &WorkspaceDocument) { + for person in &doc.people { + names.insert(person.id.to_string(), person.display_name.clone()); + if let Some(id) = person.user_id { + names.insert(id.to_string(), person.display_name.clone()); + } + } + for (id, name) in doc + .schedules + .iter() + .map(|s| (s.id, &s.name)) + .chain(doc.escalations.iter().map(|s| (s.id, &s.name))) + .chain(doc.routes.iter().map(|s| (s.id, &s.name))) + .chain(doc.generation.rules.iter().map(|s| (s.id, &s.name))) + .chain(doc.generation.variables.iter().map(|s| (s.id, &s.name))) + { + names.insert(id.to_string(), name.clone()); + } +} + +pub fn resolve(value: &Value, names: &Names) -> Value { + match value { + Value::String(id) => names + .get(id) + .map(|name| json!({"name":name,"id":id})) + .unwrap_or_else(|| value.clone()), + Value::Array(items) => Value::Array(items.iter().map(|v| resolve(v, names)).collect()), + Value::Object(fields) => Value::Object( + fields + .iter() + .map(|(key, value)| { + // Free text must remain verbatim, even if a comment happens to equal a UUID. + let reference = key == "id" + || key.ends_with("_id") + || matches!( + key.as_str(), + "from_user" + | "to_user" + | "requested_by" + | "forced_by" + | "approvals" + | "required_approvers" + | "participants" + | "primary" + | "secondary" + | "fallback" + | "user" + | "redirect_to" + | "escalation" + | "variable" + | "iteration" + | "output" + ); + let resolved = if reference || value.is_object() || value.is_array() { + resolve(value, names) + } else { + value.clone() + }; + (key.clone(), resolved) + }) + .collect(), + ), + _ => value.clone(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn references_expand_without_changing_comments_unknown_ids_or_raw_details() { + let id = uuid::Uuid::new_v4().to_string(); + let names = Names::from([(id.clone(), "Alice".into())]); + let raw = json!({"from_user":id,"approvals":[id],"reason":id,"unknown_id":"missing","legs":[{"to_user":id}]}); + let resolved = resolve(&raw, &names); + assert_eq!(resolved["from_user"]["name"], "Alice"); + assert_eq!(resolved["approvals"][0]["id"], id); + assert_eq!(resolved["legs"][0]["to_user"]["name"], "Alice"); + assert_eq!(resolved["reason"], id); + assert_eq!(resolved["unknown_id"], "missing"); + assert_eq!(raw["from_user"], id); + } +} diff --git a/src/configuration.rs b/src/configuration.rs new file mode 100644 index 0000000..ef4534f --- /dev/null +++ b/src/configuration.rs @@ -0,0 +1,374 @@ +//! Pure edits of the configuration graph. No storage or provider side effects. +use crate::{ + domain::*, + error::{Error, Result, ensure}, +}; +use chrono::{DateTime, Utc}; +use serde::Serialize; +use std::collections::BTreeSet; +use uuid::Uuid; + +#[derive(Debug, Serialize)] +pub struct NamedResource { + pub id: Uuid, + pub name: String, +} + +#[derive(Debug, Serialize)] +pub struct EscalationUse { + pub policy_id: Uuid, + pub policy_name: String, + pub step_number: usize, + pub delay_minutes: u16, +} + +#[derive(Debug, Serialize)] +pub struct ScheduleRemovalPlan { + pub schedule: NamedResource, + pub escalation_steps: Vec, + pub coverage_fallbacks: Vec, + pub policies_emptied: Vec, + pub pending_requests: Vec, + pub active_replacements: Vec, +} + +pub fn schedule_removal_plan( + doc: &WorkspaceDocument, + id: Uuid, + now: DateTime, +) -> Result { + ensure( + !doc.generation.managed.iter().any(|m| m.id == id), + crate::i18n::text("err_this_schedule_is_generated_remove_it_through_var_6da973"), + )?; + let schedule = doc.schedule(id).ok_or(Error::NotFound)?; + let mut plan = ScheduleRemovalPlan { + schedule: NamedResource { + id, + name: schedule.name.clone(), + }, + escalation_steps: vec![], + coverage_fallbacks: vec![], + policies_emptied: vec![], + pending_requests: vec![], + active_replacements: vec![], + }; + for policy in &doc.escalations { + let mut removed = 0; + for (index, step) in policy.steps.iter().enumerate() { + if matches!(step.recipient, Recipient::Schedule { id: target } if target==id) { + removed += 1; + plan.escalation_steps.push(EscalationUse { + policy_id: policy.id, + policy_name: policy.name.clone(), + step_number: index + 1, + delay_minutes: step.delay_minutes, + }); + } + } + if removed > 0 && removed == policy.steps.len() { + plan.policies_emptied.push(NamedResource { + id: policy.id, + name: policy.name.clone(), + }); + } + } + for schedule in &doc.schedules { + if matches!(schedule.kind, ScheduleKind::Priority { fallback,.. } if fallback==id) { + plan.coverage_fallbacks.push(NamedResource { + id: schedule.id, + name: schedule.name.clone(), + }); + } + } + for request in &doc.requests { + if request.status == RequestStatus::Pending + && request.legs.iter().any(|leg| leg.schedule_id == id) + { + plan.pending_requests.push(request.id); + } + if request.status == RequestStatus::Applied + && request + .effective_legs() + .iter() + .any(|leg| leg.schedule_id == id && leg.end > now) + { + plan.active_replacements.push(request.id); + } + } + Ok(plan) +} + +pub fn remove_schedule( + doc: &WorkspaceDocument, + id: Uuid, + redirect: Option, + now: DateTime, +) -> Result { + let plan = schedule_removal_plan(doc, id, now)?; + ensure( + plan.active_replacements.is_empty(), + crate::i18n::text("err_this_schedule_has_current_or_future_approved_rep_49a098"), + )?; + if let Some(target) = redirect { + ensure( + target != id && doc.schedule(target).is_some(), + crate::i18n::text("err_choose_a_different_existing_schedule_for_the_ref_ea3955"), + )?; + } else { + ensure( + plan.coverage_fallbacks.is_empty(), + crate::i18n::message( + "err_choose_a_replacement_fallback_for_p0_6e17d4", + &[( + "p0", + (plan + .coverage_fallbacks + .iter() + .map(|s| s.name.as_str()) + .collect::>() + .join(", ")) + .to_string(), + )], + ), + )?; + ensure( + plan.policies_emptied.is_empty(), + crate::i18n::message( + "err_choose_a_replacement_schedule_or_edit_these_poli_f9b0c9", + &[( + "p0", + (plan + .policies_emptied + .iter() + .map(|s| s.name.as_str()) + .collect::>() + .join(", ")) + .to_string(), + )], + ), + )?; + } + let mut result = doc.clone(); + result.schedules.retain(|s| s.id != id); + for policy in &mut result.escalations { + policy.steps.retain_mut(|step| { + if matches!(step.recipient, Recipient::Schedule { id: target } if target==id) { + if let Some(target) = redirect { + step.recipient = Recipient::Schedule { id: target }; + } else { + return false; + } + } + true + }); + } + for schedule in &mut result.schedules { + if let ScheduleKind::Priority { fallback, .. } = &mut schedule.kind + && *fallback == id + { + *fallback = redirect.expect("A fallback redirect was validated above"); + } + } + for request in &mut result.requests { + if plan.pending_requests.contains(&request.id) { + request.status = RequestStatus::Cancelled; + } + } + crate::generation::validate_manual_edit(doc, &mut result)?; + Ok(result) +} + +pub fn reorder_routes(doc: &mut WorkspaceDocument, ids: &[Uuid]) -> Result<()> { + let expected: BTreeSet<_> = doc.routes.iter().map(|r| r.id).collect(); + let supplied: BTreeSet<_> = ids.iter().copied().collect(); + ensure( + ids.len() <= 500 + && ids.len() == doc.routes.len() + && supplied.len() == ids.len() + && supplied == expected, + crate::i18n::text("err_the_ordered_list_must_contain_every_current_rout_a7854b"), + )?; + let mut routes = Vec::with_capacity(ids.len()); + for (index, id) in ids.iter().enumerate() { + let mut route = doc + .routes + .iter() + .find(|r| r.id == *id) + .unwrap_or_else(|| panic!("{}", crate::i18n::text("err_validated_permutation_74a9c2"))) + .clone(); + route.order = index as u16; + routes.push(route); + } + doc.routes = routes; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Duration; + fn fixture() -> (WorkspaceDocument, Uuid, Uuid, Uuid) { + let mut doc = WorkspaceDocument::new("Platform".into()); + let primary = Uuid::new_v4(); + let secondary = Uuid::new_v4(); + let person = Uuid::new_v4(); + doc.schedules = [(primary, "Primary"), (secondary, "Secondary")] + .into_iter() + .map(|(id, name)| Schedule { + id, + name: name.into(), + handover: default_handover(), + kind: ScheduleKind::Rotation { + participants: vec![person], + anchor: "2026-09-21".parse().unwrap(), + shift_days: 7, + }, + }) + .collect(); + doc.escalations.push(Escalation { + id: Uuid::new_v4(), + name: "Payments escalation".into(), + steps: vec![ + EscalationStep { + delay_minutes: 0, + condition: EscalationCondition::IfNotAcked, + recipient: Recipient::Schedule { id: primary }, + }, + EscalationStep { + delay_minutes: 10, + condition: EscalationCondition::IfNotAcked, + recipient: Recipient::Schedule { id: secondary }, + }, + ], + repeat: Default::default(), + }); + (doc, primary, secondary, person) + } + #[test] + fn remove_backup_prunes_only_its_steps_and_cancels_pending_requests_as_a_whole() { + let (mut doc, primary, secondary, person) = fixture(); + let now = DateTime::parse_from_rfc3339("2027-01-01T00:00:00Z") + .unwrap() + .with_timezone(&Utc); + let mut request = ReplacementRequest { + effective_legs: None, + id: Uuid::new_v4(), + absence_id: None, + requested_by: person, + created_at: now, + reason: "Swap".into(), + legs: vec![ + ReplacementLeg { + schedule_id: secondary, + start: now + Duration::days(1), + end: now + Duration::days(2), + from_user: person, + to_user: Uuid::new_v4(), + }, + ReplacementLeg { + schedule_id: primary, + start: now + Duration::days(8), + end: now + Duration::days(9), + from_user: Uuid::new_v4(), + to_user: person, + }, + ], + required_approvers: Default::default(), + approvals: Default::default(), + status: RequestStatus::Pending, + applied_at: None, + forced_by: None, + force_reason: None, + }; + doc.requests.push(request.clone()); + let plan = schedule_removal_plan(&doc, secondary, now).unwrap(); + assert_eq!(plan.escalation_steps[0].step_number, 2); + assert_eq!(plan.pending_requests, vec![request.id]); + let removed = remove_schedule(&doc, secondary, None, now).unwrap(); + assert_eq!(doc.schedules.len(), 2, "Input remains unchanged"); + assert_eq!(removed.schedules.len(), 1); + assert_eq!(removed.escalations[0].steps.len(), 1); + assert_eq!(removed.requests[0].status, RequestStatus::Cancelled); + assert_eq!(removed.requests[0].legs.len(), 2); + request.status = RequestStatus::Applied; + doc.requests[0] = request; + assert!( + remove_schedule(&doc, secondary, Some(primary), now).is_err(), + "Redirecting references cannot rewrite an approved replacement" + ); + let past = remove_schedule(&doc, secondary, None, now + Duration::days(10)).unwrap(); + assert_eq!(past.requests[0].status, RequestStatus::Applied); + } + #[test] + fn last_policy_step_and_coverage_fallback_require_an_explicit_valid_redirect() { + let (mut doc, primary, secondary, person) = fixture(); + let now = Utc::now(); + doc.escalations[0].steps.remove(0); + assert!(remove_schedule(&doc, secondary, None, now).is_err()); + let redirect = remove_schedule(&doc, secondary, Some(primary), now).unwrap(); + assert!( + matches!(redirect.escalations[0].steps[0].recipient,Recipient::Schedule{id} if id==primary) + ); + doc.schedules[0].kind = ScheduleKind::Priority { + participants: vec![person, Uuid::new_v4()], + fallback: secondary, + }; + assert_eq!( + schedule_removal_plan(&doc, secondary, now) + .unwrap() + .coverage_fallbacks + .len(), + 1 + ); + let cycle = remove_schedule(&doc, secondary, Some(primary), now).unwrap(); + let people = match &doc.schedules[0].kind { + ScheduleKind::Priority { participants, .. } => participants.iter().copied().collect(), + _ => unreachable!(), + }; + assert!( + cycle.validate(&people).is_err(), + "The service must validate the rewritten graph before committing" + ); + assert!(remove_schedule(&doc, secondary, Some(Uuid::new_v4()), now).is_err()); + } + #[test] + fn order_requires_an_exact_permutation_and_preserves_rule_meaning() { + let mut doc = WorkspaceDocument::new("Team".into()); + let first = Uuid::new_v4(); + let second = Uuid::new_v4(); + doc.routes = vec![ + RoutingRule { + id: first, + name: "Specific".into(), + order: 10, + conditions: vec![RouteCondition { + field: "tags".into(), + operation: "contains".into(), + value: "prod".into(), + negate: false, + }], + window: None, + escalation_id: None, + }, + RoutingRule { + id: second, + name: "Fallback".into(), + order: 9999, + conditions: vec![], + window: None, + escalation_id: None, + }, + ]; + let before = serde_json::to_value(&doc.routes).unwrap(); + for ids in [vec![first], vec![first, first], vec![first, Uuid::new_v4()]] { + assert!(reorder_routes(&mut doc, &ids).is_err()); + assert_eq!(serde_json::to_value(&doc.routes).unwrap(), before); + } + reorder_routes(&mut doc, &[second, first]).unwrap(); + assert_eq!(doc.routes[0].id, second); + assert_eq!(doc.routes[0].order, 0); + assert_eq!(doc.routes[1].order, 1); + assert_eq!(doc.routes[1].conditions[0].value, "prod"); + } +} diff --git a/src/demo-holidays-2026.csv b/src/demo-holidays-2026.csv new file mode 100644 index 0000000..b34140e --- /dev/null +++ b/src/demo-holidays-2026.csv @@ -0,0 +1,34 @@ +country,date,name +CY,2026-01-01,New Year's Day +CY,2026-01-06,Epiphany +CY,2026-02-23,Green Monday +CY,2026-03-25,Greek Independence Day +CY,2026-04-01,Cyprus National Day +CY,2026-04-10,Orthodox Good Friday +CY,2026-04-13,Orthodox Easter Monday +CY,2026-05-01,Labour Day +CY,2026-06-01,Whit Monday +CY,2026-08-15,Assumption Day +CY,2026-10-01,Cyprus Independence Day +CY,2026-10-28,Ohi Day +CY,2026-12-24,Christmas Eve +CY,2026-12-25,Christmas Day +CY,2026-12-26,Boxing Day +JP,2026-01-01,New Year's Day +JP,2026-01-12,Coming of Age Day +JP,2026-02-11,National Foundation Day +JP,2026-02-23,Emperor's Birthday +JP,2026-03-20,Vernal Equinox Day +JP,2026-04-29,Showa Day +JP,2026-05-03,Constitution Memorial Day +JP,2026-05-04,Greenery Day +JP,2026-05-05,Children's Day +JP,2026-05-06,Substitute Holiday +JP,2026-07-20,Marine Day +JP,2026-08-11,Mountain Day +JP,2026-09-21,Respect for the Aged Day +JP,2026-09-22,Citizens' Holiday +JP,2026-09-23,Autumnal Equinox Day +JP,2026-10-12,Sports Day +JP,2026-11-03,Culture Day +JP,2026-11-23,Labour Thanksgiving Day diff --git a/src/demo.rs b/src/demo.rs new file mode 100644 index 0000000..86c9773 --- /dev/null +++ b/src/demo.rs @@ -0,0 +1,373 @@ +//! Small opt-in development examples, activated through the real generator service. +use crate::{ + domain::*, + error::Result, + generation::{Node, Rule, Variable}, + generation_service::{self, Draft}, + models::{UserRow, WorkspaceRow}, + service, + store::{self, Store}, +}; +use chrono::{Datelike, Duration, NaiveDate, Utc}; +use cot::db::{Model, query}; +use serde_json::{Value, json}; +use uuid::Uuid; + +fn node(value: Value) -> Node { + let (kind, entries) = match &value { + Value::Array(items) => ("list", items.iter().cloned().map(node).collect()), + Value::Object(items) => ( + "dictionary", + items + .iter() + .map(|(key, value)| { + let mut n = node(value.clone()); + n.key = key.clone(); + n + }) + .collect(), + ), + Value::String(s) if Uuid::parse_str(s).is_ok() || s.contains('@') => ("person", vec![]), + Value::String(_) => ("text", vec![]), + Value::Number(_) => ("number", vec![]), + _ => ("boolean", vec![]), + }; + Node { + id: Uuid::new_v4(), + kind: kind.into(), + key: String::new(), + value: if entries.is_empty() { + value + } else { + Value::Null + }, + entries, + } +} +fn variable(name: &str, value: Value) -> Variable { + Variable { + id: Uuid::new_v4(), + name: name.into(), + node: node(value), + } +} +fn fixed(value: impl serde::Serialize) -> Value { + json!({"kind":"literal","value":value}) +} +fn reference(id: Uuid) -> Value { + json!({"kind":"variable","variable":id}) +} +fn output(id: Uuid) -> Value { + json!({"kind":"output","output":id}) +} +fn item(id: Uuid, suffix: &str) -> Value { + json!({"kind":"item","iteration":id,"suffix":suffix}) +} +fn step(delay: u16, id: Uuid) -> Value { + json!({"delay":fixed(delay),"recipient":output(id),"condition":"if-not-acked"}) +} + +/// Projects keep fixed responsibility priority; one weekly rotation covers off hours. +pub fn project_matrix(monday: NaiveDate, alice: Uuid, bob: Uuid) -> (Vec, Vec) { + let projects = variable("projects", json!(["Payments", "Search", "Billing"])); + let responsible = variable( + "responsible", + json!({"Payments":[alice,bob,"sam@example.com"],"Search":[bob,alice],"Billing":[alice,"sam@example.com",bob]}), + ); + let on_call = variable("on_call", json!([alice, bob])); + let duty = Uuid::new_v4(); + let general = Uuid::new_v4(); + let iteration = Uuid::new_v4(); + let coverage = Uuid::new_v4(); + let policy = Uuid::new_v4(); + let rules = json!([ + {"id":Uuid::new_v4(),"name":"Weekly Time off","enabled":true,"blocks":[ + {"id":duty,"kind":"rotation","fields":{"name":fixed("Time off"),"participants":reference(on_call.id),"anchor":fixed(monday),"shift_days":fixed(7),"handover":fixed("11:00")}}, + {"id":general,"kind":"escalation","fields":{"name":fixed("Time off")},"steps":[step(0,duty)]} + ]}, + {"id":Uuid::new_v4(),"name":"Project owners during working hours","enabled":true,"iterations":[{"id":iteration,"name":"Project","source":reference(projects.id)}],"blocks":[ + {"id":coverage,"kind":"priority","fields":{"name":item(iteration," · owners"),"participants":{"kind":"lookup","variable":responsible.id,"key":item(iteration,"")},"fallback":output(duty),"handover":fixed("11:00")}}, + {"id":policy,"kind":"escalation","fields":{"name":item(iteration," · daytime")},"steps":[step(0,coverage)]}, + {"id":Uuid::new_v4(),"kind":"route","fields":{"name":item(iteration," · working hours"),"escalation":output(policy),"working":fixed(true)},"conditions":[{"field":"tags","operation":"contains","value":{"kind":"item","iteration":iteration,"prefix":"project:"}}]} + ]}, + {"id":Uuid::new_v4(),"name":"Off hours and unmatched alerts","enabled":true,"blocks":[ + {"id":Uuid::new_v4(),"kind":"route","fields":{"name":fixed("Otherwise → Time off"),"escalation":output(general),"working":fixed(false)}} + ]} + ]); + ( + vec![projects, responsible, on_call], + serde_json::from_value(rules).expect("typed demo rules"), + ) +} + +/// Main interactive demo: six services with distinct owners and five real workspace members. +pub fn engineering_matrix(monday: NaiveDate, people: [Uuid; 5]) -> (Vec, Vec) { + let [alice, bob, carol, dave, erin] = people; + let (mut variables, rules) = project_matrix(monday, alice, bob); + variables[0].node = node(json!([ + {"name":"Payments","responsible":[alice,bob,dave]}, + {"name":"Search","responsible":[bob,erin]}, + {"name":"Billing","responsible":[carol,alice,erin]}, + {"name":"Identity","responsible":[dave,carol]}, + {"name":"Data platform","responsible":[erin,dave,bob]}, + {"name":"Developer portal","responsible":[carol,erin]} + ])); + variables.remove(1); + variables[1].node = node(json!(people)); + let mut rules = serde_json::to_value(rules).expect("serializable demo rules"); + let iteration = serde_json::from_value(rules[1]["iterations"][0]["id"].clone()) + .expect("demo iteration UUID"); + for block in rules[1]["blocks"].as_array_mut().expect("demo blocks") { + block["fields"]["name"]["path"] = json!(["name"]); + } + rules[1]["blocks"][0]["fields"]["participants"] = field(iteration, &["responsible"]); + rules[1]["blocks"][2]["conditions"][0]["value"]["path"] = json!(["name"]); + let rules = serde_json::from_value(rules).expect("nested project demo rules"); + (variables, rules) +} + +/// A separate tenant demonstrates daily coverage and a delayed escalation step. +pub fn support_queues(anchor: NaiveDate, alice: Uuid, carol: Uuid) -> (Vec, Vec) { + let queues = variable("queues", json!(["Customers", "Partners"])); + let agents = variable("agents", json!([carol, alice])); + let lead = variable("lead", json!(alice)); + let backup = Uuid::new_v4(); + let iteration = Uuid::new_v4(); + let rotation = Uuid::new_v4(); + let policy = Uuid::new_v4(); + let rules = json!([ + {"id":Uuid::new_v4(),"name":"Support lead","enabled":true,"blocks":[{"id":backup,"kind":"rotation","fields":{"name":fixed("Support lead"),"participants":{"kind":"list","items":[reference(lead.id)]},"anchor":fixed(anchor),"shift_days":fixed(7),"handover":fixed("09:00")}}]}, + {"id":Uuid::new_v4(),"name":"Daily queue coverage","enabled":true,"iterations":[{"id":iteration,"name":"Queue","source":reference(queues.id)}],"blocks":[ + {"id":rotation,"kind":"rotation","fields":{"name":item(iteration," · daily"),"participants":reference(agents.id),"anchor":fixed(anchor),"shift_days":fixed(1),"handover":fixed("09:00")}}, + {"id":policy,"kind":"escalation","fields":{"name":item(iteration," · escalation")},"steps":[step(0,rotation),step(15,backup)]}, + {"id":Uuid::new_v4(),"kind":"route","fields":{"name":item(iteration," · 24/7"),"escalation":output(policy),"working":fixed(false)},"conditions":[{"field":"tags","operation":"contains","value":{"kind":"item","iteration":iteration,"prefix":"queue:"}}]} + ]} + ]); + ( + vec![queues, agents, lead], + serde_json::from_value(rules).expect("typed demo rules"), + ) +} + +fn field(iteration: Uuid, path: &[&str]) -> Value { + json!({"kind":"item","iteration":iteration,"path":path}) +} +fn lookup(variable: Uuid, key: Value, path: &[&str]) -> Value { + json!({"kind":"lookup","variable":variable,"key":key,"path":path}) +} + +/// Dependent iterations: each regional portfolio supplies its own services and rota settings. +pub fn regional_platform(monday: NaiveDate, alice: Uuid, bob: Uuid) -> (Vec, Vec) { + let regions = variable( + "regions", + json!({ + "Europe":{"handover":"11:00","shift_days":7,"responders":[alice,bob],"services":[{"name":"EU · Gateway","tag":"eu-gateway","enabled":true},{"name":"EU · Storage","tag":"eu-storage","enabled":true}]}, + "Pacific":{"handover":"05:00","shift_days":2,"responders":[bob,alice],"services":[{"name":"Pacific · Gateway","tag":"pacific-gateway","enabled":true},{"name":"Pacific · Sandbox","tag":"pacific-sandbox","enabled":false}]} + }), + ); + let commander = variable("incident_commander", json!(alice)); + let lead = Uuid::new_v4(); + let region = Uuid::new_v4(); + let service = Uuid::new_v4(); + let rotation = Uuid::new_v4(); + let policy = Uuid::new_v4(); + let rules = json!([ + {"id":Uuid::new_v4(),"name":"Shared incident commander","enabled":true,"blocks":[{"id":lead,"kind":"rotation","fields":{"name":fixed("Incident commander"),"participants":{"kind":"list","items":[reference(commander.id)]},"anchor":fixed(monday),"shift_days":fixed(7),"handover":fixed("11:00")}}]}, + {"id":Uuid::new_v4(),"name":"Region → enabled services","enabled":true,"iterations":[{"id":region,"name":"Region","source":reference(regions.id)},{"id":service,"name":"Service","source":field(region,&["services"])}],"when":{"left":field(service,&["enabled"]),"operation":"equals","right":fixed(true)},"blocks":[ + {"id":rotation,"kind":"rotation","fields":{"name":field(service,&["name"]),"participants":field(region,&["responders"]),"anchor":fixed(monday),"shift_days":field(region,&["shift_days"]),"handover":field(region,&["handover"])}}, + {"id":policy,"kind":"escalation","fields":{"name":field(service,&["name"])},"steps":[step(0,rotation),step(10,lead)]}, + {"id":Uuid::new_v4(),"kind":"route","fields":{"name":field(service,&["name"]),"escalation":output(policy),"working":fixed(false)},"conditions":[{"field":"tags","operation":"contains","value":field(service,&["tag"])}]} + ]} + ]); + ( + vec![regions, commander], + serde_json::from_value(rules).expect("regional demo rules"), + ) +} + +/// Per-product tier lookup controls coverage windows, escalation delay and repetition. +pub fn service_tiers(monday: NaiveDate, alice: Uuid, carol: Uuid) -> (Vec, Vec) { + let products = variable( + "products", + json!({ + "Checkout":{"tier":"critical","owners":[carol,alice],"enabled":true}, + "Analytics":{"tier":"standard","owners":[alice,carol],"enabled":true}, + "Merchant portal":{"tier":"standard","owners":[carol,"lee@example.com",alice],"enabled":true}, + "Legacy export":{"tier":"retired","owners":[alice],"enabled":false} + }), + ); + let tiers = variable( + "service_tiers", + json!({"critical":{"working_only":false,"delay":5,"repeats":2,"wait":10},"standard":{"working_only":true,"delay":30,"repeats":0,"wait":0}}), + ); + let duty = variable("shared_on_call", json!([alice, carol])); + let rotation = Uuid::new_v4(); + let general = Uuid::new_v4(); + let product = Uuid::new_v4(); + let owners = Uuid::new_v4(); + let policy = Uuid::new_v4(); + let tier = |path: &str| lookup(tiers.id, field(product, &["tier"]), &[path]); + let name = || json!({"kind":"key","iteration":product}); + let rules = json!([ + {"id":Uuid::new_v4(),"name":"Shared weekly safety net","enabled":true,"blocks":[ + {"id":rotation,"kind":"rotation","fields":{"name":fixed("Shared time off"),"participants":reference(duty.id),"anchor":fixed(monday),"shift_days":fixed(7),"handover":fixed("11:00")}}, + {"id":general,"kind":"escalation","fields":{"name":fixed("Shared time off")},"steps":[step(0,rotation)]} + ]}, + {"id":Uuid::new_v4(),"name":"Product → service tier","enabled":true,"iterations":[{"id":product,"name":"Product","source":reference(products.id)}],"when":{"left":field(product,&["enabled"]),"operation":"equals","right":fixed(true)},"blocks":[ + {"id":owners,"kind":"priority","fields":{"name":name(),"participants":field(product,&["owners"]),"fallback":output(rotation),"handover":fixed("11:00")}}, + {"id":policy,"kind":"escalation","fields":{"name":name(),"repeat_count":tier("repeats"),"repeat_wait":tier("wait")},"steps":[step(0,owners),{"delay":tier("delay"),"recipient":output(rotation),"condition":"if-not-acked"}]}, + {"id":Uuid::new_v4(),"kind":"route","fields":{"name":name(),"escalation":output(policy),"working":tier("working_only")},"conditions":[{"field":"tags","operation":"contains","value":name()}]} + ]}, + {"id":Uuid::new_v4(),"name":"Unmatched and off-hours safety net","enabled":true,"blocks":[{"id":Uuid::new_v4(),"kind":"route","fields":{"name":fixed("Otherwise → shared time off"),"escalation":output(general),"working":fixed(false)}}]} + ]); + ( + vec![products, tiers, duty], + serde_json::from_value(rules).expect("service tier demo rules"), + ) +} + +pub async fn seed(db: &Store) -> Result<()> { + let mut users = vec![]; + for (username, display) in [ + ("alice", "Alice Morgan"), + ("bob", "Bob Chen"), + ("carol", "Carol Davis"), + ("dave", "Dave Wilson"), + ("erin", "Erin Patel"), + ] { + let user = match query!(UserRow,$username==username).get(&db.db).await? { + Some(row) => store::user(row)?, + None => { + service::register( + db, + username.into(), + display.into(), + "meguri-demo-only".into(), + ) + .await? + } + }; + users.push(user); + } + for user in &users { + db.db + .raw_with( + "INSERT INTO meguri__demo_pins(kind,id) VALUES('user',$1) ON CONFLICT DO NOTHING", + &[&user.id.to_string()], + ) + .await?; + } + let alice = users[0].id; + let today = Utc::now().date_naive(); + let monday = today - Duration::days(i64::from(today.weekday().num_days_from_monday())); + for (index, workspace_name, timezone, start, end) in [ + ( + 1, + "Acme Engineering", + "Asia/Nicosia", + "11:00:00", + "19:00:00", + ), + ( + 2, + "Customer Support", + "Europe/London", + "09:00:00", + "17:00:00", + ), + ( + 3, + "Regional Platform", + "Asia/Nicosia", + "09:00:00", + "18:00:00", + ), + ( + 4, + "Commerce Operations", + "Europe/London", + "09:00:00", + "17:00:00", + ), + ] { + let mut existing = None; + for row in WorkspaceRow::objects().all(&db.db).await? { + if row.demo_deleted_at.is_some() { + continue; + } + let candidate = store::record(row)?; + if candidate.document.name == workspace_name + && db.owner(candidate.id, alice).await.is_ok() + { + existing = Some(candidate); + break; + } + } + if existing.is_some() { + continue; + } + let mut workspace = service::create_workspace(db, alice, workspace_name.into()).await?; + service::set_working_interval( + db, + alice, + workspace.id, + workspace.revision, + WorkingWindow { + timezone: timezone.into(), + weekdays: vec![1, 2, 3, 4, 5], + start: start.parse().unwrap(), + end: end.parse().unwrap(), + }, + ) + .await?; + workspace = db.workspace(workspace.id).await?; + let member_indices = if index == 1 { + vec![1, 2, 3, 4] + } else { + vec![if index % 2 == 1 { 1 } else { 2 }] + }; + for member_index in member_indices { + workspace = service::set_member( + db, + alice, + workspace.id, + workspace.revision, + users[member_index].username.clone(), + Permission::member(), + ) + .await?; + } + // Fixed, documented 2026 examples; use the normal scoped CSV import path. + let csv = include_str!("demo-holidays-2026.csv"); + if !crate::holidays::compare(csv, &workspace.document.holidays)? + .additions + .is_empty() + { + workspace = + service::import_holidays(db, alice, workspace.id, workspace.revision, csv).await?; + } + let (variables, rules) = match index { + 1 => engineering_matrix(monday, std::array::from_fn(|index| users[index].id)), + 2 => support_queues(monday, alice, users[2].id), + 3 => regional_platform(monday, alice, users[1].id), + _ => service_tiers(monday, alice, users[2].id), + }; + let mut draft = Draft { + revision: workspace.revision, + variables, + rules, + route_position: 0, + review_id: None, + }; + let report = generation_service::preview(db, alice, workspace.id, draft.clone()).await?; + draft.review_id = report["review_id"].as_str().map(str::to_owned); + generation_service::apply(db, alice, workspace.id, draft).await?; + db.db + .raw_with( + "INSERT INTO meguri__demo_pins(kind,id) VALUES('workspace',$1) ON CONFLICT DO NOTHING", + &[&workspace.id.to_string()], + ) + .await?; + } + Ok(()) +} diff --git a/src/demo_runtime.rs b/src/demo_runtime.rs new file mode 100644 index 0000000..63ff5da --- /dev/null +++ b/src/demo_runtime.rs @@ -0,0 +1,259 @@ +//! Demo-only retention and a conservative database provenance boundary before migrations. +use crate::{ + error::{Error, Result, ensure}, + store::Store, +}; +use chrono::{DateTime, Utc}; +use cot::db::{Database, DatabaseError}; +use serde::Serialize; +use std::sync::OnceLock; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize)] +pub struct Retention { + pub workspaces: i64, + pub accounts: i64, + pub requests: i64, + pub events: i64, + pub purge_grace: i64, +} +impl Default for Retention { + fn default() -> Self { + Self { + workspaces: 86400, + accounts: 172800, + requests: 7200, + events: 21600, + purge_grace: 600, + } + } +} +impl Retention { + pub fn from_env() -> Result { + fn read(name: &str, default: i64) -> Result { + let value = std::env::var(name).map_or(Ok(default), |v| { + v.parse().map_err(|_| { + Error::Validation(format!("{name} must be an integer number of seconds")) + }) + })?; + ensure( + (60..=2592000).contains(&value), + format!("{name} must be 60–2592000 seconds"), + )?; + Ok(value) + } + let d = Self::default(); + Ok(Self { + workspaces: read("MEGURI_DEMO_WORKSPACE_TTL_SECONDS", d.workspaces)?, + accounts: read("MEGURI_DEMO_ACCOUNT_TTL_SECONDS", d.accounts)?, + requests: read("MEGURI_DEMO_REQUEST_TTL_SECONDS", d.requests)?, + events: read("MEGURI_DEMO_EVENT_TTL_SECONDS", d.events)?, + purge_grace: read("MEGURI_DEMO_PURGE_GRACE_SECONDS", d.purge_grace)?, + }) + } +} +#[derive(Debug, Clone, Serialize)] +pub struct Notice { + pub isolated: bool, + pub retention: Retention, +} +static NOTICE: OnceLock = OnceLock::new(); +pub fn notice() -> Option<&'static Notice> { + NOTICE.get() +} +pub fn active() -> bool { + NOTICE.get().is_some() +} +pub fn install(notice: Notice) { + let _ = NOTICE.set(notice); +} + +fn identifier(name: &str) -> String { + format!("\"{}\"", name.replace('"', "\"\"")) +} +fn code(error: &DatabaseError) -> Option { + if let DatabaseError::DatabaseEngineError(e) = error { + e.as_database_error() + .and_then(|e| e.code().map(|c| c.into_owned())) + } else { + None + } +} +pub fn application_url(url: &str, demo: bool) -> Result { + let mut parsed = + reqwest::Url::parse(url).map_err(|_| Error::Validation("Invalid PostgreSQL URL".into()))?; + ensure(parsed.scheme() == "postgresql", "Use a postgresql:// URL")?; + let pairs: Vec<_> = parsed + .query_pairs() + .filter(|(k, _)| k != "application_name") + .map(|(k, v)| (k.into_owned(), v.into_owned())) + .collect(); + parsed.set_query(None); + parsed.query_pairs_mut().extend_pairs(pairs).append_pair( + "application_name", + &format!( + "meguri-{}-{}", + if demo { "demo" } else { "normal" }, + Uuid::new_v4().simple() + ), + ); + Ok(parsed.to_string()) +} +const CREATE_GUARD: &str = "CREATE TABLE IF NOT EXISTS public.meguri_runtime_guard(id boolean PRIMARY KEY CHECK(id),signature text NOT NULL,last_mode text NOT NULL CHECK(last_mode IN('normal','demo')),started_at timestamptz NOT NULL)"; +const PROOF: &str = "DO $$ BEGIN + IF to_regclass('public.meguri_runtime_guard') IS NULL THEN RAISE EXCEPTION 'MEGURI_DEMO_PROTECTED'; END IF; + IF NOT EXISTS(SELECT 1 FROM public.meguri_runtime_guard WHERE id AND signature='meguri.runtime.v1' AND last_mode='demo') THEN RAISE EXCEPTION 'MEGURI_DEMO_PROTECTED'; END IF; + END $$;"; +/// A failed proof never authorizes a write. No migration or marker update happens here. +pub async fn is_demo_database(db: &Database) -> bool { + db.raw(PROOF).await.is_ok() +} + +/// Claim only a database with no user relations. A pre-existing application DB is never claimed. +pub async fn claim_empty(db: &Database) -> Result<()> { + db.raw(&format!("DO $$ BEGIN + PERFORM pg_advisory_xact_lock(726482039); + IF EXISTS(SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname NOT LIKE 'pg_%' AND n.nspname<>'information_schema' AND c.relkind IN('r','p','v','m','f','S')) THEN RAISE EXCEPTION 'MEGURI_DEMO_PROTECTED'; END IF; + {CREATE_GUARD}; + INSERT INTO public.meguri_runtime_guard VALUES(true,'meguri.runtime.v1','demo',clock_timestamp()); + END $$;")).await?; + Ok(()) +} +pub async fn mark_normal(db: &Database) -> Result<()> { + db.raw(&format!("DO $$ BEGIN PERFORM pg_advisory_xact_lock(726482039); {CREATE_GUARD}; + INSERT INTO public.meguri_runtime_guard VALUES(true,'meguri.runtime.v1','normal',clock_timestamp()) ON CONFLICT(id) DO UPDATE SET last_mode='normal',started_at=clock_timestamp(); END $$;")).await?; + Ok(()) +} + +/// Uses the configured DB only with positive demo provenance. Otherwise creates/reuses a +/// separately named DB on the same server. Protected candidates and migration tables survive. +pub async fn select_database(configured: &str, alternate: Option<&str>) -> Result<(String, bool)> { + let configured = application_url(configured, true)?; + let source = Database::new(&configured).await?; + if is_demo_database(&source).await { + return Ok((configured, false)); + } + let base = reqwest::Url::parse(alternate.unwrap_or(&configured)) + .map_err(|_| Error::Validation("Invalid MEGURI_DEMO_DATABASE_URL".into()))?; + let fingerprint = crate::generation::stable_id(&serde_json::json!([ + base.host_str(), + base.port(), + base.path() + ])); + for attempt in 0..8 { + let mut target = base.clone(); + let name = if alternate.is_some() && attempt == 0 { + base.path().trim_start_matches('/').to_owned() + } else { + format!( + "meguri_demo_{}_{}", + &fingerprint.simple().to_string()[..12], + attempt + ) + }; + ensure(!name.is_empty(), "A demo database name is required")?; + target.set_path(&format!("/{name}")); + let target = application_url(target.as_str(), true)?; + let database = match Database::new(&target).await { + Ok(db) => db, + Err(e) if code(&e).as_deref() == Some("3D000") => { + // Database creation is the only cluster-level write; no DROP DATABASE/schema/CASCADE. + let mut admin = base.clone(); + admin.set_path("/postgres"); + let admin = Database::new(application_url(admin.as_str(), true)?).await?; + if let Err(e) = admin + .raw(&format!("CREATE DATABASE {}", identifier(&name))) + .await + && code(&e).as_deref() != Some("42P04") + { + return Err(Error::Validation("Cannot create an isolated demo database. Ask the administrator to provision an empty database and set MEGURI_DEMO_DATABASE_URL. The configured database was not changed.".into())); + } + Database::new(&target).await? + } + Err(e) => return Err(e.into()), + }; + if is_demo_database(&database).await { + return Ok((target, true)); + } + if claim_empty(&database).await.is_ok() { + return Ok((target, true)); + } + } + Err(Error::Validation("No safe demo database is available. Protected databases were not modified; configure a separate empty MEGURI_DEMO_DATABASE_URL.".into())) +} + +/// Truncate only the enumerated application state, after rechecking provenance under lock. +/// No CASCADE: an unexpected external dependency aborts the entire reset. +pub async fn reset(db: &Database) -> Result<()> { + db.raw("DO $$ DECLARE tables text; BEGIN + PERFORM pg_advisory_xact_lock(726482039); + IF to_regclass('public.meguri_runtime_guard') IS NULL THEN RAISE EXCEPTION 'MEGURI_DEMO_PROTECTED'; END IF; + LOCK TABLE public.meguri_runtime_guard IN EXCLUSIVE MODE; + IF NOT EXISTS(SELECT 1 FROM public.meguri_runtime_guard WHERE id AND signature='meguri.runtime.v1' AND last_mode='demo') THEN RAISE EXCEPTION 'MEGURI_DEMO_PROTECTED'; END IF; + IF EXISTS(SELECT 1 FROM pg_stat_activity WHERE datname=current_database() AND backend_type='client backend' AND pid<>pg_backend_pid() AND application_name<>current_setting('application_name')) THEN RAISE EXCEPTION 'Demo database has another active client; stop the other process before restarting'; END IF; + SELECT string_agg(format('public.%I',name),',') INTO tables FROM unnest(ARRAY['meguri__workspaces','meguri__workspace_members','meguri__workspace_history','meguri__sessions','meguri__external_identities','meguri__team_members','meguri__tenant_members','meguri__team_history','meguri__audit_events','meguri__sync_jobs','meguri__provider_objects','meguri__teams','meguri__tenants','meguri__users','meguri__demo_pins']) name WHERE to_regclass('public.'||name) IS NOT NULL; + IF tables IS NOT NULL THEN EXECUTE 'TRUNCATE '||tables||' RESTART IDENTITY'; END IF; + UPDATE public.meguri_runtime_guard SET started_at=clock_timestamp() WHERE id; + END $$;").await?; + Ok(()) +} + +pub async fn cleanup(db: &Store, now: DateTime, ttl: &Retention) -> Result<()> { + // The server checks provenance on every sweep too. A changed mode stops cleanup. + ensure( + is_demo_database(&db.db).await, + "Cleanup refused: database is not marked as demo", + )?; + db.db + .raw_with( + CLEANUP, + &[&now, &ttl.workspaces, &ttl.accounts, &ttl.requests], + ) + .await?; + db.db + .raw_with(PURGE, &[&now, &ttl.purge_grace, &ttl.events]) + .await?; + Ok(()) +} +pub async fn run(db: Store, ttl: Retention) { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(60)); + loop { + interval.tick().await; + if let Err(error) = async { + cleanup(&db, Utc::now(), &ttl).await?; + crate::demo::seed(&db).await + } + .await + { + tracing::error!(%error,"demo maintenance failed"); + } + } +} +const CLEANUP: &str = r#" +WITH guard AS MATERIALIZED (SELECT id FROM public.meguri_runtime_guard WHERE id AND signature='meguri.runtime.v1' AND last_mode='demo' FOR UPDATE), +expired AS (UPDATE meguri__workspaces SET demo_deleted_at=$1,revision=revision+1 WHERE EXISTS(SELECT 1 FROM guard) AND demo_deleted_at IS NULL AND created_at<=$1-make_interval(secs=>$2::bigint::double precision) RETURNING id), +expired_users AS (UPDATE meguri__users SET demo_deleted_at=$1 WHERE EXISTS(SELECT 1 FROM guard) AND demo_deleted_at IS NULL AND created_at<=$1-make_interval(secs=>$3::bigint::double precision) AND NOT EXISTS(SELECT 1 FROM meguri__demo_pins p WHERE p.kind='user' AND p.id=meguri__users.id) RETURNING id), +sessions AS (DELETE FROM meguri__sessions WHERE EXISTS(SELECT 1 FROM guard) AND (user_id IN(SELECT id FROM expired_users) OR expires_at<=$1)), +jobs AS (DELETE FROM meguri__sync_jobs WHERE workspace_id IN(SELECT id FROM expired)), +requests AS ( + UPDATE meguri__workspaces SET document=jsonb_set(document::jsonb,'{requests}',COALESCE((SELECT jsonb_agg(r) FROM jsonb_array_elements(document::jsonb->'requests') r WHERE (r->>'created_at')::timestamptz>$1-make_interval(secs=>$4::bigint::double precision)),'[]'::jsonb))::text,revision=revision+1 + WHERE EXISTS(SELECT 1 FROM guard) AND demo_deleted_at IS NULL AND id NOT IN(SELECT id FROM expired) AND EXISTS(SELECT 1 FROM jsonb_array_elements(document::jsonb->'requests') r WHERE (r->>'created_at')::timestamptz<=$1-make_interval(secs=>$4::bigint::double precision)) RETURNING * +), history AS (INSERT INTO meguri__workspace_history(id,workspace_id,revision,document,effective_at) SELECT id||':'||revision::text,id,revision,document,$1 FROM requests), +request_jobs AS (INSERT INTO meguri__sync_jobs(workspace_id,requested_revision,state,attempts,next_attempt) SELECT id,revision,'pending',0,$1 FROM requests ON CONFLICT(workspace_id) DO UPDATE SET requested_revision=EXCLUDED.requested_revision,state='pending',next_attempt=$1) +INSERT INTO meguri__audit_events(workspace_id,action,details,created_at) SELECT id,'demo.requests_expired','{}',$1 FROM requests; +"#; +const PURGE: &str = r#" +WITH guard AS MATERIALIZED (SELECT id FROM public.meguri_runtime_guard WHERE id AND signature='meguri.runtime.v1' AND last_mode='demo' FOR UPDATE), +workspaces AS MATERIALIZED (SELECT id FROM meguri__workspaces WHERE EXISTS(SELECT 1 FROM guard) AND demo_deleted_at<=$1-make_interval(secs=>$2::bigint::double precision)), +members AS (DELETE FROM meguri__workspace_members WHERE workspace_id IN(SELECT id FROM workspaces)), +history AS (DELETE FROM meguri__workspace_history h WHERE workspace_id IN(SELECT id FROM workspaces) OR (EXISTS(SELECT 1 FROM guard) AND effective_at<$1-make_interval(secs=>$3::bigint::double precision) AND EXISTS(SELECT 1 FROM meguri__workspace_history newer WHERE newer.workspace_id=h.workspace_id AND newer.revision>h.revision AND newer.effective_at<$1-make_interval(secs=>$3::bigint::double precision)))), +audit AS (DELETE FROM meguri__audit_events WHERE workspace_id IN(SELECT id FROM workspaces) OR (EXISTS(SELECT 1 FROM guard) AND created_at<$1-make_interval(secs=>$3::bigint::double precision))), +jobs AS (DELETE FROM meguri__sync_jobs WHERE workspace_id IN(SELECT id FROM workspaces)), +objects AS (DELETE FROM meguri__provider_objects WHERE workspace_id IN(SELECT id FROM workspaces)), +pins AS (DELETE FROM meguri__demo_pins WHERE kind='workspace' AND id IN(SELECT id FROM workspaces)), +removed AS (DELETE FROM meguri__workspaces WHERE id IN(SELECT id FROM workspaces)), +users AS MATERIALIZED (SELECT id FROM meguri__users u WHERE EXISTS(SELECT 1 FROM guard) AND demo_deleted_at<=$1-make_interval(secs=>$2::bigint::double precision) AND NOT EXISTS(SELECT 1 FROM meguri__workspace_members m WHERE m.user_id=u.id AND m.workspace_id NOT IN(SELECT id FROM workspaces))), +sessions AS (DELETE FROM meguri__sessions WHERE user_id IN(SELECT id FROM users)), +identities AS (DELETE FROM meguri__external_identities WHERE user_id IN(SELECT id FROM users)) +DELETE FROM meguri__users WHERE id IN(SELECT id FROM users); +"#; diff --git a/src/domain.rs b/src/domain.rs new file mode 100644 index 0000000..0d151bd --- /dev/null +++ b/src/domain.rs @@ -0,0 +1,706 @@ +use crate::error::{Result, ensure}; +use chrono::{DateTime, NaiveDate, NaiveTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; +use uuid::Uuid; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "snake_case")] +pub enum Permission { + View, + EditSchedules, + RequestOverrides, + ForceOverrides, + ManageMembers, + ManageIntegration, + ViewAudit, +} + +impl Permission { + pub fn all() -> Vec { + vec![ + Self::View, + Self::EditSchedules, + Self::RequestOverrides, + Self::ForceOverrides, + Self::ManageMembers, + Self::ManageIntegration, + Self::ViewAudit, + ] + } + pub fn member() -> Vec { + vec![Self::View, Self::RequestOverrides] + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct User { + #[serde(default)] + pub language: Option, + pub id: Uuid, + pub username: String, + pub display_name: String, + pub country: String, + /// None follows the browser timezone; this never changes scheduling rules. + pub timezone: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Member { + pub user: User, + pub permissions: Vec, +} + +/// Scheduling metadata, not an authentication principal. Registered users retain their ID. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Person { + pub id: Uuid, + pub display_name: String, + pub email: Option, + pub country: Option, + pub user_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Holiday { + pub country: String, + pub date: NaiveDate, + pub name: String, +} + +pub fn default_handover() -> NaiveTime { + NaiveTime::from_hms_opt(11, 0, 0).unwrap() +} + +pub fn valid_country(country: &str) -> bool { + // ISO 3166-1 alpha-2. No network dependency for validating scheduling state. + const CODES: &str = "AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY BZ CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ DE DJ DK DM DO DZ EC EE EG EH ER ES ET FI FJ FK FM FO FR GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY HK HM HN HR HT HU ID IE IL IM IN IO IQ IR IS IT JE JM JO JP KE KG KH KI KM KN KP KR KW KY KZ LA LB LC LI LK LR LS LT LU LV LY MA MC MD ME MF MG MH MK ML MM MN MO MP MQ MR MS MT MU MV MW MX MY MZ NA NC NE NF NG NI NL NO NP NR NU NZ OM PA PE PF PG PH PK PL PM PN PR PS PT PW PY QA RE RO RS RU RW SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ TC TD TF TG TH TJ TK TL TM TN TO TR TT TV TW TZ UA UG UM US UY UZ VA VC VE VG VI VN VU WF WS YE YT ZA ZM ZW"; + CODES.split_whitespace().any(|code| code == country) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkspaceDocument { + pub working_interval: WorkingWindow, + pub overwrite_drift: bool, + pub name: String, + pub timezone: String, + pub horizon_days: u16, + #[serde(default)] + pub people: Vec, + #[serde(default)] + pub schedules: Vec, + #[serde(default)] + pub holidays: Vec, + #[serde(default)] + pub absences: Vec, + #[serde(default)] + pub requests: Vec, + #[serde(default)] + pub escalations: Vec, + #[serde(default)] + pub routes: Vec, + #[serde(default)] + pub integration: Integration, + #[serde(default)] + pub generation: crate::generation::Generation, +} + +impl WorkspaceDocument { + pub fn new(name: String) -> Self { + Self { + name, + working_interval: WorkingWindow::default(), + overwrite_drift: false, + timezone: "Europe/Bucharest".into(), + horizon_days: 90, + people: vec![], + schedules: vec![], + holidays: vec![], + absences: vec![], + requests: vec![], + escalations: vec![], + routes: vec![], + integration: Integration::Mock, + generation: Default::default(), + } + } + pub fn is_absent(&self, user: Uuid, day: NaiveDate) -> bool { + self.absences + .iter() + .any(|a| a.user_id == user && a.start <= day && day <= a.end) + } + pub fn schedule(&self, id: Uuid) -> Option<&Schedule> { + self.schedules.iter().find(|s| s.id == id) + } + + pub fn person(&self, id: Uuid) -> Option<&Person> { + self.people.iter().find(|p| p.id == id) + } + + pub fn person_ids(&self, members: &BTreeSet) -> BTreeSet { + members + .iter() + .copied() + .chain(self.people.iter().map(|p| p.id)) + .collect() + } + + pub fn country_holiday(&self, id: Uuid, day: NaiveDate) -> bool { + let country = self + .person(id) + .and_then(|p| p.country.as_deref()) + .unwrap_or("CY"); + self.holidays + .iter() + .any(|h| h.date == day && h.country == country) + } + + pub fn validate(&self, members: &BTreeSet) -> Result<()> { + ensure( + self.people.len() <= 1000, + "A team supports at most 1,000 people", + )?; + let mut person_ids = BTreeSet::new(); + let mut emails = BTreeSet::new(); + for person in &self.people { + ensure( + person_ids.insert(person.id), + crate::i18n::text("err_person_ids_must_be_unique_2d45f9"), + )?; + validate_name(&person.display_name)?; + ensure( + person + .user_id + .is_none_or(|id| id == person.id && members.contains(&id)), + crate::i18n::text("err_choose_a_user_who_has_access_to_this_workspace_41cf15"), + )?; + ensure( + person.country.as_deref().is_none_or(valid_country), + crate::i18n::text("err_choose_a_valid_country_code_e79c72"), + )?; + if let Some(email) = &person.email { + ensure( + email.len() <= 254 && email_address::EmailAddress::is_valid(email), + crate::i18n::text("err_enter_a_valid_email_address_1577ea"), + )?; + ensure( + email == &email.trim().to_lowercase(), + crate::i18n::text("err_email_addresses_must_be_normalized_c8f704"), + )?; + ensure( + emails.insert(email), + crate::i18n::text("err_this_email_is_already_in_the_team_directory_2692f4"), + )?; + } + ensure( + person.user_id.is_some() || person.email.is_some(), + "A contact needs an email address", + )?; + } + let members = self.person_ids(members); + validate_name(&self.name)?; + ensure( + self.timezone.parse::().is_ok(), + crate::i18n::text("err_use_a_valid_iana_timezone_b90408"), + )?; + ensure( + (7..=366).contains(&self.horizon_days), + crate::i18n::text("err_publication_horizon_must_be_7_366_days_95023e"), + )?; + ensure( + self.schedules.len() <= 200 + && self.escalations.len() <= 200 + && self.routes.len() <= 500, + "A team supports at most 200 schedules, 200 policies, and 500 routing rules", + )?; + ensure( + self.absences.len() <= 2000 + && self.holidays.len() <= 2000 + && self.requests.len() <= 5000, + crate::i18n::text("err_team_history_is_too_large_archive_old_data_befor_fc0b97"), + )?; + let ids: BTreeSet<_> = self.schedules.iter().map(|s| s.id).collect(); + ensure( + ids.len() == self.schedules.len(), + crate::i18n::text("err_schedule_ids_must_be_unique_4f12c8"), + )?; + for s in &self.schedules { + validate_name(&s.name)?; + match &s.kind { + ScheduleKind::Rotation { + participants, + anchor, + shift_days, + } => { + ensure( + !participants.is_empty() && participants.len() <= 100, + "A rotation needs 1–100 participants", + )?; + ensure( + participants.iter().all(|id| members.contains(id)), + crate::i18n::text( + "err_choose_a_workspace_user_or_email_contact_for_eve_879191", + ), + )?; + ensure( + participants.iter().collect::>().len() == participants.len(), + crate::i18n::text("err_a_rotation_cannot_repeat_a_participant_adc324"), + )?; + ensure( + (1..=366).contains(shift_days), + crate::i18n::text("err_shift_length_must_be_1_366_calendar_days_b772e3"), + )?; + ensure( + (2000..=2100).contains(&chrono::Datelike::year(anchor)), + crate::i18n::text("err_anchor_year_must_be_2000_2100_5009d7"), + )?; + } + ScheduleKind::Priority { + participants, + fallback, + } => { + ensure( + !participants.is_empty() && participants.len() <= 100, + crate::i18n::text("err_priority_coverage_needs_1_100_people_b162ae"), + )?; + ensure( + participants.iter().all(|id| members.contains(id)), + crate::i18n::text( + "err_choose_a_workspace_user_or_email_contact_for_eve_291413", + ), + )?; + ensure( + participants.iter().collect::>().len() == participants.len(), + crate::i18n::text("err_priority_coverage_cannot_repeat_a_person_c524c8"), + )?; + ensure( + ids.contains(fallback), + crate::i18n::text("err_fallback_schedule_does_not_exist_0ef8b9"), + )?; + } + } + let mut seen = BTreeSet::new(); + let mut next = s.id; + loop { + ensure( + seen.insert(next), + crate::i18n::text("err_fallback_schedules_cannot_contain_a_cycle_b96e58"), + )?; + match &self + .schedule(next) + .ok_or_else(|| { + crate::error::Error::Validation( + crate::i18n::text("err_missing_fallback_schedule_6234b6").into(), + ) + })? + .kind + { + ScheduleKind::Priority { fallback, .. } => next = *fallback, + ScheduleKind::Rotation { .. } => break, + } + } + } + for a in &self.absences { + ensure( + members.contains(&a.user_id), + crate::i18n::text("err_absent_employee_must_be_a_team_member_510f37"), + )?; + ensure( + a.start <= a.end && (a.end - a.start).num_days() <= 730, + crate::i18n::text("err_absence_must_be_a_valid_range_of_at_most_two_yea_db96bb"), + )?; + } + let mut holidays = BTreeSet::new(); + for holiday in &self.holidays { + validate_name(&holiday.name)?; + ensure( + holiday.country == "*" || valid_country(&holiday.country), + crate::i18n::text("err_holiday_country_must_be_an_iso_country_code_09ebeb"), + )?; + ensure( + (2000..=2100).contains(&chrono::Datelike::year(&holiday.date)), + crate::i18n::text("err_holiday_year_must_be_2000_2100_e542b1"), + )?; + ensure( + holidays.insert((&holiday.country, holiday.date)), + "A country/date can have only one holiday", + )?; + } + let policy_ids: BTreeSet<_> = self.escalations.iter().map(|e| e.id).collect(); + ensure( + policy_ids.len() == self.escalations.len(), + crate::i18n::text("err_escalation_ids_must_be_unique_8346e7"), + )?; + for e in &self.escalations { + validate_name(&e.name)?; + ensure( + !e.steps.is_empty() && e.steps.len() <= 20, + crate::i18n::text("err_an_escalation_needs_1_20_steps_644fbf"), + )?; + let mut previous = 0; + for (index, step) in e.steps.iter().enumerate() { + ensure( + step.delay_minutes >= previous && step.delay_minutes <= 10080, + crate::i18n::text( + "err_escalation_delays_must_be_ordered_and_at_most_on_93513d", + ), + )?; + previous = step.delay_minutes; + match step.recipient { + Recipient::Schedule { id } => ensure( + ids.contains(&id), + crate::i18n::message( + "err_escalation_p0_step_p1_references_a_missing_sched_a8026c", + &[ + ("p0", (e.name).to_string()), + ("p1", (index + 1).to_string()), + ], + ), + )?, + Recipient::User { id } => ensure( + members.contains(&id), + crate::i18n::text("err_escalation_employee_must_be_a_team_member_8b2c9f"), + )?, + _ => {} + } + } + ensure( + e.repeat.count <= 20 && e.repeat.wait_minutes <= 10080, + crate::i18n::text("err_invalid_escalation_repeat_settings_508f67"), + )?; + } + let mut orders = BTreeSet::new(); + let mut route_ids = BTreeSet::new(); + for r in &self.routes { + validate_name(&r.name)?; + ensure( + route_ids.insert(r.id) && orders.insert(r.order), + crate::i18n::text("err_routing_ids_and_priorities_must_be_unique_1753f6"), + )?; + ensure( + r.escalation_id.is_none_or(|id| policy_ids.contains(&id)), + crate::i18n::text("err_routing_escalation_does_not_exist_0c1581"), + )?; + ensure( + r.conditions.len() <= 20, + crate::i18n::text("err_too_many_routing_conditions_7c419b"), + )?; + for c in &r.conditions { + ensure( + [ + "tags", + "priority", + "message", + "description", + "source", + "alias", + ] + .contains(&c.field.as_str()), + crate::i18n::text("err_unsupported_routing_field_0b98b6"), + )?; + ensure( + [ + "matches", + "contains", + "equals", + "greater-than", + "less-than", + "starts-with", + "ends-with", + ] + .contains(&c.operation.as_str()), + crate::i18n::text("err_unsupported_routing_operation_c5c07c"), + )?; + ensure( + !c.value.is_empty() && c.value.len() <= 500, + crate::i18n::text("err_routing_value_must_contain_1_500_characters_0f3a17"), + )?; + } + if let Some(window) = &r.window { + window.validate()?; + } + } + if let Integration::Jsm { + cloud_id, + team_id, + users, + .. + } = &self.integration + { + ensure( + Uuid::parse_str(cloud_id).is_ok() && Uuid::parse_str(team_id).is_ok(), + crate::i18n::text("err_jsm_cloud_and_team_ids_must_be_uuids_f75b5f"), + )?; + ensure( + users.keys().all(|id| members.contains(id)), + crate::i18n::text("err_jsm_mapping_contains_a_non_member"), + )?; + ensure( + users + .values() + .all(|v| !v.trim().is_empty() && v.len() <= 200), + crate::i18n::text("err_jsm_account_ids_must_contain_1_200_characters_9fb430"), + )?; + } + Ok(()) + } +} + +pub fn validate_name(name: &str) -> Result<()> { + ensure( + !name.trim().is_empty() && name.len() <= 120, + crate::i18n::text("err_names_must_contain_1_120_characters_8071e9"), + ) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Schedule { + pub id: Uuid, + pub name: String, + #[serde(default = "default_handover")] + pub handover: NaiveTime, + #[serde(flatten)] + pub kind: ScheduleKind, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case", from = "ScheduleKindWire")] +pub enum ScheduleKind { + /// First available person wins; the ordered list does not rotate. + Priority { + participants: Vec, + fallback: Uuid, + }, + Rotation { + participants: Vec, + anchor: NaiveDate, + shift_days: u16, + }, +} + +// Legacy JSON is normalized on read, including immutable history snapshots. +#[derive(Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum ScheduleKindWire { + Priority { + participants: Vec, + fallback: Uuid, + }, + Coverage { + primary: Uuid, + secondary: Uuid, + fallback: Uuid, + }, + Rotation { + participants: Vec, + anchor: NaiveDate, + shift_days: u16, + }, +} +impl From for ScheduleKind { + fn from(value: ScheduleKindWire) -> Self { + match value { + ScheduleKindWire::Priority { + participants, + fallback, + } => Self::Priority { + participants, + fallback, + }, + ScheduleKindWire::Coverage { + primary, + secondary, + fallback, + } => Self::Priority { + participants: vec![primary, secondary], + fallback, + }, + ScheduleKindWire::Rotation { + participants, + anchor, + shift_days, + } => Self::Rotation { + participants, + anchor, + shift_days, + }, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Absence { + pub id: Uuid, + pub user_id: Uuid, + pub start: NaiveDate, + pub end: NaiveDate, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReplacementLeg { + pub schedule_id: Uuid, + pub start: DateTime, + pub end: DateTime, + pub from_user: Uuid, + pub to_user: Uuid, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RequestStatus { + Pending, + Applied, + Rejected, + Cancelled, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReplacementRequest { + pub id: Uuid, + #[serde(default)] + pub absence_id: Option, + pub requested_by: Uuid, + pub created_at: DateTime, + pub reason: String, + pub legs: Vec, + /// Remaining active spans; original legs remain available for history. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub effective_legs: Option>, + pub required_approvers: BTreeSet, + pub approvals: BTreeSet, + pub status: RequestStatus, + pub applied_at: Option>, + pub forced_by: Option, + pub force_reason: Option, +} + +impl ReplacementRequest { + pub fn effective_legs(&self) -> &[ReplacementLeg] { + self.effective_legs.as_deref().unwrap_or(&self.legs) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(tag = "provider", rename_all = "snake_case")] +pub enum Integration { + #[default] + Mock, + Jsm { + cloud_id: String, + team_id: String, + advanced_escalations: bool, + users: BTreeMap, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Escalation { + pub id: Uuid, + pub name: String, + pub steps: Vec, + #[serde(default)] + pub repeat: EscalationRepeat, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EscalationStep { + pub delay_minutes: u16, + pub condition: EscalationCondition, + pub recipient: Recipient, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum EscalationCondition { + IfNotAcked, + IfNotClosed, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Recipient { + Schedule { id: Uuid }, + User { id: Uuid }, + TeamAll, + TeamRandom, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct EscalationRepeat { + pub count: u8, + pub wait_minutes: u16, + pub reset_recipient_states: bool, + pub close_alert_after_all: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RoutingRule { + pub id: Uuid, + pub name: String, + pub order: u16, + pub conditions: Vec, + pub window: Option, + pub escalation_id: Option, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RouteCondition { + pub field: String, + pub operation: String, + pub value: String, + #[serde(default)] + pub negate: bool, +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct WorkingWindow { + pub timezone: String, + pub weekdays: Vec, + pub start: NaiveTime, + pub end: NaiveTime, +} + +impl Default for WorkingWindow { + fn default() -> Self { + Self { + timezone: "Europe/Bucharest".into(), + weekdays: vec![1, 2, 3, 4, 5], + start: default_handover(), + end: NaiveTime::from_hms_opt(19, 0, 0).unwrap(), + } + } +} + +impl WorkingWindow { + pub fn validate(&self) -> Result<()> { + ensure( + self.timezone.parse::().is_ok(), + crate::i18n::text("err_use_a_valid_iana_timezone_for_working_hours_b4e2c6"), + )?; + ensure( + !self.weekdays.is_empty() + && self.weekdays.iter().all(|d| (1..=7).contains(d)) + && self.weekdays.iter().collect::>().len() == self.weekdays.len(), + crate::i18n::text("err_choose_distinct_working_days_9a4ea4"), + )?; + ensure( + self.start < self.end, + crate::i18n::text("err_working_hours_must_end_after_they_start_on_the_s_a19afe"), + ) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Assignment { + pub schedule_id: Uuid, + pub start: DateTime, + pub end: DateTime, + pub user_id: Uuid, + pub reason: String, + pub unavailable: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Timeline { + pub from: DateTime, + pub to: DateTime, + pub assignments: Vec, +} diff --git a/src/engine.rs b/src/engine.rs new file mode 100644 index 0000000..4957197 --- /dev/null +++ b/src/engine.rs @@ -0,0 +1,836 @@ +//! Pure local-calendar scheduling. There is deliberately no database or provider access here. +use crate::{ + domain::*, + error::{Error, Result, ensure}, +}; +use chrono::{DateTime, Days, Duration, LocalResult, NaiveDate, TimeZone, Utc}; +use chrono_tz::Tz; +use std::collections::BTreeSet; +use uuid::Uuid; + +pub fn handover(doc: &WorkspaceDocument, day: NaiveDate) -> Result> { + local_time(doc, day, default_handover()) +} + +pub fn schedule_handover( + doc: &WorkspaceDocument, + id: Uuid, + day: NaiveDate, +) -> Result> { + let schedule = doc + .schedule(id) + .ok_or_else(|| Error::Validation(crate::i18n::text("choose_a_schedule_0636f3").into()))?; + local_time(doc, day, schedule.handover) +} + +pub fn local_time( + doc: &WorkspaceDocument, + day: NaiveDate, + time: chrono::NaiveTime, +) -> Result> { + let zone: Tz = doc + .timezone + .parse() + .map_err(|_| Error::Validation(crate::i18n::text("err_unknown_timezone_9d07d7").into()))?; + match zone.from_local_datetime(&day.and_time(time)) { + LocalResult::Single(time) => Ok(time.with_timezone(&Utc)), + _ => Err(Error::Validation(crate::i18n::message( + "cover_local_time_invalid", + &[ + ("date", day.format("%d-%m-%Y").to_string()), + ("time", time.format("%H:%M").to_string()), + ("zone", doc.timezone.clone()), + ], + ))), + } +} + +pub fn duty_day(doc: &WorkspaceDocument, at: DateTime) -> Result { + day_at(doc, at, default_handover()) +} + +pub fn schedule_duty_day( + doc: &WorkspaceDocument, + id: Uuid, + at: DateTime, +) -> Result { + let schedule = doc + .schedule(id) + .ok_or_else(|| Error::Validation(crate::i18n::text("choose_a_schedule_0636f3").into()))?; + day_at(doc, at, schedule.handover) +} + +fn day_at( + doc: &WorkspaceDocument, + at: DateTime, + time: chrono::NaiveTime, +) -> Result { + let zone: Tz = doc + .timezone + .parse() + .map_err(|_| Error::Validation(crate::i18n::text("err_unknown_timezone_9d07d7").into()))?; + let local = at.with_timezone(&zone); + let date = local.date_naive(); + if at < local_time(doc, date, time)? { + date.pred_opt().ok_or_else(|| { + Error::Validation(crate::i18n::text("err_date_is_out_of_range_4ecf15").into()) + }) + } else { + Ok(date) + } +} + +pub fn publication_window( + doc: &WorkspaceDocument, + now: DateTime, +) -> Result<(DateTime, DateTime)> { + let day = duty_day(doc, now)?; + let mut from = handover(doc, day)?; + let mut to = handover(doc, day + Duration::days(i64::from(doc.horizon_days)))?; + for schedule in &doc.schedules { + let day = schedule_duty_day(doc, schedule.id, now)?; + from = from.min(schedule_handover(doc, schedule.id, day)?); + to = to.max(schedule_handover( + doc, + schedule.id, + day + Duration::days(i64::from(doc.horizon_days)), + )?); + } + Ok((from, to)) +} + +pub fn timeline( + doc: &WorkspaceDocument, + from: DateTime, + to: DateTime, +) -> Result { + ensure( + from < to && (to - from) <= Duration::days(367), + crate::i18n::text("err_calendar_range_must_be_positive_and_at_most_367_23a2e6"), + )?; + let mut bounds = BTreeSet::from([from, to]); + // Dependencies may hand over at different times. Split at every schedule's boundary. + for schedule in &doc.schedules { + let mut day = schedule_duty_day(doc, schedule.id, from)?; + let last_day = schedule_duty_day(doc, schedule.id, to)?; + while day <= last_day { + let point = schedule_handover(doc, schedule.id, day)?; + if point > from && point < to { + bounds.insert(point); + } + day = day.checked_add_days(Days::new(1)).ok_or_else(|| { + Error::Validation(crate::i18n::text("err_date_is_out_of_range_4ecf15").into()) + })?; + } + } + for request in doc + .requests + .iter() + .filter(|r| r.status == RequestStatus::Applied) + { + for leg in request.effective_legs() { + for point in [leg.start, leg.end] { + if point > from && point < to { + bounds.insert(point); + } + } + } + } + let bounds: Vec<_> = bounds.into_iter().collect(); + let mut assignments = Vec::::new(); + for schedule in &doc.schedules { + for pair in bounds.windows(2) { + let day = schedule_duty_day(doc, schedule.id, pair[0])?; + let (user_id, reason) = resolve(doc, schedule.id, pair[0], &mut BTreeSet::new())?; + let unavailable = doc.is_absent(user_id, day); + if let Some(last) = assignments.last_mut() + && last.schedule_id == schedule.id + && last.end == pair[0] + && last.user_id == user_id + && last.reason == reason + && last.unavailable == unavailable + { + last.end = pair[1]; + continue; + } + assignments.push(Assignment { + schedule_id: schedule.id, + start: pair[0], + end: pair[1], + user_id, + reason, + unavailable, + }); + } + } + Ok(Timeline { + from, + to, + assignments, + }) +} + +fn resolve( + doc: &WorkspaceDocument, + id: Uuid, + at: DateTime, + seen: &mut BTreeSet, +) -> Result<(Uuid, String)> { + ensure( + seen.insert(id), + crate::i18n::text("err_fallback_schedules_contain_a_cycle_e98539"), + )?; + let schedule = doc.schedule(id).ok_or_else(|| { + Error::Validation(crate::i18n::text("err_missing_schedule_4dc6ee").into()) + })?; + let day = schedule_duty_day(doc, id, at)?; + let overrides: Vec<_> = doc + .requests + .iter() + .filter(|r| r.status == RequestStatus::Applied) + .flat_map(|r| r.effective_legs().iter()) + .filter(|l| l.schedule_id == id && l.start <= at && at < l.end) + .collect(); + ensure( + overrides.len() <= 1, + crate::i18n::text("err_approved_overrides_overlap_ce280c"), + )?; + if let Some(leg) = overrides.first() { + return Ok(( + leg.to_user, + crate::i18n::text("err_approved_replacement_f0feba").into(), + )); + } + match &schedule.kind { + ScheduleKind::Priority { + participants, + fallback, + } => { + let global_holiday = doc + .holidays + .iter() + .any(|h| h.date == day && h.country == "*"); + if !global_holiday + && let Some((index, user)) = participants.iter().enumerate().find(|(_, user)| { + !doc.is_absent(**user, day) && !doc.country_holiday(**user, day) + }) + { + return Ok(( + *user, + if index == 0 { + "Primary".into() + } else { + crate::i18n::message( + "err_earlier_owners_off_responsible_p0_bb11c0", + &[("p0", (index + 1).to_string())], + ) + }, + )); + } + let (user, _) = resolve(doc, *fallback, at, seen)?; + Ok(( + user, + crate::i18n::text("err_owners_off_general_on_call_9cfec2").into(), + )) + } + ScheduleKind::Rotation { + participants, + anchor, + shift_days, + } => { + ensure( + !participants.is_empty() && *shift_days > 0, + crate::i18n::text("err_invalid_rotation_8f771c"), + )?; + let shift = (day - *anchor) + .num_days() + .div_euclid(i64::from(*shift_days)); + let index = shift.rem_euclid(participants.len() as i64) as usize; + Ok((participants[index], "Rotation".into())) + } + } +} + +/// Replace only overlapping active spans, preserving immutable original request legs. +/// Call only after ownership validation and approval, before inserting the new override. +pub fn supersede_overrides(doc: &mut WorkspaceDocument, legs: &[ReplacementLeg]) { + for request in doc + .requests + .iter_mut() + .filter(|r| r.status == RequestStatus::Applied) + { + let mut spans = request.effective_legs().to_vec(); + let mut changed = false; + for leg in legs { + let mut remaining = Vec::new(); + for span in spans { + if span.schedule_id != leg.schedule_id + || span.end <= leg.start + || leg.end <= span.start + { + remaining.push(span); + continue; + } + changed = true; + if span.start < leg.start { + remaining.push(ReplacementLeg { + end: leg.start, + ..span.clone() + }); + } + if leg.end < span.end { + remaining.push(ReplacementLeg { + start: leg.end, + ..span + }); + } + } + spans = remaining; + } + if changed { + request.effective_legs = Some(spans); + } + } +} + +pub fn validate_legs( + doc: &WorkspaceDocument, + legs: &[ReplacementLeg], + members: &BTreeSet, + now: DateTime, +) -> Result<()> { + let members = doc.person_ids(members); + ensure( + !legs.is_empty() && legs.len() <= 2, + "A replacement needs one interval; a swap needs two", + )?; + for (index, leg) in legs.iter().enumerate() { + ensure( + doc.schedule(leg.schedule_id).is_some(), + crate::i18n::text("err_schedule_does_not_exist_637210"), + )?; + ensure( + leg.start >= now && leg.start < leg.end && leg.end - leg.start <= Duration::days(90), + crate::i18n::text("err_replacement_must_be_future_facing_and_at_most_90_c483a9"), + )?; + ensure( + leg.end <= now + Duration::days(366), + crate::i18n::text("err_replacement_must_be_within_the_next_year_2cab72"), + )?; + ensure( + leg.from_user != leg.to_user + && members.contains(&leg.to_user) + && members.contains(&leg.from_user), + crate::i18n::text("err_choose_two_different_team_members_38153c"), + )?; + for other in &legs[..index] { + ensure( + leg.schedule_id != other.schedule_id + || leg.end <= other.start + || other.end <= leg.start, + crate::i18n::text("err_swap_intervals_overlap_740eb3"), + )?; + } + let plan = timeline(doc, leg.start, leg.end)?; + let segments: Vec<_> = plan + .assignments + .iter() + .filter(|a| a.schedule_id == leg.schedule_id) + .collect(); + ensure( + !segments.is_empty() && segments.iter().all(|a| a.user_id == leg.from_user), + crate::i18n::text("err_the_original_employee_no_longer_owns_the_entire_c36051"), + )?; + let mut day = schedule_duty_day(doc, leg.schedule_id, leg.start)?; + let end_day = schedule_duty_day(doc, leg.schedule_id, leg.end - Duration::nanoseconds(1))?; + while day <= end_day { + ensure( + !doc.is_absent(leg.to_user, day), + crate::i18n::text("err_the_replacement_employee_is_absent_during_this_i_3b5cc2"), + )?; + day = day.succ_opt().ok_or_else(|| { + Error::Validation(crate::i18n::text("err_date_is_out_of_range_4ecf15").into()) + })?; + } + } + if legs.len() == 2 { + ensure( + legs[0].from_user == legs[1].to_user && legs[0].to_user == legs[1].from_user, + crate::i18n::text("err_a_swap_must_exchange_shifts_between_the_same_two_dd579e"), + )?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::NaiveTime; + #[test] + fn holidays_skip_only_people_in_the_affected_country_and_keep_rotation_running() { + let (mut doc, people, rotation, coverage) = fixture(); + doc.people = people + .iter() + .enumerate() + .map(|(i, id)| Person { + id: *id, + user_id: Some(*id), + display_name: format!("Person {i}"), + email: None, + country: Some(if i == 0 { "AM" } else { "CY" }.into()), + }) + .collect(); + let day = "2026-03-30".parse().unwrap(); + doc.holidays.push(Holiday { + country: "AM".into(), + date: day, + name: "Armenia only".into(), + }); + let from = handover(&doc, day).unwrap(); + let owner = |doc: &WorkspaceDocument, id| { + timeline(doc, from, from + Duration::hours(1)) + .unwrap() + .assignments + .into_iter() + .find(|a| a.schedule_id == id) + .unwrap() + }; + assert_eq!(owner(&doc, coverage).user_id, people[1]); + assert_eq!(owner(&doc, rotation).user_id, people[2]); + doc.holidays.push(Holiday { + country: "CY".into(), + date: day, + name: "Cyprus only".into(), + }); + assert_eq!(owner(&doc, coverage).user_id, people[2]); + assert!( + !owner(&doc, rotation).unavailable, + "General on-call continues on public holidays" + ); + doc.holidays.retain(|h| h.country == "CY"); + assert_eq!( + owner(&doc, coverage).user_id, + people[0], + "A Cyprus holiday does not exclude the Armenian primary" + ); + } + + #[test] + fn fallback_uses_its_own_handover_instead_of_parent_duty_day() { + let (mut doc, people, rotation, coverage) = fixture(); + doc.timezone = "UTC".into(); + doc.schedules[0].handover = "09:00:00".parse().unwrap(); + for id in &people[..2] { + doc.absences.push(Absence { + id: Uuid::new_v4(), + user_id: *id, + start: "2026-03-22".parse().unwrap(), + end: "2026-03-24".parse().unwrap(), + }); + } + let plan = timeline( + &doc, + "2026-03-23T08:00:00Z".parse().unwrap(), + "2026-03-23T12:00:00Z".parse().unwrap(), + ) + .unwrap(); + for id in [rotation, coverage] { + let shifts: Vec<_> = plan + .assignments + .iter() + .filter(|a| a.schedule_id == id) + .collect(); + assert_eq!(shifts.len(), 2); + assert_eq!(shifts[0].user_id, people[0]); + assert_eq!(shifts[1].user_id, people[1]); + assert_eq!( + shifts[0].end, + "2026-03-23T09:00:00Z".parse::>().unwrap() + ); + assert_eq!(shifts[0].end, shifts[1].start); + } + } + fn fixture() -> (WorkspaceDocument, [Uuid; 3], Uuid, Uuid) { + let people = [Uuid::from_u128(1), Uuid::from_u128(2), Uuid::from_u128(3)]; + let rotation = Uuid::from_u128(10); + let coverage = Uuid::from_u128(11); + let mut doc = WorkspaceDocument::new("Platform".into()); + doc.schedules = vec![ + Schedule { + handover: default_handover(), + id: rotation, + name: "General".into(), + kind: ScheduleKind::Rotation { + participants: people.to_vec(), + anchor: "2026-03-16".parse().unwrap(), + shift_days: 7, + }, + }, + Schedule { + handover: default_handover(), + id: coverage, + name: "Payments".into(), + kind: ScheduleKind::Priority { + participants: vec![people[0], people[1]], + fallback: rotation, + }, + }, + ]; + (doc, people, rotation, coverage) + } + #[test] + fn weekly_rotation_keeps_local_handover_across_dst_and_wraps_backwards() { + let (doc, people, rotation, _) = fixture(); + let start = handover(&doc, "2026-03-23".parse().unwrap()).unwrap(); + let end = handover(&doc, "2026-03-30".parse().unwrap()).unwrap(); + assert_eq!((end - start).num_hours(), 167); + let plan = timeline(&doc, start, end).unwrap(); + let shifts: Vec<_> = plan + .assignments + .iter() + .filter(|a| a.schedule_id == rotation) + .collect(); + assert_eq!(shifts.len(), 1); + assert_eq!(shifts[0].user_id, people[1]); + let previous = handover(&doc, "2026-03-09".parse().unwrap()).unwrap(); + assert_eq!( + timeline(&doc, previous, previous + Duration::hours(1)) + .unwrap() + .assignments[0] + .user_id, + people[2] + ); + } + #[test] + fn coverage_uses_secondary_then_effective_fallback_and_holiday_precedence() { + let (mut doc, people, rotation, coverage) = fixture(); + let day = "2026-03-30".parse().unwrap(); + let start = handover(&doc, day).unwrap(); + let end = start + Duration::hours(24); + doc.absences.push(Absence { + id: Uuid::new_v4(), + user_id: people[0], + start: day, + end: day, + }); + let get = |doc: &WorkspaceDocument| { + timeline(doc, start, end) + .unwrap() + .assignments + .into_iter() + .find(|a| a.schedule_id == coverage) + .unwrap() + .user_id + }; + assert_eq!(get(&doc), people[1]); + doc.absences.push(Absence { + id: Uuid::new_v4(), + user_id: people[1], + start: day, + end: day, + }); + assert_eq!(get(&doc), people[2]); + doc.absences.clear(); + doc.holidays.push(Holiday { + country: "*".into(), + date: day, + name: "Holiday".into(), + }); + assert_eq!(get(&doc), people[2]); + doc.requests.push(ReplacementRequest { + effective_legs: None, + id: Uuid::new_v4(), + absence_id: None, + requested_by: people[2], + created_at: start - Duration::days(1), + reason: "Swap".into(), + legs: vec![ReplacementLeg { + schedule_id: rotation, + start, + end, + from_user: people[2], + to_user: people[0], + }], + required_approvers: BTreeSet::new(), + approvals: BTreeSet::new(), + status: RequestStatus::Applied, + applied_at: Some(start), + forced_by: None, + force_reason: None, + }); + assert_eq!(get(&doc), people[0]); + } + #[test] + fn ambiguous_handover_and_cycles_are_errors() { + let (mut doc, people, rotation, coverage) = fixture(); + doc.schedules[0].handover = NaiveTime::from_hms_opt(3, 30, 0).unwrap(); + assert!(schedule_handover(&doc, rotation, "2026-03-29".parse().unwrap()).is_err()); + assert!(schedule_handover(&doc, rotation, "2026-10-25".parse().unwrap()).is_err()); + doc.schedules[0].kind = ScheduleKind::Priority { + participants: vec![people[0], people[1]], + fallback: coverage, + }; + assert!(doc.validate(&people.into_iter().collect()).is_err()); + assert!( + timeline( + &doc, + "2026-04-01T09:00:00Z".parse().unwrap(), + "2026-04-02T09:00:00Z".parse().unwrap() + ) + .is_ok() + ); + doc.holidays.push(Holiday { + country: "*".into(), + date: "2026-04-01".parse().unwrap(), + name: "Holiday".into(), + }); + assert!( + timeline( + &doc, + "2026-04-01T09:00:00Z".parse().unwrap(), + "2026-04-02T09:00:00Z".parse().unwrap() + ) + .is_err() + ); + assert_eq!(doc.schedules[0].id, rotation); + } + #[test] + fn request_validation_rejects_changed_owner_and_absent_replacement() { + let (mut doc, people, rotation, _) = fixture(); + let start = handover(&doc, "2026-03-30".parse().unwrap()).unwrap(); + let leg = ReplacementLeg { + schedule_id: rotation, + start, + end: start + Duration::hours(24), + from_user: people[0], + to_user: people[1], + }; + let members = people.into_iter().collect(); + assert!( + validate_legs( + &doc, + std::slice::from_ref(&leg), + &members, + start - Duration::days(1) + ) + .is_err() + ); + let leg = ReplacementLeg { + from_user: people[2], + ..leg + }; + assert!( + validate_legs( + &doc, + std::slice::from_ref(&leg), + &members, + start - Duration::days(1) + ) + .is_ok() + ); + doc.absences.push(Absence { + id: Uuid::new_v4(), + user_id: people[1], + start: "2026-03-30".parse().unwrap(), + end: "2026-03-30".parse().unwrap(), + }); + assert!(validate_legs(&doc, &[leg], &members, start - Duration::days(1)).is_err()); + } + + #[test] + fn generated_rotations_cover_every_instant_once_across_zones_and_shift_lengths() { + for zone in [ + "UTC", + "Europe/Bucharest", + "America/New_York", + "Australia/Sydney", + "Asia/Kathmandu", + ] { + for length in [1, 2, 7, 14, 31] { + let (mut doc, people, rotation, _) = fixture(); + doc.timezone = zone.into(); + doc.schedules.truncate(1); + let anchor = "2026-01-01".parse().unwrap(); + doc.schedules[0].kind = ScheduleKind::Rotation { + participants: people.to_vec(), + anchor, + shift_days: length, + }; + let from = handover(&doc, anchor).unwrap(); + let to = handover(&doc, anchor + Duration::days(365)).unwrap(); + let plan = timeline(&doc, from, to).unwrap(); + assert_eq!(plan.assignments.first().unwrap().start, from); + assert_eq!(plan.assignments.last().unwrap().end, to); + for pair in plan.assignments.windows(2) { + assert_eq!( + pair[0].end, pair[1].start, + "No gaps or overlaps: {zone}, {length}" + ); + } + for offset in 0..365 { + let at = handover(&doc, anchor + Duration::days(offset)).unwrap(); + let owners: Vec<_> = plan + .assignments + .iter() + .filter(|a| a.start <= at && at < a.end) + .collect(); + assert_eq!(owners.len(), 1); + assert_eq!(owners[0].schedule_id, rotation); + assert_eq!( + owners[0].user_id, + people[(offset / i64::from(length)) as usize % 3] + ); + } + } + } + } + + #[test] + fn autumn_shift_is_169_hours_and_half_open_handover_changes_owner_once() { + let (mut doc, people, rotation, _) = fixture(); + let anchor = "2026-10-19".parse().unwrap(); + doc.schedules[0].kind = ScheduleKind::Rotation { + participants: people.to_vec(), + anchor, + shift_days: 7, + }; + let from = handover(&doc, anchor).unwrap(); + let boundary = handover(&doc, anchor + Duration::days(7)).unwrap(); + assert_eq!((boundary - from).num_hours(), 169); + let plan = timeline( + &doc, + boundary - Duration::nanoseconds(1), + boundary + Duration::nanoseconds(1), + ) + .unwrap(); + let shifts: Vec<_> = plan + .assignments + .iter() + .filter(|a| a.schedule_id == rotation) + .collect(); + assert_eq!(shifts.len(), 2); + assert_eq!(shifts[0].user_id, people[0]); + assert_eq!(shifts[0].end, boundary); + assert_eq!(shifts[1].user_id, people[1]); + } + + #[test] + fn partial_replacement_does_not_move_base_rotation_and_overlaps_are_rejected() { + let (mut doc, people, rotation, _) = fixture(); + let day = "2026-03-16".parse().unwrap(); + let start = handover(&doc, day).unwrap(); + let end = handover(&doc, day + Duration::days(8)).unwrap(); + let leg = ReplacementLeg { + schedule_id: rotation, + start: start + Duration::hours(3), + end: start + Duration::hours(6), + from_user: people[0], + to_user: people[2], + }; + let mut request = ReplacementRequest { + effective_legs: None, + id: Uuid::new_v4(), + absence_id: None, + requested_by: people[0], + created_at: start - Duration::days(1), + reason: "Appointment".into(), + legs: vec![leg.clone()], + required_approvers: BTreeSet::from([people[2]]), + approvals: BTreeSet::new(), + status: RequestStatus::Pending, + applied_at: None, + forced_by: None, + force_reason: None, + }; + doc.requests.push(request.clone()); + assert_eq!( + timeline(&doc, start, end) + .unwrap() + .assignments + .iter() + .filter(|a| a.schedule_id == rotation) + .count(), + 2 + ); + request.status = RequestStatus::Applied; + doc.requests[0] = request.clone(); + let plan = timeline(&doc, start, end).unwrap(); + let shifts: Vec<_> = plan + .assignments + .iter() + .filter(|a| a.schedule_id == rotation) + .collect(); + assert_eq!( + shifts.iter().map(|a| a.user_id).collect::>(), + vec![people[0], people[2], people[0], people[1]] + ); + let next = ReplacementLeg { + start: leg.end, + end: leg.end + Duration::hours(1), + ..leg.clone() + }; + assert!( + validate_legs(&doc, &[next], &people.into_iter().collect(), start).is_ok(), + "Adjacent replacements are legal" + ); + assert!(validate_legs(&doc, &[leg], &people.into_iter().collect(), start).is_err()); + doc.requests.push(request); + assert!( + timeline(&doc, start, end).is_err(), + "Corrupt overlapping state must never silently choose an owner" + ); + } + #[test] + fn superseding_cover_preserves_swap_partner_history_and_legacy_requests() { + let (mut doc, people, rotation, coverage) = fixture(); + let start = handover(&doc, "2026-03-16".parse().unwrap()).unwrap(); + let original = ReplacementLeg { + schedule_id: rotation, + start, + end: start + Duration::hours(12), + from_user: people[0], + to_user: people[1], + }; + let partner = ReplacementLeg { + schedule_id: coverage, + from_user: people[1], + to_user: people[0], + ..original.clone() + }; + let request = crate::absence::synthetic_request( + Uuid::new_v4(), + vec![original.clone(), partner.clone()], + start, + ); + let json = serde_json::to_value(&request).unwrap(); + assert!(json.get("effective_legs").is_none()); + doc.requests.push(serde_json::from_value(json).unwrap()); + let correction = ReplacementLeg { + start: start + Duration::hours(3), + end: start + Duration::hours(6), + from_user: people[1], + to_user: people[2], + ..original.clone() + }; + supersede_overrides(&mut doc, &[correction]); + let spans = doc.requests[0].effective_legs(); + assert_eq!(spans.len(), 3); + assert_eq!( + (spans[0].start, spans[0].end), + (start, start + Duration::hours(3)) + ); + assert_eq!( + (spans[1].start, spans[1].end), + (start + Duration::hours(6), original.end) + ); + assert_eq!(spans[2].schedule_id, partner.schedule_id); + assert_eq!(doc.requests[0].legs.len(), 2); + assert_eq!(doc.requests[0].legs[0].end, original.end); + supersede_overrides(&mut doc, &[original, partner]); + assert!(doc.requests[0].effective_legs().is_empty()); + let restored: ReplacementRequest = + serde_json::from_value(serde_json::to_value(&doc.requests[0]).unwrap()).unwrap(); + assert!(restored.effective_legs().is_empty()); + assert_eq!(restored.legs.len(), 2); + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..4e3ae46 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,64 @@ +use cot::{ + StatusCode, + json::Json, + response::{IntoResponse, Response}, +}; +use serde_json::json; + +pub type Result = std::result::Result; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("{0}")] + Validation(String), + #[error("{}", crate::i18n::text("err_sign_in_to_continue_9a7843"))] + Unauthorized, + #[error( + "{}", + crate::i18n::text("err_you_do_not_have_permission_for_this_action_6306e9") + )] + Forbidden, + #[error("{}", crate::i18n::text("err_resource_not_found_2d1d4c"))] + NotFound, + #[error("{0}")] + Conflict(String), + #[error("{0}")] + Provider(String), + #[error(transparent)] + Database(#[from] cot::db::DatabaseError), + #[error(transparent)] + Json(#[from] serde_json::Error), + #[error("{0}")] + Internal(String), +} + +impl IntoResponse for Error { + fn into_response(self) -> cot::Result { + let (status, message) = match &self { + Self::Validation(_) => (StatusCode::UNPROCESSABLE_ENTITY, self.to_string()), + Self::Unauthorized => (StatusCode::UNAUTHORIZED, self.to_string()), + Self::Forbidden => (StatusCode::FORBIDDEN, self.to_string()), + Self::NotFound => (StatusCode::NOT_FOUND, self.to_string()), + Self::Conflict(_) => (StatusCode::CONFLICT, self.to_string()), + Self::Provider(_) => (StatusCode::BAD_GATEWAY, self.to_string()), + _ => { + tracing::error!(error = %self, "request failed"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + crate::i18n::text("err_an_internal_error_occurred_96c51c").into(), + ) + } + }; + Json(json!({"error": message})) + .with_status(status) + .into_response() + } +} + +pub fn ensure(condition: bool, message: impl Into) -> Result<()> { + if condition { + Ok(()) + } else { + Err(Error::Validation(message.into())) + } +} diff --git a/src/generation.rs b/src/generation.rs new file mode 100644 index 0000000..30f6e0e --- /dev/null +++ b/src/generation.rs @@ -0,0 +1,1299 @@ +//! Bounded, deterministic expansion of typed workspace data and visual rule blocks. +use crate::{ + domain::*, + engine, + error::{Error, Result, ensure}, +}; +use chrono::{DateTime, NaiveDate, NaiveTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Node { + pub id: Uuid, + #[serde(deserialize_with = "deserialize_node_kind")] + pub kind: String, + #[serde(default)] + pub key: String, + #[serde(default)] + pub value: Value, + #[serde(default)] + pub entries: Vec, +} +// Normalize legacy stored variables and history recursively without changing node IDs. +fn deserialize_node_kind<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> std::result::Result { + let kind = String::deserialize(deserializer)?; + Ok(if kind == "record" { + "dictionary".into() + } else { + kind + }) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Variable { + pub id: Uuid, + pub name: String, + pub node: Node, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Binding { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub items: Vec, + #[serde(default)] + pub kind: String, + #[serde(default)] + pub value: Value, + #[serde(default)] + pub variable: Option, + #[serde(default)] + pub iteration: Option, + #[serde(default)] + pub key: Option>, + #[serde(default)] + pub output: Option, + #[serde(default)] + pub path: Vec, + #[serde(default)] + pub prefix: String, + #[serde(default)] + pub suffix: String, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Iteration { + pub id: Uuid, + pub name: String, + pub source: Binding, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Predicate { + pub left: Binding, + pub operation: String, + pub right: Binding, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Step { + pub delay: Binding, + pub recipient: Binding, + #[serde(default)] + pub user: bool, + #[serde(default)] + pub condition: String, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Condition { + pub field: String, + pub operation: String, + pub value: Binding, + #[serde(default)] + pub negate: bool, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(from = "BlockWire")] +pub struct Block { + pub id: Uuid, + pub kind: String, + pub fields: BTreeMap, + #[serde(default)] + pub steps: Vec, + #[serde(default)] + pub conditions: Vec, +} +#[derive(Deserialize)] +struct BlockWire { + pub id: Uuid, + pub kind: String, + pub fields: BTreeMap, + #[serde(default)] + pub steps: Vec, + #[serde(default)] + pub conditions: Vec, +} +impl From for Block { + fn from(mut value: BlockWire) -> Self { + if value.kind == "coverage" { + value.kind = "priority".into(); + let items = ["primary", "secondary"] + .into_iter() + .map(|key| value.fields.remove(key).unwrap_or_default()) + .collect(); + value.fields.insert( + "participants".into(), + Binding { + kind: "list".into(), + items, + ..Default::default() + }, + ); + } + Self { + id: value.id, + kind: value.kind, + fields: value.fields, + steps: value.steps, + conditions: value.conditions, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Rule { + pub id: Uuid, + pub name: String, + pub enabled: bool, + #[serde(default)] + pub iterations: Vec, + #[serde(default)] + pub when: Option, + #[serde(default)] + pub blocks: Vec, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Managed { + pub id: Uuid, + pub kind: String, + pub rule: Uuid, + pub block: Uuid, + pub item: String, +} +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Generation { + #[serde(default)] + pub rules: Vec, + #[serde(default)] + pub variables: Vec, + #[serde(default)] + pub route_position: usize, + #[serde(default)] + pub managed: Vec, +} +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Issue { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rule_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub block_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub variable_id: Option, + #[serde(default)] + pub secondary: bool, + pub path: String, + pub message: String, +} +#[derive(Debug, Serialize)] +pub struct Change { + pub kind: String, + pub id: Uuid, + pub name: String, + pub action: String, + pub source: String, +} +pub struct Expansion { + pub trace: Vec, + pub document: WorkspaceDocument, + pub changes: Vec, + pub issues: Vec, +} + +pub fn stable_id(parts: &Value) -> Uuid { + let digest = Sha256::digest(parts.to_string().as_bytes()); + let mut bytes = [0; 16]; + bytes.copy_from_slice(&digest[..16]); + bytes[6] = (bytes[6] & 15) | 128; + bytes[8] = (bytes[8] & 63) | 128; + Uuid::from_bytes(bytes) +} +fn problem(path: impl Into, message: impl Into) -> Issue { + Issue { + path: path.into(), + message: message.into(), + ..Issue::default() + } +} +fn validate_node( + node: &Node, + path: &str, + depth: usize, + seen: &mut BTreeSet, + issues: &mut Vec, +) { + if depth > 8 || seen.len() >= 10000 { + issues.push(problem( + path, + crate::i18n::text("err_data_is_limited_to_8_nested_levels_and_10_000_va_fa5d71"), + )); + return; + } + if !seen.insert(node.id) { + issues.push(problem( + path, + crate::i18n::text("err_data_entry_ids_must_be_unique_4725be"), + )); + return; + } + if matches!(node.kind.as_str(), "list" | "dictionary") { + if node.entries.len() > 1000 { + issues.push(problem( + path, + crate::i18n::text("err_a_collection_supports_at_most_1_000_entries"), + )); + return; + } + let mut keys = BTreeSet::new(); + for (index, entry) in node.entries.iter().enumerate() { + let key = if node.kind == "list" { + (index + 1).to_string() + } else { + entry.key.clone() + }; + if node.kind != "list" + && (key.trim().is_empty() || key.len() > 120 || !keys.insert(key.clone())) + { + issues.push(problem( + format!("{path} / {key}"), + crate::i18n::text("err_dictionary_keys_must_be_nonempty_and_unique_4e8ce4"), + )); + } + validate_node(entry, &format!("{path} / {key}"), depth + 1, seen, issues); + } + return; + } + let valid = match node.kind.as_str() { + "text" => node.value.as_str().is_some_and(|s| s.len() <= 2000), + "person" => node.value.as_str().is_some_and(|s| { + Uuid::parse_str(s).is_ok() || email_address::EmailAddress::is_valid(s) + }), + "number" | "duration" => node + .value + .as_f64() + .is_some_and(|n| n.is_finite() && (node.kind != "duration" || n >= 0.0)), + "boolean" => node.value.is_boolean(), + "date" => node + .value + .as_str() + .is_some_and(|s| s.parse::().is_ok()), + "time" => node.value.as_str().is_some_and(|s| { + NaiveTime::parse_from_str(s, "%H:%M") + .or_else(|_| s.parse()) + .is_ok() + }), + _ => false, + }; + if !valid || !node.entries.is_empty() { + issues.push(problem( + path, + crate::i18n::message( + "err_enter_a_valid_p0_value_3b830b", + &[("p0", (node.kind).to_string())], + ), + )); + } +} +pub fn validate_variables(variables: &[Variable]) -> Vec { + let mut issues = vec![]; + let mut names = BTreeSet::new(); + let mut ids = BTreeSet::new(); + let mut nodes = BTreeSet::new(); + if variables.len() > 100 { + return vec![problem( + "Variables", + crate::i18n::text("err_use_at_most_100_workspace_variables_c7d391"), + )]; + } + for v in variables { + let issue_start = issues.len(); + let path = crate::i18n::message("err_variable_p0_973058", &[("p0", (v.name).to_string())]); + if v.name.trim().is_empty() + || v.name.len() > 80 + || !names.insert(v.name.trim().to_lowercase()) + || !ids.insert(v.id) + { + issues.push(problem( + &path, + crate::i18n::text("err_choose_a_unique_variable_name_and_id_ee4fc3"), + )); + } + validate_node(&v.node, &path, 0, &mut nodes, &mut issues); + for issue in &mut issues[issue_start..] { + issue.variable_id = Some(v.id); + } + } + issues +} +impl Node { + fn json(&self) -> Value { + match self.kind.as_str() { + "list" => Value::Array(self.entries.iter().map(Node::json).collect()), + "dictionary" => Value::Object( + self.entries + .iter() + .map(|e| (e.key.clone(), e.json())) + .collect(), + ), + _ => self.value.clone(), + } + } + fn literal(value: Value) -> Self { + let id = stable_id(&json!(["literal", value])); + let (kind, entries) = match &value { + Value::Array(values) => ( + "list", + values.iter().map(|v| Self::literal(v.clone())).collect(), + ), + Value::Object(values) => ( + "dictionary", + values + .iter() + .map(|(key, v)| { + let mut n = Self::literal(v.clone()); + n.key = key.clone(); + n + }) + .collect(), + ), + _ => ("literal", vec![]), + }; + Self { + id, + kind: kind.into(), + key: String::new(), + value, + entries, + } + } +} +#[derive(Clone, Default)] +struct Context { + items: BTreeMap, + keys: Vec, + labels: Vec, +} +struct Resolver<'a> { + variables: &'a [Variable], + context: &'a Context, + outputs: &'a BTreeMap, +} +impl Resolver<'_> { + fn eval(&self, b: &Binding, depth: usize) -> Result { + ensure( + depth <= 16, + crate::i18n::text("err_bindings_can_be_nested_at_most_16_levels_b78618"), + )?; + let var = || { + self.variables + .iter() + .find(|v| Some(v.id) == b.variable) + .map(|v| v.node.clone()) + .ok_or_else(|| { + Error::Validation( + crate::i18n::text("err_choose_an_existing_workspace_variable_db6738") + .into(), + ) + }) + }; + let mut node = match b.kind.as_str() { + "literal" => Node::literal(b.value.clone()), + "list" => Node::literal(Value::Array( + b.items + .iter() + .map(|item| self.value(item)) + .collect::>>()?, + )), + "variable" => var()?, + "item" => self + .context + .items + .get(&b.iteration.unwrap_or_default()) + .cloned() + .ok_or_else(|| { + Error::Validation( + crate::i18n::text( + "err_choose_an_earlier_iteration_an_item_is_available_4aef3d", + ) + .into(), + ) + })?, + "key" => Node::literal(json!( + self.context + .items + .get(&b.iteration.unwrap_or_default()) + .ok_or_else(|| Error::Validation( + crate::i18n::text("err_choose_an_earlier_iteration_3562e3").into() + ))? + .key + )), + "lookup" => { + let dictionary = var()?; + ensure( + matches!(dictionary.kind.as_str(), "dictionary"), + crate::i18n::text("err_dictionary_lookup_needs_a_dictionary_variable_867cbc"), + )?; + let key = self + .eval( + b.key.as_ref().ok_or_else(|| { + Error::Validation( + crate::i18n::text("err_choose_the_dictionary_lookup_key_66d49d") + .into(), + ) + })?, + depth + 1, + )? + .json(); + let key = key.as_str().ok_or_else(|| { + Error::Validation( + crate::i18n::text("err_dictionary_keys_must_be_text_8bcea8").into(), + ) + })?; + dictionary + .entries + .iter() + .find(|n| n.key == key) + .cloned() + .ok_or_else(|| { + Error::Validation(crate::i18n::message( + "err_no_responsible_value_entry_for_key_key_06146d", + &[("key", (key).to_string())], + )) + })? + } + "output" => Node::literal(json!( + self.outputs.get(&b.output.unwrap_or_default()).ok_or_else( + || Error::Validation( + crate::i18n::text( + "err_choose_an_output_block_in_this_rule_or_a_shared_0758e5" + ) + .into() + ) + )? + )), + _ => { + return Err(Error::Validation( + crate::i18n::text("err_choose_a_value_source_00b9ad").into(), + )); + } + }; + for key in &b.path { + node = if node.kind == "list" { + key.parse::() + .ok() + .and_then(|i| i.checked_sub(1)) + .and_then(|i| node.entries.get(i)) + .cloned() + } else { + node.entries.iter().find(|e| e.key == *key).cloned() + } + .ok_or_else(|| { + Error::Validation(crate::i18n::message( + "err_field_or_list_position_key_does_not_exist_4a5e94", + &[("key", (key).to_string())], + )) + })?; + } + if !b.prefix.is_empty() || !b.suffix.is_empty() { + let value = node.json(); + let text = value.as_str().ok_or_else(|| { + Error::Validation( + crate::i18n::text("err_name_prefixes_suffixes_need_a_text_value_96730e").into(), + ) + })?; + node = Node::literal(json!(format!("{}{}{}", b.prefix, text, b.suffix))); + } + Ok(node) + } + fn value(&self, b: &Binding) -> Result { + Ok(self.eval(b, 0)?.json()) + } + fn matches(&self, p: &Predicate) -> Result { + let left = self.value(&p.left)?; + if p.operation == "not_empty" { + return Ok(!left.is_null() + && left.as_str().is_none_or(|s| !s.is_empty()) + && left.as_array().is_none_or(|a| !a.is_empty()) + && left.as_object().is_none_or(|o| !o.is_empty())); + } + let right = self.value(&p.right)?; + match p.operation.as_str() { + "equals" | "not_equals" => { + ensure( + std::mem::discriminant(&left) == std::mem::discriminant(&right), + crate::i18n::text("err_condition_values_must_have_the_same_type_e949f9"), + )?; + Ok((left == right) == (p.operation == "equals")) + } + "contains" => { + if let Some(items) = left.as_array() { + Ok(items.contains(&right)) + } else if let Some((a, b)) = left.as_str().zip(right.as_str()) { + Ok(a.contains(b)) + } else { + Err(Error::Validation( + crate::i18n::text( + "err_contains_needs_a_list_and_a_value_or_two_text_va_b878ec", + ) + .into(), + )) + } + } + _ => Err(Error::Validation( + crate::i18n::text("err_choose_a_supported_condition_f7daba").into(), + )), + } + } +} + +fn value(fields: &BTreeMap, key: &str, resolver: &Resolver<'_>) -> Result { + resolver + .value(fields.get(key).ok_or_else(|| { + Error::Validation(crate::i18n::message( + "err_set_key_a7389f", + &[("key", (key).to_string())], + )) + })?) + .map_err(|e| Error::Validation(format!("{key}: {e}"))) +} +fn text(fields: &BTreeMap, key: &str, resolver: &Resolver<'_>) -> Result { + value(fields, key, resolver)? + .as_str() + .map(str::to_owned) + .ok_or_else(|| { + Error::Validation(crate::i18n::message( + "err__key_must_be_text_4fd030", + &[("key", (key).to_string())], + )) + }) +} +fn number( + fields: &BTreeMap, + key: &str, + resolver: &Resolver<'_>, + default: u64, +) -> Result { + if !fields.contains_key(key) { + return Ok(default); + } + value(fields, key, resolver)?.as_u64().ok_or_else(|| { + Error::Validation(crate::i18n::message( + "err__key_must_be_a_nonnegative_whole_number_2fb848", + &[("key", (key).to_string())], + )) + }) +} +fn recipient( + value: &Value, + workspace: Uuid, + doc: &mut WorkspaceDocument, + members: &BTreeSet, +) -> Result { + let value = value.as_str().ok_or_else(|| { + Error::Validation( + crate::i18n::text("err_choose_a_person_or_enter_an_email_address_a24e0a").into(), + ) + })?; + if let Ok(id) = Uuid::parse_str(value) { + ensure( + members.contains(&id) || doc.person(id).is_some(), + crate::i18n::text("err_person_is_not_in_this_workspace_or_team_director_c6d509"), + )?; + return Ok(id); + } + let email = value.trim().to_lowercase(); + ensure( + email_address::EmailAddress::is_valid(&email), + crate::i18n::message( + "err__value_is_not_an_email_address_a7e20a", + &[("value", (value).to_string())], + ), + )?; + if let Some(person) = doc + .people + .iter() + .find(|p| p.email.as_deref() == Some(email.as_str())) + { + return Ok(person.id); + } + let id = stable_id(&json!(["generation-contact", workspace, email])); + doc.people.push(Person { + id, + user_id: None, + display_name: email.clone(), + email: Some(email), + country: Some("CY".into()), + }); + Ok(id) +} +fn id_value(v: Value) -> Result { + v.as_str().and_then(|s| s.parse().ok()).ok_or_else(|| { + Error::Validation( + crate::i18n::text("err_choose_a_schedule_or_policy_reference_f55953").into(), + ) + }) +} + +fn render( + block: &Block, + id: Uuid, + workspace: Uuid, + resolver: &Resolver<'_>, + doc: &mut WorkspaceDocument, + members: &BTreeSet, +) -> Result<()> { + let f = &block.fields; + let name = text(f, "name", resolver)?; + ensure( + !name.trim().is_empty() && name.len() <= 120, + crate::i18n::text("err_name_needs_1_120_characters_408c00"), + )?; + if ["rotation", "priority"].contains(&block.kind.as_str()) { + let handover = if f.contains_key("handover") { + let time = text(f, "handover", resolver)?; + NaiveTime::parse_from_str(&time, "%H:%M") + .or_else(|_| time.parse()) + .map_err(|_| { + Error::Validation(crate::i18n::text("err_handover_use_hh_mm_25d33e").into()) + })? + } else { + default_handover() + }; + let kind = match block.kind.as_str() { + "rotation" | "priority" => { + let list = value(f, "participants", resolver)?; + let list = list.as_array().ok_or_else(|| { + Error::Validation( + crate::i18n::text("err_participants_choose_a_list_of_people_emails_d13655") + .into(), + ) + })?; + ensure( + !list.is_empty() && list.len() <= 100, + crate::i18n::text("err_participants_choose_1_100_people_45308a"), + )?; + let participants = list + .iter() + .map(|v| recipient(v, workspace, doc, members)) + .collect::>>()?; + ensure( + participants.iter().collect::>().len() == participants.len(), + "participants: a person occurs more than once", + )?; + if block.kind == "priority" { + ScheduleKind::Priority { + participants, + fallback: id_value(value(f, "fallback", resolver)?)?, + } + } else { + let anchor = text(f, "anchor", resolver)?.parse().map_err(|_| { + Error::Validation( + crate::i18n::text("err_anchor_choose_a_valid_date_497062").into(), + ) + })?; + let days = number(f, "shift_days", resolver, 7)?; + ensure( + (1..=366).contains(&days), + crate::i18n::text("err_shift_days_use_1_366_days_801e00"), + )?; + ScheduleKind::Rotation { + participants, + anchor, + shift_days: days as u16, + } + } + } + _ => unreachable!(), + }; + doc.schedules.push(Schedule { + id, + name, + handover, + kind, + }); + } else if block.kind == "escalation" { + ensure( + !block.steps.is_empty() && block.steps.len() <= 20, + crate::i18n::text("err_an_escalation_needs_1_20_steps_644fbf"), + )?; + let mut steps = vec![]; + for (i, step) in block.steps.iter().enumerate() { + let delay = resolver + .value(&step.delay)? + .as_u64() + .filter(|n| *n <= 10080) + .ok_or_else(|| { + Error::Validation(crate::i18n::message( + "err_step_p0_delay_must_be_0_10080_minutes_b384d0", + &[("p0", (i + 1).to_string())], + )) + })?; + let target = resolver.value(&step.recipient)?; + steps.push(EscalationStep { + delay_minutes: delay as u16, + condition: if step.condition == "if-not-closed" { + EscalationCondition::IfNotClosed + } else { + EscalationCondition::IfNotAcked + }, + recipient: if step.user { + Recipient::User { + id: recipient(&target, workspace, doc, members)?, + } + } else { + Recipient::Schedule { + id: id_value(target)?, + } + }, + }); + } + let count = number(f, "repeat_count", resolver, 0)?; + let wait = number(f, "repeat_wait", resolver, 0)?; + ensure( + count <= 20 && wait <= 10080, + crate::i18n::text("err_escalation_repeat_is_limited_to_20_repeats_10080_9b06f3"), + )?; + doc.escalations.push(Escalation { + id, + name, + steps, + repeat: EscalationRepeat { + count: count as u8, + wait_minutes: wait as u16, + ..Default::default() + }, + }); + } else if block.kind == "route" { + let mut conditions = vec![]; + for c in &block.conditions { + let raw = resolver.value(&c.value)?; + conditions.push(RouteCondition { + field: c.field.clone(), + operation: c.operation.clone(), + negate: c.negate, + value: raw + .as_str() + .ok_or_else(|| { + Error::Validation( + crate::i18n::text("err_routing_condition_value_must_be_text_233f0e") + .into(), + ) + })? + .into(), + }); + } + let policy = if f.contains_key("escalation") { + Some(id_value(value(f, "escalation", resolver)?)?) + } else { + None + }; + let working = if f.contains_key("working") { + value(f, "working", resolver)?.as_bool().ok_or_else(|| { + Error::Validation( + crate::i18n::text("err_working_must_be_true_or_false_10625c").into(), + ) + })? + } else { + false + }; + doc.routes.push(RoutingRule { + id, + name, + order: 0, + conditions, + escalation_id: policy, + window: working.then(WorkingWindow::default), + }); + } else { + return Err(Error::Validation( + crate::i18n::text("err_choose_an_output_type_4ab1b5").into(), + )); + } + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +pub fn expand( + doc: &WorkspaceDocument, + workspace: Uuid, + variables: &[Variable], + rules: &[Rule], + route_position: usize, + interval: &WorkingWindow, + members: &BTreeSet, + now: DateTime, +) -> Expansion { + let mut trace = vec![]; + let mut issues = validate_variables(variables); + if rules.len() > 50 { + issues.push(problem( + "Rules", + crate::i18n::text("err_use_at_most_50_generation_rules_abf459"), + )); + } + let mut output = doc.clone(); + let old_ids: BTreeSet<_> = doc.generation.managed.iter().map(|m| m.id).collect(); + output.schedules.retain(|s| !old_ids.contains(&s.id)); + output.escalations.retain(|s| !old_ids.contains(&s.id)); + output.routes.retain(|s| !old_ids.contains(&s.id)); + let manual_routes = output.routes.clone(); + output.routes.clear(); + let manual_ids: BTreeSet<_> = output + .schedules + .iter() + .map(|s| s.id) + .chain(output.escalations.iter().map(|s| s.id)) + .chain(manual_routes.iter().map(|s| s.id)) + .collect(); + let mut managed = vec![]; + let mut block_ids = BTreeSet::new(); + let mut rule_ids = BTreeSet::new(); + if !issues.is_empty() { + return Expansion { + trace, + document: doc.clone(), + changes: vec![], + issues, + }; + } + // Singleton outputs are shared across rules; iterated outputs stay local to their row. + let shared: BTreeMap<_, _> = rules + .iter() + .filter(|r| r.enabled && r.iterations.is_empty()) + .flat_map(|r| { + r.blocks.iter().map(move |b| { + ( + b.id, + stable_id(&json!(["generated", workspace, r.id, [], b.id])), + ) + }) + }) + .collect(); + for rule in rules { + trace.push(json!({"rule":rule.id,"enabled":rule.enabled,"stages":[],"rows":[]})); + let issue_start = issues.len(); + let path = crate::i18n::message("err_rule_p0_eb6431", &[("p0", (rule.name).to_string())]); + if !rule_ids.insert(rule.id) + || rule.name.trim().is_empty() + || rule.name.len() > 120 + || rule.iterations.len() > 4 + || rule.blocks.len() > 20 + { + issues.push(problem( + &path, + crate::i18n::text("err_use_a_unique_rule_id_name_at_most_4_iterations_a_c413b3"), + )); + continue; + } + if !rule.enabled { + continue; + } + let mut contexts = vec![Context::default()]; + let mut loop_ids = BTreeSet::new(); + for iteration in &rule.iterations { + if !loop_ids.insert(iteration.id) { + issues.push(problem( + &path, + crate::i18n::text("err_iteration_ids_must_be_unique_bfb5cc"), + )); + contexts.clear(); + break; + } + let mut next = vec![]; + for context in contexts { + let empty = BTreeMap::new(); + let resolver = Resolver { + variables, + context: &context, + outputs: &empty, + }; + match resolver.eval(&iteration.source, 0) { + Ok(node) if matches!(node.kind.as_str(), "list" | "dictionary") => { + let mut values = BTreeSet::new(); + for entry in node.entries { + let label = if node.kind == "list" { + entry + .json() + .as_str() + .map(str::to_owned) + .unwrap_or_else(|| entry.id.to_string()) + } else { + entry.key.clone() + }; + if !values.insert(label.clone()) { + issues.push(problem( + &path, + crate::i18n::message( + "err_iteration_p0_contains_duplicate_value_label_1743fe", + &[ + ("p0", (iteration.name).to_string()), + ("label", (label).to_string()), + ], + ), + )); + continue; + } + let mut child = context.clone(); + child.keys.push(entry.id); + child.labels.push(label.clone()); + let mut entry = entry; + if node.kind == "list" { + entry.key = label; + } + child.items.insert(iteration.id, entry); + next.push(child); + if next.len() > 500 { + break; + } + } + } + Ok(_) => issues.push(problem( + format!("{path} / {}", iteration.name), + crate::i18n::text("err_for_each_needs_a_list_or_dictionary_c87f69"), + )), + Err(e) => issues.push(problem( + format!( + "{path} / {} / {}", + iteration.name, + context.labels.join(" / ") + ), + e.to_string(), + )), + } + if next.len() > 500 { + break; + } + } + if next.len() > 500 { + issues.push(problem( + &path, + crate::i18n::text( + "err_expansion_is_limited_to_500_iteration_combinatio_81a6ef", + ), + )); + contexts = vec![]; + break; + } + trace.last_mut().unwrap()["stages"] + .as_array_mut() + .unwrap() + .push(json!({"iteration":iteration.id,"count":next.len()})); + contexts = next; + } + for block in &rule.blocks { + if !block_ids.insert(block.id) { + issues.push(problem( + &path, + crate::i18n::text("err_output_block_ids_must_be_unique_across_rules_1efed6"), + )); + } + } + for context in contexts { + let mut ids = shared.clone(); + ids.extend(rule.blocks.iter().map(|b| { + ( + b.id, + stable_id(&json!([ + "generated", + workspace, + rule.id, + context.keys, + b.id + ])), + ) + })); + let resolver = Resolver { + variables, + context: &context, + outputs: &ids, + }; + let row_path = if context.labels.is_empty() { + path.clone() + } else { + format!("{path} / {}", context.labels.join(" / ")) + }; + let mut row_trace = json!({"labels":context.labels,"inputs":context.items.iter().map(|(id,n)|(id.to_string(),n.json())).collect::>(),"status":"included","resources":[],"blocks":[]}); + let managed_start = managed.len(); + let row_issue_start = issues.len(); + if let Some(predicate) = &rule.when { + row_trace["condition"] = json!({"left":resolver.eval(&predicate.left,0).ok().map(|n|n.json()),"operation":predicate.operation,"right":resolver.eval(&predicate.right,0).ok().map(|n|n.json())}); + match resolver.matches(predicate) { + Ok(true) => {} + Ok(false) => { + row_trace["status"] = json!("filtered"); + trace.last_mut().unwrap()["rows"] + .as_array_mut() + .unwrap() + .push(row_trace); + continue; + } + Err(e) => { + issues.push(problem(&row_path, e.to_string())); + row_trace["status"] = json!("error"); + trace.last_mut().unwrap()["rows"] + .as_array_mut() + .unwrap() + .push(row_trace); + continue; + } + } + } + for (index, block) in rule.blocks.iter().enumerate() { + let id = ids[&block.id]; + let resolved = |binding: &Binding| { + resolver + .eval(binding, 0) + .map(|n| n.json()) + .unwrap_or(Value::Null) + }; + row_trace["blocks"].as_array_mut().unwrap().push(json!({ + "block":block.id, + "fields":block.fields.iter().map(|(key,b)|(key.clone(),resolved(b))).collect::>(), + "steps":block.steps.iter().map(|s|json!({"delay":resolved(&s.delay),"recipient":resolved(&s.recipient),"condition":s.condition})).collect::>(), + "conditions":block.conditions.iter().map(|c|json!({"field":c.field,"operation":c.operation,"value":resolved(&c.value),"negate":c.negate})).collect::>() + })); + if manual_ids.contains(&id) { + issues.push(problem( + &row_path, + crate::i18n::text( + "err_generated_identity_collides_with_a_manual_resour_4403f1", + ), + )); + continue; + } + if managed.len() >= 900 { + issues.push(problem( + &path, + crate::i18n::text("err_expansion_exceeds_the_team_resource_budget_5669f8"), + )); + break; + } + match render(block, id, workspace, &resolver, &mut output, members) { + Ok(()) => managed.push(Managed { + id, + kind: block.kind.clone(), + rule: rule.id, + block: block.id, + item: context.labels.join(" / "), + }), + Err(e) => { + let mut issue = problem( + format!("{row_path} / {} block {}", block.kind, index + 1), + e.to_string(), + ); + issue.rule_id = Some(rule.id); + issue.block_id = Some(block.id); + issues.push(issue); + } + } + } + if issues.len() > row_issue_start { + row_trace["status"] = json!("error"); + } + row_trace["resources"] = json!( + managed[managed_start..] + .iter() + .map(|m| m.id) + .collect::>() + ); + trace.last_mut().unwrap()["rows"] + .as_array_mut() + .unwrap() + .push(row_trace); + } + for issue in &mut issues[issue_start..] { + issue.rule_id = Some(rule.id); + } + } + let current_ids: BTreeSet<_> = managed.iter().map(|m| m.id).collect(); + let removed: BTreeSet<_> = old_ids.difference(¤t_ids).copied().collect(); + for request in &mut output.requests { + if !request + .legs + .iter() + .any(|l| removed.contains(&l.schedule_id)) + { + continue; + } + if request.status == RequestStatus::Applied + && request + .effective_legs() + .iter() + .any(|l| removed.contains(&l.schedule_id) && l.end > now) + { + issues.push(problem( + "Removal", + crate::i18n::text("err_a_generated_schedule_has_current_future_approved_0abed7"), + )); + } + if request.status == RequestStatus::Pending { + request.status = RequestStatus::Cancelled; + } + } + let mut generated_routes = std::mem::take(&mut output.routes); + for route in &mut generated_routes { + if route.window.is_some() { + route.window = Some(interval.clone()); + } + } + let mut routes = manual_routes; + routes.sort_by_key(|r| r.order); + let position = route_position.min(routes.len()); + routes.splice(position..position, generated_routes); + for (i, r) in routes.iter_mut().enumerate() { + r.order = i as u16; + } + output.routes = routes; + output.generation = Generation { + rules: rules.to_vec(), + variables: variables.to_vec(), + route_position: position, + managed, + }; + if let Err(e) = output.validate(members) { + let mut issue = problem( + crate::i18n::text("err_generated_configuration_61fd64"), + e.to_string(), + ); + issue.secondary = !issues.is_empty(); + issues.push(issue); + } + if issues.is_empty() + && let Err(e) = engine::timeline( + &output, + now, + now + chrono::Duration::days(i64::from(output.horizon_days)), + ) + { + issues.push(problem( + crate::i18n::text("err_generated_calendar_5e4edc"), + e.to_string(), + )); + } + let before = resources(doc); + let after = resources(&output); + let mut changes = vec![]; + for m in &output.generation.managed { + let Some((name, value)) = after.get(&m.id) else { + continue; + }; + let order_changed = m.kind == "route" + && doc.routes.iter().find(|r| r.id == m.id).map(|r| r.order) + != output.routes.iter().find(|r| r.id == m.id).map(|r| r.order); + let action = match before.get(&m.id) { + None => "create", + Some((_, old)) if old == value && !order_changed => "unchanged", + _ => "update", + }; + changes.push(Change { + kind: m.kind.clone(), + id: m.id, + name: name.clone(), + action: action.into(), + source: m.item.clone(), + }); + } + for m in &doc.generation.managed { + if removed.contains(&m.id) { + changes.push(Change { + kind: m.kind.clone(), + id: m.id, + name: before + .get(&m.id) + .map(|(n, _)| n.clone()) + .unwrap_or_default(), + action: "remove".into(), + source: m.item.clone(), + }); + } + } + Expansion { + trace, + document: output, + changes, + issues, + } +} + +fn resources(doc: &WorkspaceDocument) -> BTreeMap { + let mut map = BTreeMap::new(); + for s in &doc.schedules { + map.insert( + s.id, + ( + s.name.clone(), + serde_json::to_value(s).expect("serializable schedule"), + ), + ); + } + for s in &doc.escalations { + map.insert( + s.id, + ( + s.name.clone(), + serde_json::to_value(s).expect("serializable policy"), + ), + ); + } + for s in &doc.routes { + let mut value = serde_json::to_value(s).expect("serializable route"); + value.as_object_mut().unwrap().remove("order"); + map.insert(s.id, (s.name.clone(), value)); + } + map +} +pub fn validate_manual_edit( + before: &WorkspaceDocument, + after: &mut WorkspaceDocument, +) -> Result<()> { + ensure( + serde_json::to_value(&before.generation)? == serde_json::to_value(&after.generation)?, + crate::i18n::text("err_edit_generation_sources_in_variables_rules_8ec0b0"), + )?; + let old = resources(before); + let new = resources(after); + ensure( + before + .generation + .managed + .iter() + .all(|m| old.get(&m.id) == new.get(&m.id)), + crate::i18n::text("err_this_resource_is_generated_edit_its_source_in_va_b45498"), + )?; + validate_route_block(after) +} +pub fn validate_route_block(doc: &mut WorkspaceDocument) -> Result<()> { + let expected: Vec<_> = doc + .generation + .managed + .iter() + .filter(|m| m.kind == "route") + .map(|m| m.id) + .collect(); + if expected.is_empty() { + return Ok(()); + } + let mut sorted = doc.routes.clone(); + sorted.sort_by_key(|r| r.order); + let start = sorted + .iter() + .position(|r| r.id == expected[0]) + .ok_or_else(|| { + Error::Validation( + crate::i18n::text("err_a_generated_routing_rule_is_missing_df7dc4").into(), + ) + })?; + ensure( + sorted + .iter() + .skip(start) + .take(expected.len()) + .map(|r| r.id) + .collect::>() + == expected, + crate::i18n::text("err_generated_routing_rules_move_as_one_block_set_th_d09425"), + )?; + doc.generation.route_position = start; + Ok(()) +} diff --git a/src/generation_service.rs b/src/generation_service.rs new file mode 100644 index 0000000..59768b7 --- /dev/null +++ b/src/generation_service.rs @@ -0,0 +1,163 @@ +//! Read-only expansion and atomic activation for one workspace. +use crate::{ + domain::*, + engine, + error::{Result, ensure}, + generation::{self, Issue, Rule, Variable}, + providers, + store::{Store, WorkspaceRecord}, +}; +use chrono::{Duration, Utc}; +use serde::Deserialize; +use serde_json::{Value, json}; +use uuid::Uuid; +#[derive(Debug, Clone, Deserialize)] +pub struct Draft { + pub revision: i64, + pub variables: Vec, + pub rules: Vec, + #[serde(default)] + pub route_position: usize, + #[serde(default)] + pub review_id: Option, +} +struct Prepared { + report: Value, + record: WorkspaceRecord, + issues: Vec, + review: String, +} +async fn prepare(db: &Store, actor: Uuid, workspace: Uuid, draft: &Draft) -> Result { + let mut record = db + .prepare(workspace, draft.revision, actor, Permission::EditSchedules) + .await?; + let directory = db.directory(workspace).await?; + let members = directory.iter().map(|u| u.id).collect(); + let now = Utc::now(); + let mut expanded = generation::expand( + &record.document, + workspace, + &draft.variables, + &draft.rules, + draft.route_position, + &record.document.working_interval, + &members, + now, + ); + let used: std::collections::BTreeSet<_> = expanded + .document + .schedules + .iter() + .flat_map(|s| match &s.kind { + ScheduleKind::Rotation { participants, .. } + | ScheduleKind::Priority { participants, .. } => participants.clone(), + }) + .chain(expanded.document.escalations.iter().flat_map(|e| { + e.steps.iter().filter_map(|s| match s.recipient { + Recipient::User { id } => Some(id), + _ => None, + }) + })) + .collect(); + for user in &directory { + if used.contains(&user.id) && expanded.document.person(user.id).is_none() { + expanded.document.people.push(Person { + id: user.id, + user_id: Some(user.id), + display_name: user.display_name.clone(), + email: None, + country: Some(user.country.clone()), + }); + } + } + if expanded.issues.is_empty() { + if let Err(e) = expanded.document.validate(&members) { + expanded.issues.push(Issue { + path: "People".into(), + message: e.to_string(), + ..Issue::default() + }); + } + if let Err(e) = providers::validate_capabilities(&expanded.document, now) { + expanded.issues.push(Issue { + path: "Provider".into(), + message: e.to_string(), + ..Issue::default() + }); + } + // Both shared and manually configured calendars must remain valid under DST. + if let Err(e) = engine::timeline( + &expanded.document, + now, + now + Duration::days(i64::from(expanded.document.horizon_days)), + ) { + expanded.issues.push(Issue { + path: "Calendar".into(), + message: e.to_string(), + ..Issue::default() + }); + } + } + + let issues = expanded.issues; + let cancelled = record + .document + .requests + .iter() + .filter(|r| { + r.status == RequestStatus::Pending + && expanded + .document + .requests + .iter() + .any(|n| n.id == r.id && n.status == RequestStatus::Cancelled) + }) + .count(); + let result=issues.is_empty().then(||{let d=&expanded.document;json!({"timezone":d.timezone,"people":d.people,"schedules":d.schedules,"escalations":d.escalations,"routes":d.routes,"managed":d.generation.managed})}); + let review = generation::stable_id(&json!([ + workspace, + draft.revision, + draft.variables, + draft.rules, + draft.route_position + ])) + .to_string(); + let report = json!({"review_id":review,"changes":expanded.changes,"cancelled_requests":cancelled,"result":result,"trace":expanded.trace,"issues":issues}); + record.document = expanded.document; + Ok(Prepared { + report, + record, + issues, + review, + }) +} +pub async fn preview(db: &Store, actor: Uuid, workspace: Uuid, draft: Draft) -> Result { + Ok(prepare(db, actor, workspace, &draft).await?.report) +} +pub async fn apply(db: &Store, actor: Uuid, workspace: Uuid, draft: Draft) -> Result { + let mut prepared = prepare(db, actor, workspace, &draft).await?; + ensure( + prepared.issues.is_empty(), + prepared + .issues + .iter() + .take(8) + .map(|i| format!("{}: {}", i.path, i.message)) + .collect::>() + .join("; "), + )?; + ensure( + draft.review_id.as_deref() == Some(prepared.review.as_str()), + crate::i18n::text("err_the_preview_is_stale_check_the_updated_impact_be_85cacd"), + )?; + db.commit( + &mut prepared.record, + actor, + Permission::EditSchedules, + "generation.applied", + prepared.report, + None, + ) + .await?; + Ok(json!({"ok":true})) +} diff --git a/src/holidays.rs b/src/holidays.rs new file mode 100644 index 0000000..328169b --- /dev/null +++ b/src/holidays.rs @@ -0,0 +1,179 @@ +//! Pure RFC 4180 CSV parsing and comparison. Preview never changes state. +use crate::{ + domain::{Holiday, valid_country}, + error::{Result, ensure}, +}; +use chrono::{Datelike, NaiveDate}; +use serde::Serialize; +use std::collections::BTreeMap; + +#[derive(Debug, Serialize)] +pub struct ImportPreview { + pub additions: Vec, + pub existing: usize, + pub duplicates: usize, + pub conflicts: Vec, + pub errors: Vec, +} + +pub fn compare(input: &str, saved: &[Holiday]) -> Result { + ensure( + input.len() <= 262_144, + crate::i18n::text("err_csv_is_limited_to_256_kib_e30936"), + )?; + let mut preview = ImportPreview { + additions: vec![], + existing: 0, + duplicates: 0, + conflicts: vec![], + errors: vec![], + }; + let mut reader = csv::ReaderBuilder::new() + .trim(csv::Trim::All) + .from_reader(input.trim_start_matches('\u{feff}').as_bytes()); + let headers = match reader.headers() { + Ok(h) => h.iter().map(str::to_ascii_lowercase).collect::>(), + Err(_) => { + preview + .errors + .push(crate::i18n::text("err_could_not_read_csv_headers_2bbd8d").into()); + return Ok(preview); + } + }; + let positions = ["country", "date", "name"].map(|key| headers.iter().position(|h| h == key)); + let [Some(country), Some(date), Some(name)] = positions else { + preview + .errors + .push(crate::i18n::text("err_use_the_csv_headers_country_date_name_7d9639").into()); + return Ok(preview); + }; + if headers.len() != 3 { + preview.errors.push( + crate::i18n::text("err_csv_must_have_exactly_three_columns_country_date_723e07").into(), + ); + return Ok(preview); + } + let existing: BTreeMap<_, _> = saved + .iter() + .map(|h| ((h.country.clone(), h.date), h.name.clone())) + .collect(); + let mut seen = BTreeMap::new(); + for (index, row) in reader.records().enumerate() { + if index >= 2000 { + preview + .errors + .push(crate::i18n::text("err_import_is_limited_to_2_000_rows_6e1c86").into()); + break; + } + let line = index + 2; + let row = match row { + Ok(row) => row, + Err(_) => { + preview.errors.push(crate::i18n::message( + "err_row_line_invalid_csv_or_column_count_be7745", + &[("line", (line).to_string())], + )); + continue; + } + }; + let country = row[country].to_ascii_uppercase(); + let parsed = ["%Y-%m-%d", "%d-%m-%Y"].into_iter().find_map(|format| { + NaiveDate::parse_from_str(&row[date], format) + .ok() + .filter(|d| { + (2000..=2100).contains(&d.year()) && d.format(format).to_string() == row[date] + }) + }); + if !valid_country(&country) + || parsed.is_none() + || row[name].is_empty() + || row[name].len() > 120 + { + preview.errors.push(crate::i18n::message( + "err_row_line_use_an_iso_country_code_dd_mm_yyyy_or_y_bf011e", + &[("line", (line).to_string())], + )); + continue; + } + let holiday = Holiday { + country, + date: parsed.unwrap(), + name: row[name].into(), + }; + let key = (holiday.country.clone(), holiday.date); + if let Some(previous) = seen.insert(key.clone(), holiday.name.clone()) { + if previous == holiday.name { + preview.duplicates += 1; + } else { + preview.errors.push(crate::i18n::message( + "err_row_line_two_different_names_for_p0_on_p1_4c127b", + &[ + ("line", (line).to_string()), + ("p0", (holiday.country).to_string()), + ("p1", (holiday.date.format("%d-%m-%Y")).to_string()), + ], + )); + } + continue; + } + if let Some(name) = existing.get(&key) { + if name == &holiday.name { + preview.existing += 1; + } else { + preview.conflicts.push(crate::i18n::message( + "err__p0_p1_existing_p2_is_kept_p3_will_not_replace_i_053cc0", + &[ + ("p0", (holiday.country).to_string()), + ("p1", (holiday.date.format("%d-%m-%Y")).to_string()), + ("p2", (name).to_string()), + ("p3", (holiday.name).to_string()), + ], + )); + } + } else { + preview.additions.push(holiday); + } + } + if saved.len() + preview.additions.len() > 2000 { + preview + .errors + .push(crate::i18n::text("err_the_team_would_exceed_2_000_holidays_496996").into()); + } + preview + .additions + .sort_by(|a, b| (&a.country, a.date).cmp(&(&b.country, b.date))); + Ok(preview) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn csv_handles_bom_quotes_countries_duplicates_and_preserves_existing_names() { + let saved = vec![Holiday { + country: "CY".into(), + date: "2026-01-01".parse().unwrap(), + name: "New Year".into(), + }]; + let p=compare("\u{feff}date,name,country\r\n2026-01-01,New Year,CY\r\n2026-01-01,\"New Year, holiday\",am\r\n2026-01-01,\"New Year, holiday\",AM\r\n2026-12-25,Christmas,CY",&saved).unwrap(); + assert!(p.errors.is_empty()); + assert_eq!(p.existing, 1); + assert_eq!(p.duplicates, 1); + assert_eq!(p.additions.len(), 2); + let p = compare("country,date,name\nCY,2026-01-01,Changed", &saved).unwrap(); + assert!(p.additions.is_empty()); + assert_eq!(p.conflicts.len(), 1); + assert_eq!(saved[0].name, "New Year"); + } + #[test] + fn invalid_rows_and_conflicting_input_duplicates_block_import() { + let p=compare("country,date,name\nXX,2026-01-01,Invalid country\nCY,2026-02-30,Invalid date\nAM,2026-01-01,One\nAM,2026-01-01,Two\nCY,2026-01-01",&[]).unwrap(); + assert_eq!(p.errors.len(), 4); + assert!( + !compare("date,label\n2026-01-01,Holiday", &[]) + .unwrap() + .errors + .is_empty() + ); + } +} diff --git a/src/i18n.rs b/src/i18n.rs new file mode 100644 index 0000000..55f02bd --- /dev/null +++ b/src/i18n.rs @@ -0,0 +1,71 @@ +//! One in-memory catalog shared with the browser. Domain messages retain stable English +//! diagnostics for API compatibility; presentation uses the same catalog and parameters. +use serde::Deserialize; +use std::{collections::BTreeMap, sync::LazyLock}; + +#[derive(Deserialize)] +pub struct Message { + pub en: String, + pub ru: Option, + pub fr: Option, +} +pub static MESSAGES: LazyLock> = LazyLock::new(|| { + serde_json::from_str(include_str!("../static/locales/messages.json")) + .expect("the bundled localization catalog must be valid") +}); +pub fn text(key: &str) -> &'static str { + &MESSAGES.get(key).expect("localization key must exist").en +} +pub fn message(key: &str, parameters: &[(&str, String)]) -> String { + let pattern = text(key); + let mut output = String::new(); + let mut remaining = pattern; + while let Some(start) = remaining.find('{') { + output.push_str(&remaining[..start]); + let tail = &remaining[start + 1..]; + let Some(end) = tail.find('}') else { + output.push_str(&remaining[start..]); + return output; + }; + let name = &tail[..end]; + if let Some((_, value)) = parameters.iter().find(|(key, _)| *key == name) { + output.push_str(value); + } else { + output.push_str(&remaining[start..start + end + 2]); + } + remaining = &tail[end + 1..]; + } + output.push_str(remaining); + output +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn catalog_is_complete_and_parameters_are_not_reinterpreted() { + for (key, value) in MESSAGES.iter() { + assert!(!value.en.is_empty(), "{key}: English missing"); + assert!( + value.ru.as_ref().is_some_and(|s| !s.is_empty()), + "{key}: Russian missing" + ); + assert!( + value.fr.as_ref().is_some_and(|s| !s.is_empty()), + "{key}: French missing" + ); + } + let output = message( + "publication_absent_detail", + &[ + ("person", "{schedule} + + + + + + + +

+ + + +
+
+
+ +
+ + +
+
+ +
+
+ + + + + + +
+ + + +
+
+
+ + +
+
+
+
+
+ + + + diff --git a/static/locales/messages.json b/static/locales/messages.json new file mode 100644 index 0000000..128d849 --- /dev/null +++ b/static/locales/messages.json @@ -0,0 +1,5957 @@ +{ + "meguri_on_call_together_f7894c": { + "en": "Meguri · On-call together", + "ru": "Meguri · Дежурим вместе", + "fr": "Meguri · L’astreinte en équipe" + }, + "demo_data_retention_e272aa": { + "en": "Demo data retention", + "ru": "Хранение демоданных", + "fr": "Conservation des données de démonstration" + }, + "demo_temporary_data_8112dd": { + "en": "DEMO · temporary data", + "ru": "ДЕМО · временные данные", + "fr": "DÉMO · données temporaires" + }, + "teams_workspaces_332acb": { + "en": "Workspaces: ", + "ru": "Пространства: ", + "fr": "Espaces : " + }, + "requests_0e1805": { + "en": " · requests: ", + "ru": " · запросы: ", + "fr": " · demandes : " + }, + "visitor_accounts_f4ac15": { + "en": " · visitor accounts: ", + "ru": " · аккаунты посетителей: ", + "fr": " · comptes visiteurs : " + }, + "restart_resets_demo_data_examples_refresh_automatica_0082d1": { + "en": "Restart resets demo data. Examples refresh automatically.", + "ru": "Перезапуск очищает демоданные. Примеры обновляются автоматически.", + "fr": "Le redémarrage réinitialise les données. Les exemples sont renouvelés automatiquement." + }, + "protected_database_isolated_demo_active_2e4ba8": { + "en": "Protected database — isolated demo active.", + "ru": "База защищена — демо работает отдельно.", + "fr": "Base protégée — démonstration isolée active." + }, + "the_configured_database_was_not_verified_as_demo_and_c1a54e": { + "en": "The configured database was not verified as demo and was left untouched. Administrator: review DATABASE_URL and the demo-mode setting.", + "ru": "Указанная база не подтверждена как демонстрационная и не изменялась. Администратору: проверьте DATABASE_URL и настройку деморежима.", + "fr": "La base configurée n’a pas été reconnue comme base de démonstration et reste intacte. Administration : vérifiez DATABASE_URL et le mode démo." + }, + "loading_meguri_856b9a": { + "en": "Loading Meguri…", + "ru": "Загрузка Meguri…", + "fr": "Chargement de Meguri…" + }, + "dismiss_error_347aaa": { + "en": "Dismiss error", + "ru": "Скрыть ошибку", + "fr": "Fermer l’erreur" + }, + "symbol_67fba2": { + "en": "×", + "ru": "×", + "fr": "×" + }, + "symbol_4d82f1": { + "en": "◒", + "ru": "◒", + "fr": "◒" + }, + "meguri_d160e8": { + "en": "meguri", + "ru": "meguri", + "fr": "meguri" + }, + "on_call_in_good_hands_f1cf2d": { + "en": "ON-CALL, IN GOOD HANDS", + "ru": "ДЕЖУРСТВА ПОД КОНТРОЛЕМ", + "fr": "L’ASTREINTE EN BONNES MAINS" + }, + "a_clear_rhythm_c58fae": { + "en": "A clear rhythm.", + "ru": "Понятный ритм.", + "fr": "Un rythme clair." + }, + "a_shared_responsibility_2c5e48": { + "en": "A shared responsibility.", + "ru": "Общая ответственность.", + "fr": "Une responsabilité partagée." + }, + "one_calendar_for_your_team_thoughtful_rotations_simp_52aff3": { + "en": "One calendar for your team. Thoughtful rotations, simple swaps, and a schedule you can trust.", + "ru": "Общий календарь команды. Удобные ротации, простые подмены и надёжное расписание.", + "fr": "Un calendrier commun. Des rotations adaptées, des échanges simples et un planning fiable." + }, + "plan_here_stay_in_sync_everywhere_70e528": { + "en": "Plan here. Stay in sync everywhere.", + "ru": "Планируйте здесь. Синхронизируйте везде.", + "fr": "Planifiez ici. Restez synchronisés partout." + }, + "your_team_s_home_for_on_call_66a895": { + "en": "YOUR TEAM’S HOME FOR ON-CALL", + "ru": "ДЕЖУРСТВА ВАШЕЙ КОМАНДЫ", + "fr": "LES ASTREINTES DE VOTRE ÉQUIPE" + }, + "create_your_account_4046b5": { + "en": "Create your account", + "ru": "Создайте аккаунт", + "fr": "Créez votre compte" + }, + "welcome_back_b80783": { + "en": "Welcome back", + "ru": "С возвращением", + "fr": "Bon retour" + }, + "make_space_for_a_smoother_handover_44adaf": { + "en": "Make space for a smoother handover.", + "ru": "Передавайте смену без лишних хлопот.", + "fr": "Passez le relais simplement." + }, + "username_84c290": { + "en": "Username", + "ru": "Имя пользователя", + "fr": "Identifiant" + }, + "display_name_c7874a": { + "en": "Display name", + "ru": "Отображаемое имя", + "fr": "Nom affiché" + }, + "password_8be3c9": { + "en": "Password", + "ru": "Пароль", + "fr": "Mot de passe" + }, + "create_account_aaf374": { + "en": "Create account", + "ru": "Создать аккаунт", + "fr": "Créer un compte" + }, + "sign_in_ada2e9": { + "en": "Sign in", + "ru": "Войти", + "fr": "Se connecter" + }, + "already_registered_sign_in_0df05c": { + "en": "Already registered? Sign in", + "ru": "Уже зарегистрированы? Войти", + "fr": "Déjà inscrit ? Se connecter" + }, + "new_here_create_an_account_2b2c3f": { + "en": "New here? Create an account", + "ru": "Впервые здесь? Создать аккаунт", + "fr": "Nouveau ? Créer un compte" + }, + "try_the_demo_38cfaf": { + "en": "Try the demo", + "ru": "Попробуйте демо", + "fr": "Essayez la démonstration" + }, + "alice_522b27": { + "en": "alice", + "ru": "alice", + "fr": "alice" + }, + "lead_both_workspaces_variables_rules_ed9a19": { + "en": "Lead · all four workspaces, variables & rules", + "ru": "Тимлид · все четыре пространства, переменные и правила", + "fr": "Responsable · les quatre espaces, variables et règles" + }, + "bob_48181a": { + "en": "bob", + "ru": "bob", + "fr": "bob" + }, + "engineering_weekly_on_call_swaps_de49e8": { + "en": "Engineering & regions · rotations and swaps", + "ru": "Разработка и регионы · ротации и подмены", + "fr": "Ingénierie et régions · rotations et échanges" + }, + "carol_28b92b": { + "en": "carol", + "ru": "carol", + "fr": "carol" + }, + "support_daily_shifts_approvals_c9a0d8": { + "en": "Support & commerce · shifts and service tiers", + "ru": "Поддержка и коммерция · смены и уровни обслуживания", + "fr": "Assistance et commerce · rotations et niveaux de service" + }, + "password_for_all_2f1a67": { + "en": "Password for all:", + "ru": "Общий пароль:", + "fr": "Mot de passe commun :" + }, + "meguri_demo_only_0c7b5d": { + "en": "meguri-demo-only", + "ru": "meguri-demo-only", + "fr": "meguri-demo-only" + }, + "workspace_703988": { + "en": "WORKSPACE", + "ru": "ПРОСТРАНСТВО", + "fr": "ESPACE" + }, + "team_218887": { + "en": "Workspace", + "ru": "Пространство", + "fr": "Espace" + }, + "choose_a_team_1853ee": { + "en": "Choose a workspace", + "ru": "Выберите пространство", + "fr": "Choisissez un espace" + }, + "main_navigation_efd197": { + "en": "Main navigation", + "ru": "Основная навигация", + "fr": "Navigation principale" + }, + "setup_cdd7bb": { + "en": "Setup", + "ru": "Настройка", + "fr": "Configuration" + }, + "symbol_be22a3": { + "en": "⚙", + "ru": "⚙", + "fr": "⚙" + }, + "sign_out_dc1649": { + "en": "Sign out", + "ru": "Выйти", + "fr": "Se déconnecter" + }, + "choose_team_9c6970": { + "en": "Choose workspace", + "ru": "Выбрать пространство", + "fr": "Choisir un espace" + }, + "workspace_4ca0a7": { + "en": "Workspace", + "ru": "Пространство", + "fr": "Espace" + }, + "symbol_42099b": { + "en": "/", + "ru": "/", + "fr": "/" + }, + "getting_started_76e7a3": { + "en": "Getting started", + "ru": "Начало работы", + "fr": "Premiers pas" + }, + "your_time_31bdbf": { + "en": "Your time: ", + "ru": "Ваш часовой пояс: ", + "fr": "Votre fuseau : " + }, + "new_workspace_b21b86": { + "en": "New workspace", + "ru": "Новое пространство", + "fr": "Nouvel espace" + }, + "your_team_s_next_chapter_starts_here_4e986d": { + "en": "Your team’s next chapter starts here.", + "ru": "Начните планировать дежурства команды.", + "fr": "Organisez les prochaines astreintes de votre équipe." + }, + "create_a_workspace_add_a_team_and_give_your_on_call__af9412": { + "en": "Create a workspace, invite colleagues and set up your schedules.", + "ru": "Создайте пространство, пригласите коллег и настройте расписания.", + "fr": "Créez un espace, invitez vos collègues et configurez les plannings." + }, + "create_a_workspace_e94c64": { + "en": "Create a workspace", + "ru": "Создать пространство", + "fr": "Créer un espace" + }, + "team_setup_504738": { + "en": "WORKSPACE SETUP", + "ru": "НАСТРОЙКИ ПРОСТРАНСТВА", + "fr": "CONFIGURATION DE L’ESPACE" + }, + "your_team_303d2a": { + "en": "YOUR WORKSPACE", + "ru": "ВАШЕ ПРОСТРАНСТВО", + "fr": "VOTRE ESPACE" + }, + "plan_time_off_33c0fc": { + "en": "Plan time off", + "ru": "Запланировать отсутствие", + "fr": "Planifier une absence" + }, + "ask_for_cover_53c1cd": { + "en": "Ask for cover", + "ru": "Попросить подмену", + "fr": "Demander un remplacement" + }, + "schedule_is_in_sync_be394a": { + "en": "Schedule is in sync", + "ru": "Расписание синхронизировано", + "fr": "Planning synchronisé" + }, + "publication_fe5f07": { + "en": "Publication: ", + "ru": "Публикация: ", + "fr": "Publication : " + }, + "local_preview_provider_aca82c": { + "en": "Local preview provider", + "ru": "Локальный предпросмотр", + "fr": "Aperçu local" + }, + "jira_service_management_071f90": { + "en": "Jira Service Management", + "ru": "Jira Service Management", + "fr": "Jira Service Management" + }, + "sync_now_2b7d93": { + "en": "Sync now", + "ru": "Синхронизировать", + "fr": "Synchroniser" + }, + "on_call_now_0ec724": { + "en": "ON CALL NOW ·", + "ru": "СЕЙЧАС ДЕЖУРИТ ·", + "fr": "D’ASTREINTE ·" + }, + "no_coverage_252d1f": { + "en": "No coverage", + "ru": "Нет дежурного", + "fr": "Aucune couverture" + }, + "until_24bf03": { + "en": "Until ", + "ru": "До ", + "fr": "Jusqu’au " + }, + "choose_a_schedule_below_dea8d5": { + "en": "Choose a schedule below", + "ru": "Выберите расписание ниже", + "fr": "Choisissez un planning ci-dessous" + }, + "your_next_shift_next_4_weeks_0e4beb": { + "en": "YOUR NEXT SHIFT · NEXT 4 WEEKS", + "ru": "ВАША БЛИЖАЙШАЯ СМЕНА · ЗА 4 НЕДЕЛИ", + "fr": "VOTRE PROCHAINE ASTREINTE · SUR 4 SEMAINES" + }, + "no_shifts_in_this_view_e878f4": { + "en": "No shifts in this view", + "ru": "В этом периоде смен нет", + "fr": "Aucune astreinte sur cette période" + }, + "enjoy_your_time_off_44297f": { + "en": "Enjoy your time off.", + "ru": "Хорошего отдыха.", + "fr": "Profitez de votre temps libre." + }, + "needs_your_reply_9e26e0": { + "en": "Needs your reply", + "ru": "Ждут вашего ответа", + "fr": "Votre réponse est attendue" + }, + "all_requests_63246c": { + "en": "All requests →", + "ru": "Все запросы →", + "fr": "Toutes les demandes →" + }, + "decline_b59cf9": { + "en": "Decline", + "ru": "Отклонить", + "fr": "Refuser" + }, + "accept_bb54db": { + "en": "Accept", + "ru": "Принять", + "fr": "Accepter" + }, + "request_5e8425": { + "en": " request", + "ru": " запрос", + "fr": " demande" + }, + "waiting_for_a_reply_529d98": { + "en": " waiting for a reply", + "ru": " ждут ответа", + "fr": " en attente de réponse" + }, + "view_cf3dbd": { + "en": "View →", + "ru": "Посмотреть →", + "fr": "Voir →" + }, + "select_a_day_to_arrange_cover_510f60": { + "en": "Select a day to arrange cover", + "ru": "Выберите день для подмены", + "fr": "Sélectionnez un jour à remplacer" + }, + "choose_schedule_33049c": { + "en": "Choose schedule", + "ru": "Выбрать расписание", + "fr": "Choisir un planning" + }, + "symbol_00d74e": { + "en": "⌄", + "ru": "⌄", + "fr": "⌄" + }, + "search_schedules_235648": { + "en": "Search schedules", + "ru": "Поиск расписаний", + "fr": "Rechercher un planning" + }, + "search_schedules_85f641": { + "en": "Search schedules…", + "ru": "Найти расписание…", + "fr": "Rechercher un planning…" + }, + "schedule_0a8ada": { + "en": "Schedule", + "ru": "Расписание", + "fr": "Planning" + }, + "previous_four_weeks_37f96a": { + "en": "Previous four weeks", + "ru": "Предыдущие четыре недели", + "fr": "Quatre semaines précédentes" + }, + "symbol_0dccf9": { + "en": "‹", + "ru": "‹", + "fr": "‹" + }, + "today_24345a": { + "en": "Today", + "ru": "Сегодня", + "fr": "Aujourd’hui" + }, + "next_four_weeks_08a1b8": { + "en": "Next four weeks", + "ru": "Следующие четыре недели", + "fr": "Quatre semaines suivantes" + }, + "symbol_60ecbc": { + "en": "›", + "ru": "›", + "fr": "›" + }, + "uncovered_77617b": { + "en": "Uncovered · ", + "ru": "Нет подмены · ", + "fr": "Non couvert · " + }, + "off_e362e3": { + "en": " off", + "ru": " отсутствует", + "fr": " absent" + }, + "symbol_0380cf": { + "en": "⇄", + "ru": "⇄", + "fr": "⇄" + }, + "no_available_replacement_publication_is_blocked_144e92": { + "en": "No available replacement; publication is blocked", + "ru": "Нет доступной подмены; публикация заблокирована", + "fr": "Aucun remplaçant disponible ; publication bloquée" + }, + "symbol_0ab831": { + "en": "!", + "ru": "!", + "fr": "!" + }, + "times_shown_in_1f96a9": { + "en": "Times shown in", + "ru": "Часовой пояс:", + "fr": "Fuseau horaire :" + }, + "approved_change_coverage_needs_attention_fc97bf": { + "en": "⇄ Approved change   ·   ! Coverage needs attention", + "ru": "⇄ Подмена подтверждена   ·   ! Нужен дежурный", + "fr": "⇄ Remplacement confirmé   ·   ! Couverture à vérifier" + }, + "your_team_80b301": { + "en": "Members", + "ru": "Участники", + "fr": "Membres" + }, + "manage_team_68a698": { + "en": "Manage members →", + "ru": "Управление участниками →", + "fr": "Gérer les membres →" + }, + "you_8af56d": { + "en": "you", + "ru": "вы", + "fr": "vous" + }, + "replacements_take_effect_after_everyone_involved_con_a63d3a": { + "en": "Replacements take effect after everyone involved confirms.", + "ru": "Подмена применяется после подтверждения всеми участниками.", + "fr": "Le remplacement prend effet après l’accord de tous les participants." + }, + "new_request_8a3714": { + "en": "New request", + "ru": "Новый запрос", + "fr": "Nouvelle demande" + }, + "nothing_waiting_on_a_handover_1a3b46": { + "en": "Nothing waiting on a handover.", + "ru": "Запросов на подмену пока нет.", + "fr": "Aucune demande en attente." + }, + "your_team_s_replacements_and_swaps_will_appear_here_ee9501": { + "en": "Your team’s replacements and swaps will appear here.", + "ru": "Здесь появятся подмены и обмены сменами вашей команды.", + "fr": "Les remplacements et échanges de votre équipe apparaîtront ici." + }, + "created_05e8fd": { + "en": "Created ", + "ru": "Создано ", + "fr": "Créé le " + }, + "waiting_for_f91441": { + "en": "Waiting for ", + "ru": "Ожидается ответ: ", + "fr": "En attente de " + }, + "applied_without_confirmation_f694d2": { + "en": "Applied without confirmation: ", + "ru": "Применено без согласования: ", + "fr": "Appliqué sans validation : " + }, + "cancel_request_84837a": { + "en": "Cancel request", + "ru": "Отменить запрос", + "fr": "Annuler la demande" + }, + "apply_without_confirmation_f0350d": { + "en": "Apply without confirmation", + "ru": "Применить без согласования", + "fr": "Appliquer sans validation" + }, + "rotation_rules_5fc430": { + "en": "Rotation rules", + "ru": "Правила ротации", + "fr": "Règles de rotation" + }, + "project_coverage_6d54f7": { + "en": "+ First available", + "ru": "+ Первый доступный", + "fr": "+ Premier disponible" + }, + "rotation_b83874": { + "en": "+ Rotation", + "ru": "+ Ротация", + "fr": "+ Rotation" + }, + "ordered_rotation_8e0214": { + "en": "ORDERED ROTATION", + "ru": "ПОСЛЕДОВАТЕЛЬНАЯ РОТАЦИЯ", + "fr": "ROTATION ORDONNÉE" + }, + "project_coverage_7b1767": { + "en": "First available", + "ru": "Первый доступный", + "fr": "Premier disponible" + }, + "every_9dd38c": { + "en": "Every ", + "ru": "Период: ", + "fr": "Tous les " + }, + "days_from_490892": { + "en": " days · from ", + "ru": " дн. · с ", + "fr": " jours · depuis le " + }, + "at_7df702": { + "en": " at ", + "ru": " в ", + "fr": " à " + }, + "a_fixed_recipient_for_a_later_escalation_step_absenc_5e5415": { + "en": "One person, without rotation. Arrange a replacement when they are away.", + "ru": "Один ответственный без ротации. На время отсутствия назначьте подмену.", + "fr": "Une personne, sans rotation. Prévoyez un remplacement pendant ses absences." + }, + "if_earlier_owners_are_off_1e3f8c": { + "en": "If earlier owners are off", + "ru": "Если предыдущие отсутствуют", + "fr": "Si les précédents sont absents" + }, + "primary_a9a96e": { + "en": "Primary", + "ru": "Основной", + "fr": "Principal" + }, + "if_everyone_is_off_e30f82": { + "en": "↓ if everyone is off", + "ru": "↓ если все отсутствуют", + "fr": "↓ si tout le monde est absent" + }, + "effective_fallback_1c5666": { + "en": "Effective fallback", + "ru": "Дежурный по резервному расписанию", + "fr": "Relais effectif" + }, + "1_356a19": { + "en": "1", + "ru": "1", + "fr": "1" + }, + "if_off_in_their_country_or_absent_32eb36": { + "en": "↓ if off in their country or absent", + "ru": "↓ если праздник в стране или отсутствие", + "fr": "↓ en cas d’absence ou de jour férié local" + }, + "2_da4b92": { + "en": "2", + "ru": "2", + "fr": "2" + }, + "secondary_025de5": { + "en": "Secondary", + "ru": "Второй", + "fr": "Secondaire" + }, + "if_both_off_e97563": { + "en": "↓ if both off", + "ru": "↓ если оба отсутствуют", + "fr": "↓ si les deux sont absents" + }, + "3_77de68": { + "en": "3", + "ru": "3", + "fr": "3" + }, + "edit_530164": { + "en": "Edit", + "ru": "Изменить", + "fr": "Modifier" + }, + "edit_source_rule_0d3b78": { + "en": "Edit source rule", + "ru": "Изменить исходное правило", + "fr": "Modifier la règle source" + }, + "remove_e96390": { + "en": "Remove", + "ru": "Удалить", + "fr": "Supprimer" + }, + "absences_0f9ec2": { + "en": "Absences", + "ru": "Отсутствия", + "fr": "Absences" + }, + "absence_0e0616": { + "en": "+ Absence", + "ru": "+ Отсутствие", + "fr": "+ Absence" + }, + "no_absences_planned_224ab7": { + "en": "No absences planned.", + "ru": "Отсутствия не запланированы.", + "fr": "Aucune absence prévue." + }, + "inclusive_75b495": { + "en": " (inclusive)", + "ru": " (включительно)", + "fr": " (inclus)" + }, + "public_holidays_badf9f": { + "en": "Public holidays", + "ru": "Государственные праздники", + "fr": "Jours fériés" + }, + "import_csv_eeec98": { + "en": "Import CSV", + "ru": "Импорт CSV", + "fr": "Importer un CSV" + }, + "holiday_85f1a0": { + "en": "+ Holiday", + "ru": "+ Праздник", + "fr": "+ Jour férié" + }, + "no_holidays_yet_import_dates_for_the_countries_your__c8ed79": { + "en": "No holidays yet. Import dates for the countries your team works in.", + "ru": "Праздников пока нет. Импортируйте даты для стран вашей команды.", + "fr": "Aucun jour férié. Importez les dates pour les pays de votre équipe." + }, + "holidays_9db6c2": { + "en": " holidays", + "ru": " праздников", + "fr": " jours fériés" + }, + "country_calendar_date_cc65d7": { + "en": " · country calendar date", + "ru": " · дата по календарю страны", + "fr": " · date du calendrier national" + }, + "meguri_manages_these_rules_your_connected_provider_s_bc7ae1": { + "en": "Meguri manages these rules. Your connected provider sends notifications and runs escalation timers.", + "ru": "Meguri управляет правилами. Подключённый сервис отправляет уведомления и выполняет эскалации.", + "fr": "Meguri gère ces règles. Le fournisseur connecté envoie les notifications et exécute les escalades." + }, + "escalation_policies_ad128e": { + "en": "Escalation policies", + "ru": "Политики эскалации", + "fr": "Politiques d’escalade" + }, + "policy_938cc5": { + "en": "+ Policy", + "ru": "+ Политика", + "fr": "+ Politique" + }, + "alert_reaches_this_policy_ea3f47": { + "en": "Alert reaches this policy", + "ru": "Алерт поступает в эту политику", + "fr": "L’alerte arrive dans cette politique" + }, + "symbol_fc4866": { + "en": "↓", + "ru": "↓", + "fr": "↓" + }, + "immediately_21b60a": { + "en": "Immediately", + "ru": "Сразу", + "fr": "Immédiatement" + }, + "min_after_alert_8adeb2": { + "en": " min after alert", + "ru": " мин. после алерта", + "fr": " min après l’alerte" + }, + "if_unacknowledged_407d03": { + "en": "if unacknowledged", + "ru": "если не подтверждён", + "fr": "si non acquittée" + }, + "if_still_open_c34949": { + "en": "if still open", + "ru": "если ещё открыт", + "fr": "si encore ouverte" + }, + "repeat_654579": { + "en": "↺ Repeat ", + "ru": "↺ Повторить ", + "fr": "↺ Répéter " + }, + "times_wait_e1b163": { + "en": " times · wait ", + "ru": " раз · ожидание ", + "fr": " fois · attente " + }, + "min_f4e8a8": { + "en": " min", + "ru": " мин.", + "fr": " min" + }, + "end_of_policy_no_repeats_c2ac4f": { + "en": "End of policy · no repeats", + "ru": "Конец политики · без повторов", + "fr": "Fin de la politique · sans répétition" + }, + "routing_rules_49ce36": { + "en": "Routing rules", + "ru": "Правила маршрутизации", + "fr": "Règles de routage" + }, + "rule_fce5b5": { + "en": "+ Rule", + "ru": "+ Правило", + "fr": "+ Règle" + }, + "first_match_wins_drag_a_rule_by_its_handle_or_use_th_ee6dee": { + "en": "Evaluate by numbered priority; the first match wins. Manual rules are grouped above generated rules. Drag or use arrows to change priority.", + "ru": "Проверка идёт по номеру приоритета до первого совпадения. Ручные правила показаны выше сгенерированных. Меняйте приоритет перетаскиванием или стрелками.", + "fr": "Évaluation par numéro de priorité, au premier résultat. Les règles manuelles sont regroupées avant les règles générées. Modifiez la priorité par glisser-déposer ou avec les flèches." + }, + "routing_rules_in_evaluation_order_582c46": { + "en": "Routing rules in evaluation order", + "ru": "Правила в порядке проверки", + "fr": "Règles dans l’ordre d’évaluation" + }, + "drag_72143a": { + "en": "Drag ", + "ru": "Перетащить ", + "fr": "Faire glisser " + }, + "to_reorder_64a607": { + "en": " to reorder", + "ru": " для смены порядка", + "fr": " pour réordonner" + }, + "drag_to_reorder_e7541f": { + "en": "Drag to reorder", + "ru": "Перетащите для смены порядка", + "fr": "Faire glisser pour réordonner" + }, + "symbol_40b24b": { + "en": "⠿", + "ru": "⠿", + "fr": "⠿" + }, + "position_713830": { + "en": "Position ", + "ru": "Позиция ", + "fr": "Position " + }, + "move_e402ac": { + "en": "Move ", + "ru": "Переместить ", + "fr": "Déplacer " + }, + "up_459409": { + "en": " up", + "ru": " выше", + "fr": " vers le haut" + }, + "move_up_b4f57c": { + "en": "Move up", + "ru": "Переместить выше", + "fr": "Monter" + }, + "symbol_9555ff": { + "en": "↑", + "ru": "↑", + "fr": "↑" + }, + "down_edbf4b": { + "en": " down", + "ru": " ниже", + "fr": " vers le bas" + }, + "move_down_260ff8": { + "en": "Move down", + "ru": "Переместить ниже", + "fr": "Descendre" + }, + "if_7be63e": { + "en": "IF", + "ru": "ЕСЛИ", + "fr": "SI" + }, + "any_alert_4496c3": { + "en": "Any alert", + "ru": "Любой алерт", + "fr": "Toute alerte" + }, + "and_c2f934": { + "en": "AND", + "ru": "И", + "fr": "ET" + }, + "workspace_working_hours_b8ec6f": { + "en": "◷ Workspace working hours", + "ru": "◷ Рабочее время пространства", + "fr": "◷ Horaires de travail de l’espace" + }, + "symbol_213edd": { + "en": "→", + "ru": "→", + "fr": "→" + }, + "do_not_notify_1a59a6": { + "en": "Do not notify", + "ru": "Не уведомлять", + "fr": "Ne pas notifier" + }, + "no_routing_rules_configured_768f05": { + "en": "No routing rules configured.", + "ru": "Правила маршрутизации не настроены.", + "fr": "Aucune règle de routage configurée." + }, + "your_role_does_not_include_access_to_audit_history_bb8e51": { + "en": "Your role does not include access to audit history.", + "ru": "У вашей роли нет доступа к истории действий.", + "fr": "Votre rôle ne permet pas de consulter l’historique d’audit." + }, + "team_preferences_440fbe": { + "en": "WORKSPACE PREFERENCES", + "ru": "ПАРАМЕТРЫ ПРОСТРАНСТВА", + "fr": "PRÉFÉRENCES DE L’ESPACE" + }, + "workspace_schedule_timezone_954c44": { + "en": "Workspace schedule timezone: ", + "ru": "Часовой пояс расписаний пространства: ", + "fr": "Fuseau des plannings de l’espace : " + }, + "planning_horizon_b249cc": { + "en": "Planning horizon: ", + "ru": "Горизонт планирования: ", + "fr": "Horizon de planification : " + }, + "days_e72d54": { + "en": " days", + "ru": " дн.", + "fr": " jours" + }, + "edit_preferences_4cefcb": { + "en": "Edit preferences", + "ru": "Изменить настройки", + "fr": "Modifier les préférences" + }, + "connected_provider_f22440": { + "en": "CONNECTED PROVIDER", + "ru": "ПОДКЛЮЧЁННЫЙ СЕРВИС", + "fr": "FOURNISSEUR CONNECTÉ" + }, + "local_preview_8d4a3f": { + "en": "Local preview", + "ru": "Локальный предпросмотр", + "fr": "Aperçu local" + }, + "approved_changes_are_published_automatically_a715bf": { + "en": "Approved changes are published automatically.", + "ru": "Подтверждённые изменения публикуются автоматически.", + "fr": "Les modifications approuvées sont publiées automatiquement." + }, + "configure_792c81": { + "en": "Configure", + "ru": "Настроить", + "fr": "Configurer" + }, + "download_preview_30507d": { + "en": "Download preview", + "ru": "Скачать предпросмотр", + "fr": "Télécharger l’aperçu" + }, + "team_access_8fb357": { + "en": "Workspace access", + "ru": "Доступ к пространству", + "fr": "Accès à l’espace" + }, + "add_teammate_d6bc5a": { + "en": "+ Add teammate", + "ru": "+ Добавить коллегу", + "fr": "+ Ajouter un collègue" + }, + "permissions_607d39": { + "en": " permissions", + "ru": " разрешений", + "fr": " autorisations" + }, + "edit_access_e381a8": { + "en": "Edit access", + "ru": "Изменить доступ", + "fr": "Modifier l’accès" + }, + "scheduling_directory_e8fdd4": { + "en": "Scheduling directory", + "ru": "Участники расписаний", + "fr": "Annuaire des participants" + }, + "country_controls_public_holidays_defaults_to_cyprus_e828a2": { + "en": "Country controls public holidays · defaults to Cyprus", + "ru": "Страна определяет праздники · по умолчанию Кипр", + "fr": "Le pays détermine les jours fériés · Chypre par défaut" + }, + "no_email_configured_b309ce": { + "en": "No email configured", + "ru": "Email не указан", + "fr": "Aucune adresse e-mail" + }, + "workspace_user_95981a": { + "en": "Workspace user", + "ru": "Пользователь пространства", + "fr": "Utilisateur de l’espace" + }, + "email_contact_e9e0b3": { + "en": "Email contact", + "ru": "Контакт по email", + "fr": "Contact par e-mail" + }, + "edit_person_d98516": { + "en": "Edit person", + "ru": "Изменить участника", + "fr": "Modifier la personne" + }, + "workspace_time_a3e57f": { + "en": "WORKSPACE TIME", + "ru": "ВРЕМЯ ПРОСТРАНСТВА", + "fr": "HORAIRES DE L’ESPACE" + }, + "working_interval_schedule_timezone_fc825f": { + "en": "Working interval & schedule timezone", + "ru": "Рабочий интервал и часовой пояс расписаний", + "fr": "Horaires de travail et fuseau des plannings" + }, + "shared_by_every_team_rotations_use_this_timezone_wit_e06e20": { + "en": "Schedules use this timezone with their own handover time; routing rules can use this working interval.", + "ru": "Расписания используют этот часовой пояс со своим временем передачи смены; маршруты могут использовать этот рабочий интервал.", + "fr": "Les plannings utilisent ce fuseau avec leur heure de relève ; les règles de routage peuvent utiliser ces horaires." + }, + "edit_working_interval_290690": { + "en": "Edit working interval", + "ru": "Изменить рабочий интервал", + "fr": "Modifier les horaires de travail" + }, + "workspace_policy_4437ae": { + "en": "WORKSPACE POLICY", + "ru": "ПОЛИТИКА ПРОСТРАНСТВА", + "fr": "POLITIQUE DE L’ESPACE" + }, + "changes_made_outside_meguri_98ff25": { + "en": "Changes made outside Meguri", + "ru": "Изменения вне Meguri", + "fr": "Modifications hors de Meguri" + }, + "applies_to_every_team_in_this_workspace_and_only_to__909c8c": { + "en": "Applies only to this workspace’s resources managed by Meguri.", + "ru": "Применяется только к ресурсам этого пространства под управлением Meguri.", + "fr": "S’applique uniquement aux ressources de cet espace gérées par Meguri." + }, + "when_external_configuration_changes_18f056": { + "en": "When external configuration changes", + "ru": "При изменении внешней конфигурации", + "fr": "Si la configuration externe change" + }, + "pause_publication_and_report_a_conflict_default_5b9c4a": { + "en": "Pause publication and report a conflict (default)", + "ru": "Остановить публикацию и сообщить о конфликте (по умолчанию)", + "fr": "Suspendre la publication et signaler le conflit (par défaut)" + }, + "overwrite_external_changes_with_meguri_s_state_36db36": { + "en": "Overwrite external changes with Meguri’s state", + "ru": "Перезаписать внешние изменения состоянием Meguri", + "fr": "Écraser les modifications externes avec l’état de Meguri" + }, + "meguri_8bdb60": { + "en": "MEGURI", + "ru": "MEGURI", + "fr": "MEGURI" + }, + "good_rotations_start_with_a_shared_plan_2e3f86": { + "en": "Good rotations start with a shared plan.", + "ru": "Хорошие дежурства начинаются с общего плана.", + "fr": "De bonnes rotations commencent par un planning commun." + }, + "apply_without_approval_d72926": { + "en": "Apply without approval", + "ru": "Применить без согласования", + "fr": "Appliquer sans accord" + }, + "who_can_cover_for_you_6e894c": { + "en": "Who can cover for you?", + "ru": "Кто вас подменит?", + "fr": "Qui peut vous remplacer ?" + }, + "cover_cc7dd4": { + "en": "Cover ", + "ru": "Подменить: ", + "fr": "Remplacer " + }, + "s_shift_9119aa": { + "en": "’s shift", + "ru": "на смене", + "fr": "pendant son astreinte" + }, + "choose_a_shift_330348": { + "en": "Choose a shift", + "ru": "Выберите смену", + "fr": "Choisir une astreinte" + }, + "workspace_variable_7abc57": { + "en": "Workspace variable", + "ru": "Переменная пространства", + "fr": "Variable de l’espace" + }, + "generation_rule_d34ccf": { + "en": "Generation rule", + "ru": "Правило генерации", + "fr": "Règle de génération" + }, + "rotation_906fe5": { + "en": "Rotation", + "ru": "Ротация", + "fr": "Rotation" + }, + "project_coverage_536388": { + "en": "First available", + "ru": "Первый доступный", + "fr": "Premier disponible" + }, + "request_a_change_3d465f": { + "en": "Request a change", + "ru": "Запросить подмену", + "fr": "Demander un changement" + }, + "planned_absence_bfcea7": { + "en": "Planned absence", + "ru": "Планируемое отсутствие", + "fr": "Absence prévue" + }, + "public_holiday_49ec03": { + "en": "Public holiday", + "ru": "Государственный праздник", + "fr": "Jour férié" + }, + "import_public_holidays_8098e3": { + "en": "Import public holidays", + "ru": "Импорт праздников", + "fr": "Importer des jours fériés" + }, + "remove_schedule_ef0a8a": { + "en": "Remove schedule", + "ru": "Удалить расписание", + "fr": "Supprimer le planning" + }, + "scheduling_person_1273c6": { + "en": "Scheduling person", + "ru": "Участник расписания", + "fr": "Participant au planning" + }, + "workspace_working_interval_c7d250": { + "en": "Workspace working interval", + "ru": "Рабочий интервал пространства", + "fr": "Horaires de travail de l’espace" + }, + "your_account_4ab291": { + "en": "Your account", + "ru": "Ваш аккаунт", + "fr": "Votre compte" + }, + "escalation_policy_bbb0f7": { + "en": "Escalation policy", + "ru": "Политика эскалации", + "fr": "Politique d’escalade" + }, + "routing_rule_b00b22": { + "en": "Routing rule", + "ru": "Правило маршрутизации", + "fr": "Règle de routage" + }, + "connected_provider_9702c0": { + "en": "Connected provider", + "ru": "Подключённый сервис", + "fr": "Fournisseur connecté" + }, + "team_preferences_7fcab5": { + "en": "Workspace preferences", + "ru": "Параметры пространства", + "fr": "Préférences de l’espace" + }, + "close_dialog_7b2902": { + "en": "Close dialog", + "ru": "Закрыть окно", + "fr": "Fermer la fenêtre" + }, + "choose_a_person_202007": { + "en": "Choose a person", + "ru": "Выберите участника", + "fr": "Choisir une personne" + }, + "person_details_0574fe": { + "en": "Person details", + "ru": "Данные участника", + "fr": "Informations de la personne" + }, + "back_c32ae9": { + "en": "← Back", + "ru": "← Назад", + "fr": "← Retour" + }, + "search_workspace_users_or_enter_email_b3922e": { + "en": "Search workspace users or enter email", + "ru": "Найдите пользователя или введите email", + "fr": "Rechercher un utilisateur ou saisir un e-mail" + }, + "name_or_person_example_com_c4e3fd": { + "en": "Name or person@example.com", + "ru": "Имя или person@example.com", + "fr": "Nom ou person@example.com" + }, + "user_9f8a23": { + "en": "User", + "ru": "Пользователь", + "fr": "Utilisateur" + }, + "contact_b37456": { + "en": "Contact", + "ru": "Контакт", + "fr": "Contact" + }, + "no_matching_person_in_this_workspace_dcec7a": { + "en": "No matching person in this workspace.", + "ru": "В этом пространстве никого не найдено.", + "fr": "Aucune personne correspondante dans cet espace." + }, + "add_by_email_9368f3": { + "en": "Add by email", + "ru": "Добавить по email", + "fr": "Ajouter par e-mail" + }, + "they_do_not_need_a_meguri_account_adding_a_contact_d_15a721": { + "en": "They do not need a Meguri account. Adding a contact does not grant access.", + "ru": "Аккаунт Meguri не требуется. Добавление контакта не даёт доступа.", + "fr": "Aucun compte Meguri n’est nécessaire. Ajouter un contact ne lui donne pas accès." + }, + "name_optional_9c9f03": { + "en": "Name (optional)", + "ru": "Имя (необязательно)", + "fr": "Nom (facultatif)" + }, + "displayed_in_the_calendar_923a0e": { + "en": "Displayed in the calendar", + "ru": "Отображается в календаре", + "fr": "Affiché dans le calendrier" + }, + "country_d523eb": { + "en": "Country", + "ru": "Страна", + "fr": "Pays" + }, + "add_e2b431": { + "en": "Add ", + "ru": "Добавить ", + "fr": "Ajouter " + }, + "name_709a23": { + "en": "Name", + "ru": "Название", + "fr": "Nom" + }, + "email_for_jsm_lookup_193cfb": { + "en": "Email for JSM lookup", + "ru": "Email для поиска в JSM", + "fr": "E-mail pour la recherche JSM" + }, + "scheduling_country_393322": { + "en": "Scheduling country", + "ru": "Страна участника", + "fr": "Pays du participant" + }, + "use_these_details_775a68": { + "en": "Use these details", + "ru": "Использовать эти данные", + "fr": "Utiliser ces informations" + }, + "country_of_work_88453b": { + "en": "Country of work", + "ru": "Страна работы", + "fr": "Pays de travail" + }, + "display_timezone_2425c3": { + "en": "Display timezone", + "ru": "Часовой пояс отображения", + "fr": "Fuseau d’affichage" + }, + "browser_default_e90df2": { + "en": "Browser default: ", + "ru": "Из браузера: ", + "fr": "Fuseau du navigateur : " + }, + "use_browser_timezone_3f54ba": { + "en": "Use browser timezone", + "ru": "Использовать часовой пояс браузера", + "fr": "Utiliser le fuseau du navigateur" + }, + "dates_and_timestamps_are_displayed_in_77ec74": { + "en": "Dates and timestamps are displayed in ", + "ru": "Даты и время отображаются в ", + "fr": "Les dates et heures sont affichées dans " + }, + "this_never_changes_the_workspace_timezone_or_shifts_1de864": { + "en": ". This never changes the workspace timezone or shifts.", + "ru": ". Это не меняет часовой пояс пространства и смены.", + "fr": ". Cela ne modifie jamais le fuseau de l’espace ni les astreintes." + }, + "country_changes_update_your_holiday_calendar_in_team_1e4eb8": { + "en": "Country changes update your holiday calendar in teams. Leave password fields blank to keep your current password.", + "ru": "Смена страны обновляет ваш календарь праздников в командах. Не заполняйте поля пароля, если не хотите его менять.", + "fr": "Changer de pays actualise vos jours fériés dans les équipes. Laissez les champs de mot de passe vides pour le conserver." + }, + "current_password_19dff4": { + "en": "Current password", + "ru": "Текущий пароль", + "fr": "Mot de passe actuel" + }, + "new_password_d850ee": { + "en": "New password", + "ru": "Новый пароль", + "fr": "Nouveau mot de passe" + }, + "an_email_contact_can_be_scheduled_without_logging_in_34d74f": { + "en": "An email contact can be scheduled without logging in. JSM needs an existing Atlassian account with this email or an explicit account ID.", + "ru": "Контакт по email может дежурить без входа. Для JSM нужен аккаунт Atlassian с этим email или явно заданный ID аккаунта.", + "fr": "Un contact peut être planifié sans se connecter. JSM nécessite un compte Atlassian associé à cet e-mail ou un identifiant de compte explicite." + }, + "workspace_iana_timezone_bea0e0": { + "en": "Workspace IANA timezone", + "ru": "Часовой пояс пространства (IANA)", + "fr": "Fuseau IANA de l’espace" + }, + "mon_24b2a0": { + "en": "Mon", + "ru": "Пн", + "fr": "Lun" + }, + "tue_529541": { + "en": "Tue", + "ru": "Вт", + "fr": "Mar" + }, + "wed_23408b": { + "en": "Wed", + "ru": "Ср", + "fr": "Mer" + }, + "thu_3593cc": { + "en": "Thu", + "ru": "Чт", + "fr": "Jeu" + }, + "fri_bbd6e3": { + "en": "Fri", + "ru": "Пт", + "fr": "Ven" + }, + "sat_6b782d": { + "en": "Sat", + "ru": "Сб", + "fr": "Sam" + }, + "sun_48c98c": { + "en": "Sun", + "ru": "Вс", + "fr": "Dim" + }, + "working_day_starts_5418a6": { + "en": "Working day starts", + "ru": "Начало рабочего дня", + "fr": "Début de la journée de travail" + }, + "working_day_ends_52d5f6": { + "en": "Working day ends", + "ru": "Конец рабочего дня", + "fr": "Fin de la journée de travail" + }, + "this_interval_is_shared_across_the_workspace_the_tim_07e765": { + "en": "This interval is shared across the workspace. The timezone also defines local rotation handovers. Your account timezone affects display only.", + "ru": "Этот интервал общий для пространства. Часовой пояс также определяет передачу смен. Часовой пояс аккаунта влияет только на отображение.", + "fr": "Ces horaires sont communs à l’espace. Le fuseau définit aussi les relèves des rotations. Le fuseau du compte ne concerne que l’affichage." + }, + "schedule_name_fb63dc": { + "en": "Schedule name", + "ru": "Название расписания", + "fr": "Nom du planning" + }, + "handover_time_c9e315": { + "en": "Handover time", + "ru": "Время передачи смены", + "fr": "Heure de relève" + }, + "schedule_time_in_911b55": { + "en": "Schedule time in ", + "ru": "Время расписания: ", + "fr": "Heure du planning : " + }, + "each_shift_ends_when_the_next_begins_14ab16": { + "en": " · each shift ends when the next begins", + "ru": " · каждая смена заканчивается в начале следующей", + "fr": " · chaque astreinte se termine au début de la suivante" + }, + "anchor_date_e77bd6": { + "en": "Anchor date", + "ru": "Дата начала ротации", + "fr": "Date de départ de la rotation" + }, + "dd_mm_yyyy_d7e5fe": { + "en": "DD-MM-YYYY", + "ru": "DD-MM-YYYY", + "fr": "DD-MM-YYYY" + }, + "open_calendar_41547e": { + "en": "Open calendar", + "ru": "Открыть календарь", + "fr": "Ouvrir le calendrier" + }, + "symbol_21887f": { + "en": "▦", + "ru": "▦", + "fr": "▦" + }, + "days_per_shift_83ec07": { + "en": "Days per shift", + "ru": "Дней в смене", + "fr": "Jours par astreinte" + }, + "participants_in_rotation_order_4edee0": { + "en": "Participants, in rotation order", + "ru": "Участники по порядку ротации", + "fr": "Participants dans l’ordre de rotation" + }, + "edit_participant_details_73e491": { + "en": "Edit participant details", + "ru": "Изменить данные участника", + "fr": "Modifier les informations du participant" + }, + "details_dc3dec": { + "en": "Details", + "ru": "Подробности", + "fr": "Détails" + }, + "move_earlier_07dd6f": { + "en": "Move earlier", + "ru": "Переместить раньше", + "fr": "Avancer" + }, + "move_later_bb4c84": { + "en": "Move later", + "ru": "Переместить позже", + "fr": "Reculer" + }, + "remove_participant_131344": { + "en": "Remove participant", + "ru": "Удалить участника", + "fr": "Retirer le participant" + }, + "add_participant_name_or_email_5d89ac": { + "en": "+ Add participant · name or email", + "ru": "+ Добавить участника · имя или email", + "fr": "+ Ajouter un participant · nom ou e-mail" + }, + "the_first_participant_starts_on_the_anchor_date_at_t_d87af4": { + "en": "The first participant starts on the anchor date at this schedule’s handover time.", + "ru": "Первый участник начинает в дату начала ротации, во время передачи смены.", + "fr": "Le premier participant commence à la date de départ, à l’heure de relève du planning." + }, + "choose_a_person_or_enter_email_bcface": { + "en": "Choose a person or enter email", + "ru": "Выберите участника или введите email", + "fr": "Choisir une personne ou saisir un e-mail" + }, + "primary_employee_805dff": { + "en": "Primary employee", + "ru": "Первый ответственный", + "fr": "Responsable principal" + }, + "secondary_employee_6194c2": { + "en": "Secondary employee", + "ru": "Второй ответственный", + "fr": "Responsable secondaire" + }, + "fallback_schedule_7b7a21": { + "en": "Fallback schedule", + "ru": "Резервное расписание", + "fr": "Planning de repli" + }, + "choose_a_schedule_0636f3": { + "en": "Choose a schedule", + "ru": "Выберите расписание", + "fr": "Choisir un planning" + }, + "when_the_primary_is_absent_or_has_a_public_holiday_i_ba8499": { + "en": "When the primary is absent or has a public holiday in their country, use the secondary. If both are off, use the effective fallback schedule. General rotations continue through holidays.", + "ru": "Если основной ответственный отсутствует или в его стране праздник, дежурит второй. Если отсутствуют оба — дежурный резервного расписания. Общие ротации продолжаются и в праздники.", + "fr": "Si le responsable principal est absent ou en jour férié, le secondaire prend le relais. Si les deux sont absents, le planning de repli s’applique. Les rotations générales continuent les jours fériés." + }, + "no_assignment_for_this_day_95a9f7": { + "en": "No assignment for this day.", + "ru": "На этот день дежурный не назначен.", + "fr": "Aucune affectation pour ce jour." + }, + "choose_755968": { + "en": "Choose →", + "ru": "Выбрать →", + "fr": "Choisir →" + }, + "past_shift_cd54e4": { + "en": "Past shift", + "ru": "Прошедшая смена", + "fr": "Astreinte passée" + }, + "from_3f6605": { + "en": "From", + "ru": "С", + "fr": "Du" + }, + "now_e3b820": { + "en": "Now", + "ru": "Сейчас", + "fr": "Maintenant" + }, + "until_96bdb6": { + "en": "Until", + "ru": "До", + "fr": "Jusqu’au" + }, + "you_ll_cover_this_shift_afcf1e": { + "en": "You’ll cover this shift", + "ru": "Вы подмените на этой смене", + "fr": "Vous assurerez cette astreinte" + }, + "requested_by_your_colleague_1320db": { + "en": "Requested by your colleague", + "ru": "По запросу коллеги", + "fr": "À la demande de votre collègue" + }, + "who_s_covering_926c44": { + "en": "Who’s covering?", + "ru": "Кто подменяет?", + "fr": "Qui remplace ?" + }, + "search_colleagues_9c47ad": { + "en": "Search colleagues", + "ru": "Поиск коллег", + "fr": "Rechercher des collègues" + }, + "search_by_name_or_email_eedfe2": { + "en": "Search by name or email…", + "ru": "Найти по имени или email…", + "fr": "Rechercher par nom ou e-mail…" + }, + "covering_colleague_10b47f": { + "en": "Covering colleague", + "ru": "Подменяющий коллега", + "fr": "Collègue remplaçant" + }, + "me_94563e": { + "en": "Me", + "ru": "Я", + "fr": "Moi" + }, + "symbol_698a87": { + "en": "✓", + "ru": "✓", + "fr": "✓" + }, + "no_matching_teammates_dac71f": { + "en": "No matching teammates.", + "ru": "Коллеги не найдены.", + "fr": "Aucun collègue trouvé." + }, + "change_dates_or_swap_days_a3da69": { + "en": "Change dates or swap days", + "ru": "Изменить даты или обменяться днями", + "fr": "Modifier les dates ou échanger des jours" + }, + "duty_days_e9fa6d": { + "en": "Duty days · ", + "ru": "Дни дежурства · ", + "fr": "Jours d’astreinte · " + }, + "first_day_9820d9": { + "en": "First day", + "ru": "Первый день", + "fr": "Premier jour" + }, + "last_day_2a1772": { + "en": "Last day", + "ru": "Последний день", + "fr": "Dernier jour" + }, + "i_ll_take_a_shift_in_return_b47888": { + "en": "I’ll take a shift in return", + "ru": "Возьму смену взамен", + "fr": "Je prendrai une astreinte en échange" + }, + "return_first_day_93f9a6": { + "en": "Return first day", + "ru": "Первый день ответной смены", + "fr": "Premier jour en échange" + }, + "return_last_day_7433cf": { + "en": "Return last day", + "ru": "Последний день ответной смены", + "fr": "Dernier jour en échange" + }, + "comment_optional_f28415": { + "en": "Comment (optional)", + "ru": "Комментарий (необязательно)", + "fr": "Commentaire (facultatif)" + }, + "e_g_ops_123_vacation_befe39": { + "en": "e.g. OPS-123 · vacation", + "ru": "например, OPS-123 · отпуск", + "fr": "p. ex. OPS-123 · congés" + }, + "checking_availability_24f241": { + "en": "Checking availability…", + "ru": "Проверяем доступность…", + "fr": "Vérification de la disponibilité…" + }, + "applies_immediately_no_approval_needed_842779": { + "en": "Applies immediately. No approval needed.", + "ru": "Применяется сразу. Согласование не требуется.", + "fr": "Application immédiate. Aucun accord nécessaire." + }, + "your_acceptance_completes_this_request_3713f7": { + "en": "Your acceptance completes this request.", + "ru": "Ваше подтверждение завершает запрос.", + "fr": "Votre accord valide cette demande." + }, + "will_confirm_in_meguri_a4a9b6": { + "en": " will confirm in Meguri.", + "ru": " подтвердит в Meguri.", + "fr": " confirmera dans Meguri." + }, + "applies_immediately_your_action_and_optional_comment_50c159": { + "en": "Applies immediately. Your action and optional comment are recorded in history.", + "ru": "Применяется сразу. Действие и комментарий сохранятся в истории.", + "fr": "Application immédiate. Votre action et votre commentaire éventuel seront enregistrés dans l’historique." + }, + "employee_079711": { + "en": "Employee", + "ru": "Сотрудник", + "fr": "Employé" + }, + "first_absent_duty_day_b0b47f": { + "en": "First absent duty day", + "ru": "Первый день отсутствия", + "fr": "Premier jour d’absence" + }, + "last_absent_duty_day_inclusive_6f2bf4": { + "en": "Last absent duty day (inclusive)", + "ru": "Последний день отсутствия (включительно)", + "fr": "Dernier jour d’absence (inclus)" + }, + "dates_are_inclusive_duty_days_in_the_workspace_timez_b377b5": { + "en": "Dates are inclusive duty days in the workspace timezone. Each schedule uses its own handover. Projects automatically use their available secondary, then fallback. Add ranges to cover your rotations in bulk.", + "ru": "Даты включают дежурные дни в часовом поясе команды. Каждое расписание использует своё время передачи смены. Проекты автоматически используют следующего доступного ответственного, затем резервное расписание. Добавьте интервалы для подмен сразу во всех ротациях.", + "fr": "Les dates incluent les jours de garde dans le fuseau de l’espace de travail. Chaque planning utilise sa propre heure de relève. Les projets utilisent automatiquement le responsable suivant disponible, puis le planning de secours. Ajoutez des intervalles pour couvrir vos rotations ensemble." + }, + "replacement_ranges_a8d04e": { + "en": "Replacement ranges", + "ru": "Интервалы подмены", + "fr": "Périodes de remplacement" + }, + "replacement_range_f216ec": { + "en": "+ Replacement range", + "ru": "+ Интервал подмены", + "fr": "+ Période de remplacement" + }, + "no_explicit_substitutes_yet_the_preview_below_shows__07dfef": { + "en": "No explicit substitutes yet. The preview below shows automatic coverage and any uncovered shifts.", + "ru": "Подмены пока не выбраны. Ниже показаны автоматические изменения и смены без дежурного.", + "fr": "Aucun remplaçant choisi. L’aperçu ci-dessous indique la couverture automatique et les astreintes non couvertes." + }, + "range_starts_3632e7": { + "en": "Range starts", + "ru": "Начало интервала", + "fr": "Début de la période" + }, + "range_ends_inclusive_a1e1bc": { + "en": "Range ends (inclusive)", + "ru": "Конец интервала (включительно)", + "fr": "Fin de la période (incluse)" + }, + "replacement_c385e3": { + "en": "Replacement", + "ru": "Подмена", + "fr": "Remplacement" + }, + "promote_secondary_automatically_3bee50": { + "en": "Promote secondary automatically", + "ru": "Автоматически назначить следующего ответственного", + "fr": "Passer automatiquement au responsable secondaire" + }, + "use_this_replacement_in_affected_projects_too_edf224": { + "en": "Use this replacement in affected projects too", + "ru": "Использовать эту подмену и в затронутых проектах", + "fr": "Utiliser aussi ce remplacement dans les projets concernés" + }, + "remove_range_55c658": { + "en": "Remove range", + "ru": "Удалить интервал", + "fr": "Supprimer la période" + }, + "apply_replacements_without_confirmation_7c4a2e": { + "en": "Apply replacements without confirmation", + "ru": "Применить подмены без согласования", + "fr": "Appliquer les remplacements sans validation" + }, + "vacation_coverage_9d7295": { + "en": "Vacation coverage", + "ru": "Подмена на время отпуска", + "fr": "Remplacement pendant les congés" + }, + "schedule_impact_0ae487": { + "en": "Schedule impact", + "ru": "Изменения расписаний", + "fr": "Effet sur les plannings" + }, + "checking_affected_schedules_109ed9": { + "en": "Checking affected schedules…", + "ru": "Проверяем затронутые расписания…", + "fr": "Vérification des plannings concernés…" + }, + "affected_schedules_c82c00": { + "en": " affected schedules · ", + "ru": " расписаний · ", + "fr": " plannings concernés · " + }, + "replacement_requests_c81010": { + "en": " replacement requests · ", + "ru": " запросов на подмену · ", + "fr": " demandes de remplacement · " + }, + "uncovered_intervals_b5430f": { + "en": " uncovered intervals", + "ru": " интервалов без дежурного", + "fr": " périodes non couvertes" + }, + "no_scheduled_shifts_change_in_this_interval_98915a": { + "en": "No scheduled shifts change in this interval.", + "ru": "В этом интервале смены не изменятся.", + "fr": "Aucune astreinte ne change sur cette période." + }, + "schedule_interval_3cf85c": { + "en": "Schedule / interval", + "ru": "Расписание / интервал", + "fr": "Planning / période" + }, + "coverage_80e135": { + "en": "Coverage", + "ru": "Дежурство", + "fr": "Couverture" + }, + "action_97c89a": { + "en": "Action", + "ru": "Действие", + "fr": "Action" + }, + "uncovered_88710a": { + "en": "Uncovered", + "ru": "Нет дежурного", + "fr": "Non couvert" + }, + "apply_now_a356cc": { + "en": "Apply now", + "ru": "Применить сейчас", + "fr": "Appliquer maintenant" + }, + "needs_confirmation_722b99": { + "en": "Needs confirmation", + "ru": "Нужно подтверждение", + "fr": "Validation requise" + }, + "choose_a_substitute_be8f67": { + "en": "Choose a substitute", + "ru": "Выберите подмену", + "fr": "Choisir un remplaçant" + }, + "automatic_secondary_fallback_7197c5": { + "en": "Automatic secondary / fallback", + "ru": "Следующий ответственный / резерв автоматически", + "fr": "Secondaire / repli automatique" + }, + "show_fewer_intervals_6d52e6": { + "en": "Show fewer intervals", + "ru": "Показать меньше интервалов", + "fr": "Afficher moins de périodes" + }, + "show_all_382ebd": { + "en": "Show all ", + "ru": "Показать все ", + "fr": "Tout afficher : " + }, + "intervals_d03402": { + "en": " intervals", + "ru": " интервалов", + "fr": " périodes" + }, + "absence_takes_effect_when_saved_each_replacement_con_a55d09": { + "en": "Absence takes effect when saved. Each replacement confirms all their affected shifts in one request per range. Until confirmed, uncovered intervals block publication; the provider keeps its last published configuration.", + "ru": "Отсутствие учитывается сразу после сохранения. Подменяющий подтверждает все свои затронутые смены одним запросом на интервал. Пока нет подтверждения, смены без дежурного блокируют публикацию; внешний сервис сохраняет прежнюю конфигурацию.", + "fr": "L’absence est prise en compte dès l’enregistrement. Chaque remplaçant confirme toutes ses astreintes dans une demande par période. Les périodes non couvertes bloquent la publication ; le fournisseur conserve sa dernière configuration." + }, + "some_shifts_have_no_available_replacement_they_are_s_c20d79": { + "en": "Some shifts have no available replacement. They are shown as uncovered and block publication.", + "ru": "Для некоторых смен нет доступной подмены. Они отмечены как непокрытые и блокируют публикацию.", + "fr": "Certaines astreintes n’ont pas de remplaçant disponible. Elles restent non couvertes et bloquent la publication." + }, + "date_in_country_calendar_fbc5e1": { + "en": "Date in country calendar", + "ru": "Дата по календарю страны", + "fr": "Date du calendrier national" + }, + "holiday_name_ceba88": { + "en": "Holiday name", + "ru": "Название праздника", + "fr": "Nom du jour férié" + }, + "paste_a_csv_with_113f64": { + "en": "Paste a CSV with", + "ru": "Вставьте CSV с заголовками", + "fr": "Collez un CSV avec les en-têtes" + }, + "country_date_name_a98540": { + "en": "country,date,name", + "ru": "country,date,name", + "fr": "country,date,name" + }, + "headers_use_country_codes_from_the_same_directory_as_abe68d": { + "en": "headers. Use country codes from the same directory as your people, such as", + "ru": ". Используйте коды стран из справочника участников, например", + "fr": ". Utilisez les codes pays de l’annuaire des participants, par exemple" + }, + "cy_c44de8": { + "en": "CY", + "ru": "CY", + "fr": "CY" + }, + "cyprus_and_834749": { + "en": "(Cyprus) and", + "ru": "(Кипр) и", + "fr": "(Chypre) et" + }, + "am_80d305": { + "en": "AM", + "ru": "AM", + "fr": "AM" + }, + "armenia_existing_dates_will_never_be_overwritten_5250bb": { + "en": "(Armenia). Existing dates will never be overwritten.", + "ru": "(Армения). Существующие даты не перезаписываются.", + "fr": "(Arménie). Les dates existantes ne seront jamais écrasées." + }, + "country_codes_csv_example_b6cb8a": { + "en": "Country codes & CSV example", + "ru": "Коды стран и пример CSV", + "fr": "Codes pays et exemple CSV" + }, + "find_a_country_bec93b": { + "en": "Find a country", + "ru": "Найти страну", + "fr": "Rechercher un pays" + }, + "choose_a_country_7843c8": { + "en": "Choose a country", + "ru": "Выберите страну", + "fr": "Choisir un pays" + }, + "cy_cyprus_am_armenia_8c0034": { + "en": "CY = Cyprus · AM = Armenia", + "ru": "CY = Кипр · AM = Армения", + "fr": "CY = Chypre · AM = Arménie" + }, + "country_date_name_cy_2026_10_01_independence_day_am__be8930": { + "en": "country,date,name\nCY,2026-10-01,Independence Day\nAM,2026-09-21,Independence Day\nCY,2026-12-25,Christmas Day", + "ru": "country,date,name\nCY,2026-10-01,Independence Day\nAM,2026-09-21,Independence Day\nCY,2026-12-25,Christmas Day", + "fr": "country,date,name\nCY,2026-10-01,Independence Day\nAM,2026-09-21,Independence Day\nCY,2026-12-25,Christmas Day" + }, + "holiday_csv_222225": { + "en": "Holiday CSV", + "ru": "CSV с праздниками", + "fr": "CSV des jours fériés" + }, + "country_date_name_cy_2026_12_25_christmas_day_b4543f": { + "en": "country,date,name\nCY,2026-12-25,Christmas Day", + "ru": "country,date,name\nCY,2026-12-25,Christmas Day", + "fr": "country,date,name\nCY,2026-12-25,Christmas Day" + }, + "comparing_with_saved_holidays_ad992c": { + "en": "Comparing with saved holidays…", + "ru": "Сравниваем с сохранёнными праздниками…", + "fr": "Comparaison avec les jours fériés enregistrés…" + }, + "new_ac85be": { + "en": " new ", + "ru": " новых ", + "fr": " nouveaux " + }, + "already_saved_2ba31a": { + "en": " already saved", + "ru": " уже сохранено", + "fr": " déjà enregistrés" + }, + "repeated_rows_60187e": { + "en": " repeated rows", + "ru": " повторяющихся строк", + "fr": " lignes répétées" + }, + "nothing_is_saved_until_you_choose_import_996b73": { + "en": "Nothing is saved until you choose Import.", + "ru": "Данные сохранятся только после нажатия «Импорт».", + "fr": "Rien n’est enregistré avant de choisir Importer." + }, + "date_eb9a4b": { + "en": "Date", + "ru": "Дата", + "fr": "Date" + }, + "will_be_added_5399ac": { + "en": "Will be added", + "ru": "Будет добавлено", + "fr": "Sera ajouté" + }, + "registered_username_e29aa9": { + "en": "Registered username", + "ru": "Имя зарегистрированного пользователя", + "fr": "Identifiant enregistré" + }, + "your_teammate_needs_an_account_before_you_can_add_th_80fa32": { + "en": "Your teammate needs an account before you can add them.", + "ru": "Чтобы добавить коллегу, ему нужен аккаунт.", + "fr": "Votre collègue doit avoir un compte pour être ajouté." + }, + "team_name_9b11ed": { + "en": "Workspace name", + "ru": "Название пространства", + "fr": "Nom de l’espace" + }, + "planning_horizon_days_fc8284": { + "en": "Planning horizon (days)", + "ru": "Горизонт планирования (дни)", + "fr": "Horizon de planification (jours)" + }, + "schedule_timezone_003979": { + "en": "Schedule timezone: ", + "ru": "Часовой пояс расписания: ", + "fr": "Fuseau du planning : " + }, + "change_it_in_workspace_working_interval_settings_eac_1779f8": { + "en": ". Change it in workspace working interval settings. Each rotation has its own handover time.", + "ru": ". Измените его в настройках рабочего интервала пространства. У каждой ротации своё время передачи смены.", + "fr": ". Modifiez-le dans les horaires de l’espace. Chaque rotation a sa propre heure de relève." + }, + "from_the_current_configuration_past_assignments_and__e2a3f7": { + "en": "from the current configuration. Past assignments and audit history are retained.", + "ru": "из текущей конфигурации. Прошедшие смены и история действий сохранятся.", + "fr": "de la configuration actuelle. Les affectations passées et l’historique d’audit sont conservés." + }, + "this_schedule_has_current_or_future_approved_replace_6bfa3a": { + "en": "This schedule has current or future approved replacements. It cannot be removed until those intervals have ended.", + "ru": "В расписании есть текущие или будущие подтверждённые подмены. Удаление возможно только после их окончания.", + "fr": "Ce planning contient des remplacements approuvés en cours ou futurs. Il ne peut être supprimé avant leur fin." + }, + "used_by_escalation_steps_3e2542": { + "en": "Used by escalation steps", + "ru": "Используется в шагах эскалации", + "fr": "Utilisé par des étapes d’escalade" + }, + "step_37d11c": { + "en": "Step ", + "ru": "Шаг ", + "fr": "Étape " + }, + "used_as_a_coverage_fallback_8955da": { + "en": "Used as a coverage fallback", + "ru": "Используется как резервное расписание", + "fr": "Utilisé comme planning de repli" + }, + "existing_references_748fbb": { + "en": "Existing references", + "ru": "Существующие ссылки", + "fr": "Références existantes" + }, + "remove_the_affected_escalation_steps_8586b1": { + "en": "Remove the affected escalation steps", + "ru": "Удалить затронутые шаги эскалации", + "fr": "Supprimer les étapes d’escalade concernées" + }, + "use_8023ca": { + "en": "Use ", + "ru": "Использовать ", + "fr": "Utiliser " + }, + "instead_cd9ec2": { + "en": " instead", + "ru": " вместо него", + "fr": " à la place" + }, + "all_escalation_steps_and_coverage_fallbacks_that_ref_8ca984": { + "en": "All escalation steps and coverage fallbacks that refer to this schedule will use the selected schedule instead.", + "ru": "Все шаги эскалации и резервные ссылки перейдут на выбранное расписание.", + "fr": "Toutes les étapes d’escalade et références de repli utiliseront le planning sélectionné." + }, + "choose_another_schedule_for_the_coverage_fallbacks_b_61dbff": { + "en": "Choose another schedule for the coverage fallbacks before removing this one.", + "ru": "Перед удалением выберите другое резервное расписание.", + "fr": "Choisissez un autre planning de repli avant de supprimer celui-ci." + }, + "these_policies_would_have_no_steps_left_choose_a_rep_2ce922": { + "en": "These policies would have no steps left. Choose a replacement schedule or edit them first:", + "ru": "В этих политиках не останется шагов. Выберите замену или сначала измените политики:", + "fr": "Ces politiques n’auraient plus d’étapes. Choisissez un planning de remplacement ou modifiez-les d’abord :" + }, + "no_escalation_or_fallback_references_need_changing_b2018c": { + "en": "No escalation or fallback references need changing.", + "ru": "Ссылки эскалации и резерва менять не требуется.", + "fr": "Aucune référence d’escalade ou de repli à modifier." + }, + "pending_replacement_requests_will_be_cancelled_in_fu_a1f178": { + "en": " pending replacement requests will be cancelled in full, including both legs of any swaps. They remain in the audit history.", + "ru": " запросов на подмену будут полностью отменены, включая обе стороны обмена. Они останутся в истории.", + "fr": " demandes de remplacement seront annulées intégralement, y compris les deux volets des échanges. Elles restent dans l’historique." + }, + "the_schedule_and_these_dependency_changes_are_saved__42e786": { + "en": "The schedule and these dependency changes are saved together. Provider publication is then queued.", + "ru": "Расписание и зависимые изменения сохраняются вместе. Затем публикация ставится в очередь.", + "fr": "Le planning et ses dépendances sont enregistrés ensemble. La publication est ensuite mise en file d’attente." + }, + "policy_name_101bf6": { + "en": "Policy name", + "ru": "Название политики", + "fr": "Nom de la politique" + }, + "then_if_the_condition_still_holds_0fa74f": { + "en": "↓ then, if the condition still holds", + "ru": "↓ затем, если условие всё ещё выполняется", + "fr": "↓ ensuite, si la condition est toujours remplie" + }, + "after_minutes_d33505": { + "en": "After minutes", + "ru": "Через минут", + "fr": "Après (minutes)" + }, + "condition_2f4979": { + "en": "Condition", + "ru": "Условие", + "fr": "Condition" + }, + "if_not_acknowledged_5fc93a": { + "en": "If not acknowledged", + "ru": "Если не подтверждён", + "fr": "Si non acquittée" + }, + "if_not_closed_4369ad": { + "en": "If not closed", + "ru": "Если не закрыт", + "fr": "Si non fermée" + }, + "notify_6c03ee": { + "en": "Notify", + "ru": "Уведомить", + "fr": "Notifier" + }, + "everyone_on_the_team_235b55": { + "en": "Everyone on the team", + "ru": "Всех участников команды", + "fr": "Toute l’équipe" + }, + "a_random_team_member_8f35f3": { + "en": "A random team member", + "ru": "Случайного участника команды", + "fr": "Un membre aléatoire de l’équipe" + }, + "remove_step_088d48": { + "en": "Remove step", + "ru": "Удалить шаг", + "fr": "Supprimer l’étape" + }, + "escalation_step_72b706": { + "en": "+ Escalation step", + "ru": "+ Шаг эскалации", + "fr": "+ Étape d’escalade" + }, + "repeat_count_912e08": { + "en": "Repeat count", + "ru": "Число повторов", + "fr": "Nombre de répétitions" + }, + "wait_between_repeats_0_disables_2e1378": { + "en": "Wait between repeats (0 disables)", + "ru": "Пауза между повторами (0 — отключить)", + "fr": "Délai entre répétitions (0 pour désactiver)" + }, + "reset_recipient_states_on_repeat_8152c6": { + "en": "Reset recipient states on repeat", + "ru": "Сбрасывать состояния получателей при повторе", + "fr": "Réinitialiser l’état des destinataires à chaque répétition" + }, + "close_alert_after_all_repeats_2bec22": { + "en": "Close alert after all repeats", + "ru": "Закрыть алерт после всех повторов", + "fr": "Fermer l’alerte après toutes les répétitions" + }, + "rule_name_9e84d7": { + "en": "Rule name", + "ru": "Название правила", + "fr": "Nom de la règle" + }, + "position_cf1c85": { + "en": "Position", + "ru": "Позиция", + "fr": "Position" + }, + "1_first_6d7898": { + "en": "1 · First", + "ru": "1 · Первое", + "fr": "1 · Première" + }, + "all_conditions_match_e5187c": { + "en": "All conditions match", + "ru": "Все условия выполняются", + "fr": "Toutes les conditions correspondent" + }, + "when_e3eafa": { + "en": "WHEN", + "ru": "КОГДА", + "fr": "QUAND" + }, + "field_c326a4": { + "en": "Field", + "ru": "Поле", + "fr": "Champ" + }, + "tags_848eed": { + "en": "Tags", + "ru": "Теги", + "fr": "Étiquettes" + }, + "priority_886cbf": { + "en": "Priority", + "ru": "Приоритет", + "fr": "Priorité" + }, + "message_68f414": { + "en": "Message", + "ru": "Сообщение", + "fr": "Message" + }, + "description_55f8eb": { + "en": "Description", + "ru": "Описание", + "fr": "Description" + }, + "operation_430d32": { + "en": "Operation", + "ru": "Операция", + "fr": "Opération" + }, + "contains_33e15d": { + "en": "Contains", + "ru": "Содержит", + "fr": "Contient" + }, + "equals_09b6a6": { + "en": "Equals", + "ru": "Равно", + "fr": "Égal à" + }, + "starts_with_2a61f5": { + "en": "Starts with", + "ru": "Начинается с", + "fr": "Commence par" + }, + "value_8dce17": { + "en": "Value", + "ru": "Значение", + "fr": "Valeur" + }, + "not_ca1dd3": { + "en": "Not", + "ru": "Не", + "fr": "Non" + }, + "remove_condition_858fb1": { + "en": "Remove condition", + "ru": "Удалить условие", + "fr": "Supprimer la condition" + }, + "and_condition_63ce48": { + "en": "+ AND condition", + "ru": "+ Условие И", + "fr": "+ Condition ET" + }, + "only_during_working_hours_fa1eb6": { + "en": "Only during working hours", + "ru": "Только в рабочее время", + "fr": "Uniquement pendant les heures de travail" + }, + "uses_the_shared_workspace_interval_change_it_in_team_01fcc4": { + "en": "Uses the workspace interval. Change it in Workspace settings → Workspace time.", + "ru": "Использует рабочий интервал пространства. Изменить: Настройки пространства → Время пространства.", + "fr": "Utilise les horaires de l’espace. Modifiez-les dans Paramètres de l’espace → Horaires." + }, + "provider_7ceee3": { + "en": "Provider", + "ru": "Сервис", + "fr": "Fournisseur" + }, + "local_preview_no_external_writes_0c302e": { + "en": "Local preview (no external writes)", + "ru": "Локальный предпросмотр (без внешней записи)", + "fr": "Aperçu local (sans écriture externe)" + }, + "atlassian_cloud_id_829908": { + "en": "Atlassian cloud ID", + "ru": "ID облака Atlassian", + "fr": "ID du cloud Atlassian" + }, + "jsm_team_id_70f05e": { + "en": "JSM team ID", + "ru": "ID команды JSM", + "fr": "ID de l’équipe JSM" + }, + "premium_enterprise_advanced_escalations_available_483732": { + "en": "Premium / Enterprise advanced escalations available", + "ru": "Доступны расширенные эскалации Premium / Enterprise", + "fr": "Escalades avancées Premium / Enterprise disponibles" + }, + "atlassian_accounts_a9aeb6": { + "en": "Atlassian accounts", + "ru": "Аккаунты Atlassian", + "fr": "Comptes Atlassian" + }, + "emails_resolve_automatically_to_existing_active_atla_e073f6": { + "en": "Emails resolve automatically to existing active Atlassian accounts before publication. Explicit account IDs take precedence; use one if an email is hidden by Atlassian.", + "ru": "Перед публикацией email автоматически сопоставляются с активными аккаунтами Atlassian. Явно заданный ID приоритетнее; укажите его, если email скрыт в Atlassian.", + "fr": "Avant publication, les e-mails sont associés aux comptes Atlassian actifs. Les ID explicites sont prioritaires ; utilisez-en un si Atlassian masque l’e-mail." + }, + "account_id_optional_when_email_is_available_d135c3": { + "en": "Account ID (optional when email is available)", + "ru": "ID аккаунта (необязателен при наличии email)", + "fr": "ID du compte (facultatif si l’e-mail est disponible)" + }, + "ask_your_deployment_administrator_to_configure_crede_782891": { + "en": "Ask your deployment administrator to configure credentials for this workspace. Secrets are never entered or shown here.", + "ru": "Попросите администратора настроить учётные данные сервиса для этого пространства. Секреты здесь не вводятся и не показываются.", + "fr": "Demandez à l’administrateur de configurer les identifiants de cet espace. Les secrets ne sont jamais saisis ni affichés ici." + }, + "cancel_77dfd2": { + "en": "Cancel", + "ru": "Отмена", + "fr": "Annuler" + }, + "request_approval_instead_b84265": { + "en": "Request approval instead", + "ru": "Запросить согласование", + "fr": "Demander un accord à la place" + }, + "accept_cover_d9a527": { + "en": "Accept & cover", + "ru": "Принять и подменить", + "fr": "Accepter et remplacer" + }, + "send_request_a89d64": { + "en": "Send request", + "ru": "Отправить запрос", + "fr": "Envoyer la demande" + }, + "offer_to_cover_054c95": { + "en": "Offer to cover", + "ru": "Предложить подмену", + "fr": "Proposer de remplacer" + }, + "saving_56a228": { + "en": "Saving…", + "ru": "Сохранение…", + "fr": "Enregistrement…" + }, + "remove_redirect_references_f1a241": { + "en": "Remove & redirect references", + "ru": "Удалить и перенаправить ссылки", + "fr": "Supprimer et rediriger les références" + }, + "remove_schedule_affected_steps_7348af": { + "en": "Remove schedule & affected steps", + "ru": "Удалить расписание и затронутые шаги", + "fr": "Supprimer le planning et les étapes concernées" + }, + "import_new_holidays_b36db6": { + "en": "Import new holidays", + "ru": "Импортировать новые праздники", + "fr": "Importer les nouveaux jours fériés" + }, + "send_for_confirmation_f3df0f": { + "en": "Send for confirmation", + "ru": "Отправить на подтверждение", + "fr": "Envoyer pour validation" + }, + "apply_replacement_9b0845": { + "en": "Apply replacement", + "ru": "Применить подмену", + "fr": "Appliquer le remplacement" + }, + "use_in_draft_a8f78c": { + "en": "Use in draft", + "ru": "Добавить в черновик", + "fr": "Utiliser dans le brouillon" + }, + "save_changes_179359": { + "en": "Save changes", + "ru": "Сохранить изменения", + "fr": "Enregistrer les modifications" + }, + "data_rules_schedules_a64885": { + "en": "Data → Rules → Schedules", + "ru": "Данные → Правила → Расписания", + "fr": "Données → Règles → Plannings" + }, + "review_and_apply_your_changes_together_76e548": { + "en": "Review and apply your changes together.", + "ru": "Проверьте и примените изменения вместе.", + "fr": "Vérifiez et appliquez les modifications ensemble." + }, + "reload_saved_state_1b1cb9": { + "en": "Reload saved state", + "ru": "Загрузить сохранённое состояние", + "fr": "Recharger l’état enregistré" + }, + "apply_checked_changes_efa2a4": { + "en": "Apply checked changes", + "ru": "Применить проверенные изменения", + "fr": "Appliquer les modifications vérifiées" + }, + "workspace_variables_2c8617": { + "en": "Workspace variables", + "ru": "Переменные пространства", + "fr": "Variables de l’espace" + }, + "variable_596d48": { + "en": "+ Variable", + "ru": "+ Переменная", + "fr": "+ Variable" + }, + "projects_people_and_shared_values_0cd0a8": { + "en": "Projects, people, and shared values.", + "ru": "Проекты, люди и общие значения.", + "fr": "Projets, personnes et valeurs partagées." + }, + "symbol_1fdf0d": { + "en": "·", + "ru": "·", + "fr": "·" + }, + "edit_variable_22ec4a": { + "en": "Edit variable ", + "ru": "Изменить переменную ", + "fr": "Modifier la variable " + }, + "remove_variable_bdd11d": { + "en": "Remove variable ", + "ru": "Удалить переменную ", + "fr": "Supprimer la variable " + }, + "no_variables_yet_start_with_a_list_of_projects_and_a_84c8f7": { + "en": "No variables yet. Start with a list of projects and a dictionary of responsible people.", + "ru": "Переменных пока нет. Начните со списка проектов и словаря ответственных.", + "fr": "Aucune variable. Commencez par une liste de projets et un dictionnaire de responsables." + }, + "team_generation_rules_f882ba": { + "en": "Generation rules", + "ru": "Правила генерации", + "fr": "Règles de génération" + }, + "generation_rule_610cb3": { + "en": "+ Generation rule", + "ru": "+ Правило генерации", + "fr": "+ Règle de génération" + }, + "turn_shared_values_into_schedules_automatically_66a530": { + "en": "Turn shared values into schedules automatically.", + "ru": "Автоматически создавайте расписания из общих данных.", + "fr": "Transformez automatiquement les données partagées en plannings." + }, + "enabled_df174a": { + "en": "Enabled", + "ru": "Включено", + "fr": "Activé" + }, + "disabled_f4f447": { + "en": "Disabled", + "ru": "Выключено", + "fr": "Désactivé" + }, + "iterations_e61016": { + "en": " iterations · ", + "ru": " итераций · ", + "fr": " itérations · " + }, + "output_blocks_22e1ee": { + "en": " output blocks", + "ru": " блоков результата", + "fr": " blocs de sortie" + }, + "move_rule_up_a8ab03": { + "en": "Move rule up", + "ru": "Переместить правило выше", + "fr": "Monter la règle" + }, + "move_rule_down_2c58ed": { + "en": "Move rule down", + "ru": "Переместить правило ниже", + "fr": "Descendre la règle" + }, + "edit_rule_d0838a": { + "en": "Edit rule ", + "ru": "Изменить правило ", + "fr": "Modifier la règle " + }, + "remove_rule_2dd5ee": { + "en": "Remove rule ", + "ru": "Удалить правило ", + "fr": "Supprimer la règle " + }, + "one_rule_can_produce_all_your_project_schedules_bb9e45": { + "en": "One rule can produce all your project schedules.", + "ru": "Одно правило может создать расписания для всех проектов.", + "fr": "Une seule règle peut produire tous les plannings de vos projets." + }, + "generated_routing_block_position_e14d64": { + "en": "Where to place generated rules", + "ru": "Где разместить сгенерированные правила", + "fr": "Placement des règles générées" + }, + "first_before_manual_routing_rules_b44e75": { + "en": "Before manual rules", + "ru": "Перед ручными", + "fr": "Avant les règles manuelles" + }, + "after_5c0dbf": { + "en": "After ", + "ru": "После ", + "fr": "Après " + }, + "manual_rules_3e27ea": { + "en": " manual rules", + "ru": " ручных правил", + "fr": " règles manuelles" + }, + "start_with_projects_responsible_example_7da288": { + "en": "Start with projects & responsible example", + "ru": "Начать с примера проектов и ответственных", + "fr": "Commencer avec l’exemple projets et responsables" + }, + "variable_name_39179c": { + "en": "Variable name", + "ru": "Имя переменной", + "fr": "Nom de la variable" + }, + "responsible_37926b": { + "en": "responsible", + "ru": "responsible", + "fr": "responsible" + }, + "simple_editor_33ff22": { + "en": "Simple editor", + "ru": "Простой редактор", + "fr": "Éditeur simple" + }, + "edit_structure_types_3c202b": { + "en": "Edit structure & types", + "ru": "Изменить структуру и типы", + "fr": "Modifier la structure et les types" + }, + "list_value_4318c3": { + "en": "List value", + "ru": "Значение списка", + "fr": "Valeur de liste" + }, + "move_entry_up_647a57": { + "en": "Move entry up", + "ru": "Переместить элемент выше", + "fr": "Monter l’entrée" + }, + "move_entry_down_bc86af": { + "en": "Move entry down", + "ru": "Переместить элемент ниже", + "fr": "Descendre l’entrée" + }, + "remove_entry_18ffc8": { + "en": "Remove entry", + "ru": "Удалить элемент", + "fr": "Supprimer l’entrée" + }, + "add_item_51e41d": { + "en": "+ Add item", + "ru": "+ Добавить элемент", + "fr": "+ Ajouter un élément" + }, + "paste_rows_24d667": { + "en": "Paste rows", + "ru": "Вставить строки", + "fr": "Coller des lignes" + }, + "project_key_1c60ec": { + "en": "Project / key", + "ru": "Проект / ключ", + "fr": "Projet / clé" + }, + "responsible_people_in_order_d69f7b": { + "en": "Responsible people · in order", + "ru": "Ответственные · по порядку", + "fr": "Responsables · dans l’ordre" + }, + "dictionary_key_9777bd": { + "en": "Dictionary key", + "ru": "Ключ словаря", + "fr": "Clé du dictionnaire" + }, + "project_name_ab9773": { + "en": "Project name", + "ru": "Название проекта", + "fr": "Nom du projet" + }, + "move_person_earlier_52af52": { + "en": "Move person earlier", + "ru": "Переместить участника раньше", + "fr": "Avancer la personne" + }, + "symbol_817964": { + "en": "←", + "ru": "←", + "fr": "←" + }, + "remove_person_2ea467": { + "en": "Remove person", + "ru": "Удалить участника", + "fr": "Retirer la personne" + }, + "add_person_to_4670b9": { + "en": "Add person to ", + "ru": "Добавить участника в ", + "fr": "Ajouter une personne à " + }, + "person_485c1d": { + "en": "+ Person", + "ru": "+ Участник", + "fr": "+ Personne" + }, + "remove_project_333b0c": { + "en": "Remove project", + "ru": "Удалить проект", + "fr": "Supprimer le projet" + }, + "add_project_key_71d0f2": { + "en": "+ Add project / key", + "ru": "+ Добавить проект / ключ", + "fr": "+ Ajouter un projet / une clé" + }, + "paste_spreadsheet_rows_e07917": { + "en": "Paste spreadsheet rows", + "ru": "Вставить строки таблицы", + "fr": "Coller des lignes de tableau" + }, + "add_to_10956d": { + "en": "Add to ", + "ru": "Добавить в ", + "fr": "Ajouter à " + }, + "done_e9b450": { + "en": "Done", + "ru": "Готово", + "fr": "Terminé" + }, + "find_person_or_email_b640f7": { + "en": "Find person or email", + "ru": "Найти участника или email", + "fr": "Rechercher une personne ou un e-mail" + }, + "search_name_or_enter_email_863139": { + "en": "Search name or enter email…", + "ru": "Найти по имени или ввести email…", + "fr": "Rechercher un nom ou saisir un e-mail…" + }, + "project_key_98f3fa": { + "en": "Project key", + "ru": "Ключ проекта", + "fr": "Clé du projet" + }, + "item_284e12": { + "en": "Item ", + "ru": "Элемент ", + "fr": "Élément " + }, + "value_type_a95648": { + "en": "Value type", + "ru": "Тип значения", + "fr": "Type de valeur" + }, + "duration_minutes_77d597": { + "en": "duration (minutes)", + "ru": "длительность (минуты)", + "fr": "durée (minutes)" + }, + "entries_7c3fe9": { + "en": " entries", + "ru": " элементов", + "fr": " entrées" + }, + "entry_dd9298": { + "en": "+ Entry", + "ru": "+ Элемент", + "fr": "+ Entrée" + }, + "text_value_9fe378": { + "en": "Text value", + "ru": "Текстовое значение", + "fr": "Valeur texte" + }, + "registered_person_734f05": { + "en": "Registered person", + "ru": "Зарегистрированный пользователь", + "fr": "Personne inscrite" + }, + "use_email_address_c12ddf": { + "en": "Use email address", + "ru": "Указать email", + "fr": "Utiliser une adresse e-mail" + }, + "person_email_1ed8a9": { + "en": "Person email", + "ru": "Email участника", + "fr": "E-mail de la personne" + }, + "person_example_com_371ab9": { + "en": "person@example.com", + "ru": "person@example.com", + "fr": "person@example.com" + }, + "number_value_f84357": { + "en": "Number value", + "ru": "Числовое значение", + "fr": "Valeur numérique" + }, + "boolean_value_4d92eb": { + "en": "Boolean value", + "ru": "Логическое значение", + "fr": "Valeur booléenne" + }, + "true_88b33e": { + "en": "True", + "ru": "Да", + "fr": "Vrai" + }, + "false_97cdbd": { + "en": "False", + "ru": "Нет", + "fr": "Faux" + }, + "time_value_1072a0": { + "en": "Time value", + "ru": "Время", + "fr": "Heure" + }, + "date_value_1f290a": { + "en": "Date value", + "ru": "Дата", + "fr": "Date" + }, + "append_rows_03d5d2": { + "en": "Append rows", + "ru": "Добавить строки", + "fr": "Ajouter les lignes" + }, + "one_value_per_line_existing_entries_are_kept_244563": { + "en": "One value per line. Existing entries are kept.", + "ru": "Одно значение на строку. Существующие элементы сохранятся.", + "fr": "Une valeur par ligne. Les entrées existantes sont conservées." + }, + "paste_two_spreadsheet_columns_project_key_then_comma_9b3966": { + "en": "Paste two spreadsheet columns: project key, then comma-separated emails. Columns must be separated by a TAB.", + "ru": "Вставьте два столбца: ключ проекта и email через запятую. Столбцы разделяются табуляцией.", + "fr": "Collez deux colonnes : la clé du projet, puis les e-mails séparés par des virgules. Séparez les colonnes par une tabulation." + }, + "rows_to_append_2ddee4": { + "en": "Rows to append", + "ru": "Строки для добавления", + "fr": "Lignes à ajouter" + }, + "cancel_paste_fbbf61": { + "en": "Cancel paste", + "ru": "Отменить вставку", + "fr": "Annuler le collage" + }, + "project_rotations_1bf6a8": { + "en": "Project rotations", + "ru": "Ротации проектов", + "fr": "Rotations des projets" + }, + "for_each_5e8ebd": { + "en": "For each", + "ru": "Для каждого", + "fr": "Pour chaque" + }, + "iteration_3b56cf": { + "en": "+ Iteration", + "ru": "+ Итерация", + "fr": "+ Itération" + }, + "no_loop_create_one_instance_add_an_iteration_to_crea_750db8": { + "en": "No loop: create one instance. Add an iteration to create outputs for every list or dictionary entry.", + "ru": "Без цикла создаётся один экземпляр. Добавьте итерацию, чтобы создавать результаты для каждого элемента списка или словаря.", + "fr": "Sans boucle : une seule instance. Ajoutez une itération pour créer des résultats pour chaque entrée de liste ou de dictionnaire." + }, + "then_for_each_4ca48c": { + "en": "THEN FOR EACH", + "ru": "ЗАТЕМ ДЛЯ КАЖДОГО", + "fr": "PUIS POUR CHAQUE" + }, + "for_each_fb4174": { + "en": "FOR EACH", + "ru": "ДЛЯ КАЖДОГО", + "fr": "POUR CHAQUE" + }, + "iteration_name_fc3d26": { + "en": "Iteration name", + "ru": "Название итерации", + "fr": "Nom de l’itération" + }, + "project_f6f4da": { + "en": "Project", + "ru": "Проект", + "fr": "Projet" + }, + "iteration_collection_bd6344": { + "en": "Iteration collection", + "ru": "Коллекция для перебора", + "fr": "Collection à parcourir" + }, + "only_generate_when_a_condition_matches_78d185": { + "en": "Only generate when a condition matches", + "ru": "Генерировать только при выполнении условия", + "fr": "Générer uniquement si une condition correspond" + }, + "condition_left_value_cc4063": { + "en": "Condition left value", + "ru": "Левая часть условия", + "fr": "Valeur gauche de la condition" + }, + "generation_condition_424e30": { + "en": "Generation condition", + "ru": "Условие генерации", + "fr": "Condition de génération" + }, + "does_not_equal_86e30f": { + "en": "Does not equal", + "ru": "Не равно", + "fr": "Différent de" + }, + "is_not_empty_414a30": { + "en": "Is not empty", + "ru": "Не пусто", + "fr": "N’est pas vide" + }, + "condition_right_value_5e741f": { + "en": "Condition right value", + "ru": "Правая часть условия", + "fr": "Valeur droite de la condition" + }, + "create_outputs_dd2376": { + "en": "Create outputs", + "ru": "Создать ресурсы", + "fr": "Créer des ressources" + }, + "add_output_block_00047b": { + "en": "Add output block", + "ru": "Добавить блок результата", + "fr": "Ajouter un bloc de sortie" + }, + "output_block_29f985": { + "en": "+ Output block", + "ru": "+ Блок результата", + "fr": "+ Bloc de sortie" + }, + "ordered_project_coverage_cf12c3": { + "en": "First available", + "ru": "Первый доступный", + "fr": "Premier disponible" + }, + "two_person_coverage_c48bee": { + "en": "First available", + "ru": "Первый доступный", + "fr": "Premier disponible" + }, + "move_block_up_b67b5b": { + "en": "Move block up", + "ru": "Переместить блок выше", + "fr": "Monter le bloc" + }, + "move_block_down_714fea": { + "en": "Move block down", + "ru": "Переместить блок ниже", + "fr": "Descendre le bloc" + }, + "bind_891f4e": { + "en": "Bind ", + "ru": "Связать ", + "fr": "Lier " + }, + "delay_in_minutes_e18c95": { + "en": "Delay in minutes", + "ru": "Задержка в минутах", + "fr": "Délai en minutes" + }, + "escalation_condition_5b4c13": { + "en": "Escalation condition", + "ru": "Условие эскалации", + "fr": "Condition d’escalade" + }, + "recipient_type_32b1f5": { + "en": "Recipient type", + "ru": "Тип получателя", + "fr": "Type de destinataire" + }, + "person_8c41ae": { + "en": "Person", + "ru": "Участник", + "fr": "Personne" + }, + "escalation_recipient_9dfd31": { + "en": "Escalation recipient", + "ru": "Получатель эскалации", + "fr": "Destinataire de l’escalade" + }, + "matches_any_alert_add_conditions_to_target_this_proj_299abf": { + "en": "Matches any alert. Add conditions to narrow the match.", + "ru": "Совпадает с любым алертом. Добавьте условия для отбора.", + "fr": "Correspond à toute alerte. Ajouter des conditions pour restreindre la sélection." + }, + "routing_field_a63fdc": { + "en": "Routing field", + "ru": "Поле маршрутизации", + "fr": "Champ de routage" + }, + "source_6da13a": { + "en": "Source", + "ru": "Источник", + "fr": "Source" + }, + "routing_operation_01689c": { + "en": "Routing operation", + "ru": "Операция маршрутизации", + "fr": "Opération de routage" + }, + "matches_ee2dbd": { + "en": "Matches", + "ru": "Соответствует шаблону", + "fr": "Correspond au motif" + }, + "routing_condition_value_f76e2d": { + "en": "Routing condition value", + "ru": "Значение условия маршрутизации", + "fr": "Valeur de la condition de routage" + }, + "value_binding_1bb7eb": { + "en": "Value binding", + "ru": "Привязка значения", + "fr": "Liaison de valeur" + }, + "dictionary_key_source_b4a40b": { + "en": "Dictionary key source", + "ru": "Источник ключа словаря", + "fr": "Source de la clé du dictionnaire" + }, + "value_source_c0ae15": { + "en": "Value source", + "ru": "Источник значения", + "fr": "Source de la valeur" + }, + "fixed_value_191429": { + "en": "Fixed value", + "ru": "Постоянное значение", + "fr": "Valeur fixe" + }, + "current_iteration_value_39357a": { + "en": "Current iteration value", + "ru": "Значение текущей итерации", + "fr": "Valeur de l’itération courante" + }, + "current_iteration_key_347e79": { + "en": "Current iteration key", + "ru": "Ключ текущей итерации", + "fr": "Clé de l’itération courante" + }, + "dictionary_lookup_f38672": { + "en": "Dictionary lookup", + "ru": "Поиск в словаре", + "fr": "Recherche dans un dictionnaire" + }, + "created_resource_c04e1e": { + "en": "From a generated resource", + "ru": "Из генерируемого ресурса", + "fr": "Depuis une ressource générée" + }, + "values_one_per_line_b8e245": { + "en": "Values, one per line", + "ru": "Значения, по одному в строке", + "fr": "Valeurs, une par ligne" + }, + "fixed_list_a199a2": { + "en": "Fixed list", + "ru": "Постоянный список", + "fr": "Liste fixe" + }, + "alice_example_com_bob_example_com_f2697e": { + "en": "alice@example.com\nbob@example.com", + "ru": "alice@example.com\nbob@example.com", + "fr": "alice@example.com\nbob@example.com" + }, + "fixed_text_ab18dc": { + "en": "Fixed text", + "ru": "Постоянный текст", + "fr": "Texte fixe" + }, + "fixed_number_017ee6": { + "en": "Fixed number", + "ru": "Постоянное число", + "fr": "Nombre fixe" + }, + "fixed_boolean_38876f": { + "en": "Fixed boolean", + "ru": "Постоянное логическое значение", + "fr": "Booléen fixe" + }, + "fixed_time_40176a": { + "en": "Fixed time", + "ru": "Постоянное время", + "fr": "Heure fixe" + }, + "fixed_date_1c63b4": { + "en": "Fixed date", + "ru": "Постоянная дата", + "fr": "Date fixe" + }, + "workspace_person_844f8f": { + "en": "Workspace person", + "ru": "Участник пространства", + "fr": "Personne de l’espace" + }, + "email_84add5": { + "en": "Email", + "ru": "Email", + "fr": "E-mail" + }, + "fixed_email_189696": { + "en": "Fixed email", + "ru": "Постоянный email", + "fr": "E-mail fixe" + }, + "existing_resource_1752f5": { + "en": "Existing resource", + "ru": "Существующий ресурс", + "fr": "Ressource existante" + }, + "choose_a_resource_3e0493": { + "en": "Choose a resource", + "ru": "Выберите ресурс", + "fr": "Choisir une ressource" + }, + "variable_19de69": { + "en": "Variable", + "ru": "Переменная", + "fr": "Variable" + }, + "binding_variable_137329": { + "en": "Binding variable", + "ru": "Переменная привязки", + "fr": "Variable de liaison" + }, + "choose_a_variable_e27462": { + "en": "Choose a variable", + "ru": "Выберите переменную", + "fr": "Choisir une variable" + }, + "iteration_18905a": { + "en": "Iteration", + "ru": "Итерация", + "fr": "Itération" + }, + "binding_iteration_f1778d": { + "en": "Binding iteration", + "ru": "Итерация привязки", + "fr": "Itération de liaison" + }, + "choose_an_iteration_5d2162": { + "en": "Choose an iteration", + "ru": "Выберите итерацию", + "fr": "Choisir une itération" + }, + "output_block_3853ab": { + "en": "Generated resource", + "ru": "Генерируемый ресурс", + "fr": "Ressource générée" + }, + "binding_output_block_e2b0ab": { + "en": "Generated resource", + "ru": "Генерируемый ресурс", + "fr": "Ressource générée" + }, + "choose_an_output_747632": { + "en": "Choose a generated resource", + "ru": "Выберите генерируемый ресурс", + "fr": "Choisissez une ressource générée" + }, + "nested_field_list_position_optional_bac49a": { + "en": "Nested field / list position (optional)", + "ru": "Вложенное поле / позиция списка (необязательно)", + "fr": "Champ imbriqué / position de liste (facultatif)" + }, + "nested_field_or_position_eaeb07": { + "en": "Nested field or position", + "ru": "Вложенное поле или позиция", + "fr": "Champ imbriqué ou position" + }, + "primary_1_4b34b8": { + "en": "primary / 1", + "ru": "primary / 1", + "fr": "primary / 1" + }, + "leave_empty_to_use_the_whole_value_list_positions_st_8da342": { + "en": "Leave empty to use the whole value. List positions start at 1.", + "ru": "Оставьте пустым для всего значения. Позиции в списке начинаются с 1.", + "fr": "Laissez vide pour utiliser la valeur entière. Les positions commencent à 1." + }, + "prefix_90eceb": { + "en": "Prefix", + "ru": "Префикс", + "fr": "Préfixe" + }, + "text_prefix_62b30b": { + "en": "Text prefix", + "ru": "Текстовый префикс", + "fr": "Préfixe de texte" + }, + "optional_0c6c41": { + "en": "Optional", + "ru": "Необязательно", + "fr": "Facultatif" + }, + "suffix_885db9": { + "en": "Suffix", + "ru": "Суффикс", + "fr": "Suffixe" + }, + "text_suffix_f191fa": { + "en": "Text suffix", + "ru": "Текстовый суффикс", + "fr": "Suffixe de texte" + }, + "server_preview_09022a": { + "en": "Server preview", + "ru": "Проверка на сервере", + "fr": "Aperçu serveur" + }, + "checking_data_references_permissions_and_generated_s_ffca12": { + "en": "Checking data, references, permissions, and generated schedules…", + "ru": "Проверяем данные, ссылки, права и сгенерированные расписания…", + "fr": "Vérification des données, références, autorisations et plannings générés…" + }, + "to_create_43ed95": { + "en": " to create · ", + "ru": " создать · ", + "fr": " à créer · " + }, + "to_update_31fbac": { + "en": " to update · ", + "ru": " изменить · ", + "fr": " à modifier · " + }, + "to_remove_29c5ac": { + "en": " to remove · ", + "ru": " удалить · ", + "fr": " à supprimer · " + }, + "unchanged_ce74d2": { + "en": " unchanged", + "ru": " без изменений", + "fr": " inchangés" + }, + "issues_prevent_applying_this_draft_509aaa": { + "en": " issues prevent applying this draft", + "ru": " ошибок мешают применить черновик", + "fr": " erreurs empêchent l’application du brouillon" + }, + "showing_the_first_30_issues_fix_these_to_continue_3e3210": { + "en": "Showing the first 30 issues. Fix these to continue.", + "ru": "Показаны первые 30 ошибок. Исправьте их, чтобы продолжить.", + "fr": "Les 30 premières erreurs sont affichées. Corrigez-les pour continuer." + }, + "valid_configuration_no_changes_have_been_saved_by_th_114934": { + "en": "Valid configuration. No changes have been saved by this preview.", + "ru": "Конфигурация корректна. Предпросмотр не сохраняет изменения.", + "fr": "Configuration valide. Cet aperçu n’a enregistré aucune modification." + }, + "pending_requests_will_be_cancelled_because_their_sch_ea9b18": { + "en": " pending requests will be cancelled because their schedules are being removed.", + "ru": " ожидающих запросов будут отменены, поскольку их расписания удаляются.", + "fr": " demandes en attente seront annulées car leurs plannings sont supprimés." + }, + "team_source_item_d10c3e": { + "en": "Source item", + "ru": "Источник", + "fr": "Élément source" + }, + "resource_021493": { + "en": "Resource", + "ru": "Ресурс", + "fr": "Ressource" + }, + "change_64fbd9": { + "en": "Change", + "ru": "Изменение", + "fr": "Modification" + }, + "single_instance_785a47": { + "en": "Single instance", + "ru": "Один экземпляр", + "fr": "Instance unique" + }, + "show_fewer_resources_3953fb": { + "en": "Show fewer resources", + "ru": "Показать меньше ресурсов", + "fr": "Afficher moins de ressources" + }, + "resources_015def": { + "en": " resources", + "ru": " ресурсов", + "fr": " ressources" + }, + "enter_a_valid_date_as_dd_mm_yyyy_f6df03": { + "en": "Enter a valid date as DD-MM-YYYY.", + "ru": "Введите корректную дату в формате ДД-ММ-ГГГГ.", + "fr": "Saisissez une date valide au format JJ-MM-AAAA." + }, + "all_countries_legacy_04451d": { + "en": "All countries (legacy)", + "ru": "Все страны (старый формат)", + "fr": "Tous les pays (ancien format)" + }, + "not_975146": { + "en": "NOT ", + "ru": "НЕ ", + "fr": "NON " + }, + "calendar_adab50": { + "en": "Calendar", + "ru": "Календарь", + "fr": "Calendrier" + }, + "requests_f7194e": { + "en": "Requests", + "ru": "Запросы", + "fr": "Demandes" + }, + "schedules_26d9d0": { + "en": "Schedules", + "ru": "Расписания", + "fr": "Plannings" + }, + "escalations_routing_af3e1b": { + "en": "Escalations & routing", + "ru": "Эскалации и маршрутизация", + "fr": "Escalades et routage" + }, + "variables_rules_9bb3e0": { + "en": "Variables & rules", + "ru": "Переменные и правила", + "fr": "Variables et règles" + }, + "activity_81c0d9": { + "en": "Activity", + "ru": "История действий", + "fr": "Activité" + }, + "team_settings_06b774": { + "en": "Workspace settings", + "ru": "Настройки пространства", + "fr": "Paramètres de l’espace" + }, + "view_calendar_b702b1": { + "en": "View calendar", + "ru": "Просмотр календаря", + "fr": "Voir le calendrier" + }, + "request_replacements_386c5e": { + "en": "Request replacements", + "ru": "Запрос подмен", + "fr": "Demander des remplacements" + }, + "edit_schedules_policies_a02744": { + "en": "Edit schedules & policies", + "ru": "Редактирование расписаний и политик", + "fr": "Modifier les plannings et politiques" + }, + "manage_team_access_db8b53": { + "en": "Manage workspace access", + "ru": "Управление доступом к пространству", + "fr": "Gérer l’accès à l’espace" + }, + "manage_provider_804901": { + "en": "Manage provider", + "ru": "Управление сервисом", + "fr": "Gérer le fournisseur" + }, + "view_audit_history_896487": { + "en": "View audit history", + "ru": "Просмотр истории действий", + "fr": "Voir l’historique d’audit" + }, + "unknown_employee_f68692": { + "en": "Unknown employee", + "ru": "Неизвестный сотрудник", + "fr": "Employé inconnu" + }, + "archived_schedule_40f4da": { + "en": "Archived schedule", + "ru": "Архивное расписание", + "fr": "Planning archivé" + }, + "the_request_failed_02d674": { + "en": "The request failed", + "ru": "Запрос не выполнен", + "fr": "La demande a échoué" + }, + "daily_support_30fec0": { + "en": "Daily support", + "ru": "Ежедневная поддержка", + "fr": "Assistance quotidienne" + }, + "project_matrix_7c3bed": { + "en": "Project matrix", + "ru": "Матрица проектов", + "fr": "Matrice de projets" + }, + "enter_a_complete_email_address_5f5f50": { + "en": "Enter a complete email address.", + "ru": "Введите полный email.", + "fr": "Saisissez une adresse e-mail complète." + }, + "complete_the_dates_and_choose_a_person_for_each_repl_6187c4": { + "en": "Complete the dates and choose a person for each replacement range.", + "ru": "Заполните даты и выберите участника для каждого интервала подмены.", + "fr": "Renseignez les dates et choisissez une personne pour chaque période de remplacement." + }, + "choose_the_day_you_want_to_change_6ce301": { + "en": "Choose the day you want to change.", + "ru": "Выберите день для изменения.", + "fr": "Choisissez le jour à modifier." + }, + "replacement_accepted_publication_is_queued_afea8f": { + "en": "Replacement accepted. Publication is queued.", + "ru": "Подмена принята. Публикация в очереди.", + "fr": "Remplacement accepté. Publication en attente." + }, + "replacement_applied_publication_is_queued_89e283": { + "en": "Replacement applied. Publication is queued.", + "ru": "Подмена применена. Публикация в очереди.", + "fr": "Remplacement appliqué. Publication en attente." + }, + "request_sent_waiting_for_your_colleague_b70b64": { + "en": "Request sent. Waiting for your colleague.", + "ru": "Запрос отправлен. Ожидаем ответа коллеги.", + "fr": "Demande envoyée. En attente de votre collègue." + }, + "offers_to_cover_935d75": { + "en": " offers to cover ", + "ru": " предлагает подменить ", + "fr": " propose de remplacer " + }, + "needs_cover_9e1ff7": { + "en": " needs cover", + "ru": " просит подмену", + "fr": " demande un remplacement" + }, + "preview_the_csv_and_fix_any_errors_before_importing_2ccc4c": { + "en": "Preview the CSV and fix any errors before importing.", + "ru": "Проверьте CSV и исправьте ошибки перед импортом.", + "fr": "Vérifiez le CSV et corrigez les erreurs avant l’importation." + }, + "resolve_the_listed_dependencies_before_removing_this_fa1445": { + "en": "Resolve the listed dependencies before removing this schedule.", + "ru": "Исправьте указанные зависимости перед удалением расписания.", + "fr": "Résolvez les dépendances indiquées avant de supprimer ce planning." + }, + "wait_for_a_valid_absence_preview_before_saving_c743bf": { + "en": "Wait for a valid absence preview before saving.", + "ru": "Дождитесь успешной проверки отсутствия перед сохранением.", + "fr": "Attendez un aperçu d’absence valide avant d’enregistrer." + }, + "this_country_already_has_a_holiday_on_that_date_7a457a": { + "en": "This country already has a holiday on that date.", + "ru": "В этой стране уже есть праздник на эту дату.", + "fr": "Ce pays a déjà un jour férié à cette date." + }, + "changes_saved_5ffbaf": { + "en": "Changes saved.", + "ru": "Изменения сохранены.", + "fr": "Modifications enregistrées." + }, + "request_updated_c52ad2": { + "en": "Request updated.", + "ru": "Запрос обновлён.", + "fr": "Demande mise à jour." + }, + "configuration_updated_a81653": { + "en": "Configuration updated.", + "ru": "Конфигурация обновлена.", + "fr": "Configuration mise à jour." + }, + "move_route_a6e86d": { + "en": "\"] .move-route", + "ru": "\"] .move-route", + "fr": "\"] .move-route" + }, + "routing_order_saved_publication_is_queued_cfb972": { + "en": "Routing order saved. Publication is queued.", + "ru": "Порядок правил сохранён. Публикация в очереди.", + "fr": "Ordre de routage enregistré. Publication en attente." + }, + "drift_policy_updated_be40fd": { + "en": "Drift policy updated.", + "ru": "Политика внешних изменений обновлена.", + "fr": "Politique de dérive mise à jour." + }, + "publication_queued_8355e1": { + "en": "Publication queued.", + "ru": "Публикация в очереди.", + "fr": "Publication en attente." + }, + "publication_preview_downloaded_afd91a": { + "en": "Publication preview downloaded.", + "ru": "Предпросмотр публикации скачан.", + "fr": "Aperçu de publication téléchargé." + }, + "workspace_data_and_generated_schedules_saved_publica_e4ae3d": { + "en": "Workspace data and generated schedules saved. Publication is queued.", + "ru": "Данные пространства и расписания сохранены. Публикация в очереди.", + "fr": "Données de l’espace et plannings générés enregistrés. Publication en attente." + }, + "choose_person_f20051": { + "en": "Choose person", + "ru": "Выберите участника", + "fr": "Choisir une personne" + }, + "enter_a_name_86c810": { + "en": "Enter a name.", + "ru": "Введите название.", + "fr": "Saisissez un nom." + }, + "line_2b754a": { + "en": "Line ", + "ru": "Строка ", + "fr": "Ligne " + }, + "paste_a_project_key_a_tab_and_comma_separated_emails_2b81fc": { + "en": ": paste a project key, a TAB, and comma-separated emails.", + "ru": ": вставьте ключ проекта, табуляцию и email через запятую.", + "fr": ": collez une clé de projet, une tabulation et des e-mails séparés par des virgules." + }, + "shift_length_days_0b28fa": { + "en": "Shift length (days)", + "ru": "Длительность смены (дни)", + "fr": "Durée d’astreinte (jours)" + }, + "responsible_person_d575dc": { + "en": "Responsible person", + "ru": "Ответственный", + "fr": "Responsable" + }, + "responsible_people_in_priority_order_7205a5": { + "en": "Responsible people, in priority order", + "ru": "Ответственные по приоритету", + "fr": "Responsables par ordre de priorité" + }, + "minutes_between_repeats_f17ab0": { + "en": "Minutes between repeats", + "ru": "Минут между повторами", + "fr": "Minutes entre les répétitions" + }, + "new_61bd57": { + "en": "New ", + "ru": "Новый ", + "fr": "Nouveau " + }, + "choose_a_value_e0b399": { + "en": "Choose a value", + "ru": "Выберите значение", + "fr": "Choisir une valeur" + }, + "variable_2e46ef": { + "en": "Variable · ", + "ru": "Переменная · ", + "fr": "Variable · " + }, + "choose_variable_435bd9": { + "en": "Choose variable", + "ru": "Выберите переменную", + "fr": "Choisir une variable" + }, + "current_item_8d8efa": { + "en": "Current item", + "ru": "Текущий элемент", + "fr": "Élément courant" + }, + "key_b6898c": { + "en": " → key", + "ru": " → ключ", + "fr": " → clé" + }, + "value_2a618b": { + "en": " → value", + "ru": " → значение", + "fr": " → valeur" + }, + "dictionary_860e1f": { + "en": "Dictionary", + "ru": "Словарь", + "fr": "Dictionnaire" + }, + "key_bb778f": { + "en": " → key: ", + "ru": " → ключ: ", + "fr": " → clé : " + }, + "variables_projects_or_responsible_already_exist_add__83d3ed": { + "en": "Variables projects or responsible already exist. Add a rule and bind them, or rename the existing variables first.", + "ru": "Переменные projects или responsible уже существуют. Добавьте правило с ними или сначала переименуйте переменные.", + "fr": "Les variables projects ou responsible existent déjà. Ajoutez une règle qui les utilise ou renommez-les d’abord." + }, + "on_call_5d0267": { + "en": " · on-call", + "ru": " · дежурство", + "fr": " · astreinte" + }, + "example_draft_added_replace_the_example_emails_befor_e73113": { + "en": "Example draft added. Replace the example emails before applying.", + "ru": "Пример добавлен в черновик. Замените примерные email перед применением.", + "fr": "Exemple ajouté au brouillon. Remplacez les e-mails d’exemple avant d’appliquer." + }, + "language": { + "en": "Language", + "ru": "Язык", + "fr": "Langue" + }, + "language_auto": { + "en": "Browser language (automatic)", + "ru": "Язык браузера (автоматически)", + "fr": "Langue du navigateur (automatique)" + }, + "language_en": { + "en": "English", + "ru": "English", + "fr": "English" + }, + "language_ru": { + "en": "Русский", + "ru": "Русский", + "fr": "Русский" + }, + "language_fr": { + "en": "Français", + "ru": "Français", + "fr": "Français" + }, + "language_invalid": { + "en": "Choose a supported language", + "ru": "Выберите поддерживаемый язык", + "fr": "Choisissez une langue prise en charge" + }, + "enum_pending": { + "en": "Pending", + "ru": "Ожидает", + "fr": "En attente" + }, + "enum_applied": { + "en": "Applied", + "ru": "Применено", + "fr": "Appliqué" + }, + "enum_rejected": { + "en": "Declined", + "ru": "Отклонено", + "fr": "Refusé" + }, + "enum_cancelled": { + "en": "Cancelled", + "ru": "Отменено", + "fr": "Annulé" + }, + "enum_synced": { + "en": "Synced", + "ru": "Синхронизировано", + "fr": "Synchronisé" + }, + "enum_failed": { + "en": "Failed", + "ru": "Ошибка", + "fr": "Échec" + }, + "enum_conflict": { + "en": "Conflict", + "ru": "Конфликт", + "fr": "Conflit" + }, + "enum_running": { + "en": "Publishing", + "ru": "Публикуется", + "fr": "Publication en cours" + }, + "enum_create": { + "en": "Create", + "ru": "Создать", + "fr": "Créer" + }, + "enum_update": { + "en": "Update", + "ru": "Изменить", + "fr": "Modifier" + }, + "enum_remove": { + "en": "Remove", + "ru": "Удалить", + "fr": "Supprimer" + }, + "enum_unchanged": { + "en": "Unchanged", + "ru": "Без изменений", + "fr": "Inchangé" + }, + "enum_text": { + "en": "Text", + "ru": "Текст", + "fr": "Texte" + }, + "enum_person": { + "en": "Person", + "ru": "Участник", + "fr": "Personne" + }, + "enum_number": { + "en": "Number", + "ru": "Число", + "fr": "Nombre" + }, + "enum_boolean": { + "en": "Boolean", + "ru": "Логическое значение", + "fr": "Booléen" + }, + "enum_date": { + "en": "Date", + "ru": "Дата", + "fr": "Date" + }, + "enum_time": { + "en": "Time", + "ru": "Время", + "fr": "Heure" + }, + "enum_duration": { + "en": "Duration", + "ru": "Длительность", + "fr": "Durée" + }, + "enum_list": { + "en": "List", + "ru": "Список", + "fr": "Liste" + }, + "enum_dictionary": { + "en": "Dictionary / object", + "ru": "Словарь / объект", + "fr": "Dictionnaire / objet" + }, + "enum_rotation": { + "en": "Rotation", + "ru": "Ротация", + "fr": "Rotation" + }, + "enum_priority": { + "en": "First available", + "ru": "Первый доступный", + "fr": "Premier disponible" + }, + "enum_coverage": { + "en": "First available", + "ru": "Первый доступный", + "fr": "Premier disponible" + }, + "enum_escalation": { + "en": "Escalation", + "ru": "Эскалация", + "fr": "Escalade" + }, + "enum_route": { + "en": "Routing rule", + "ru": "Правило маршрутизации", + "fr": "Règle de routage" + }, + "enum_schedule": { + "en": "Schedule", + "ru": "Расписание", + "fr": "Planning" + }, + "enum_policy": { + "en": "Policy", + "ru": "Политика", + "fr": "Politique" + }, + "enum_true": { + "en": "True", + "ru": "Да", + "fr": "Vrai" + }, + "enum_false": { + "en": "False", + "ru": "Нет", + "fr": "Faux" + }, + "waiting_count": { + "en": "Requests awaiting a reply: {count}", + "ru": "Запросов без ответа: {count}", + "fr": "Demandes en attente de réponse : {count}" + }, + "cover_person": { + "en": "Cover {name}’s shift", + "ru": "Подменить на смене: {name}", + "fr": "Remplacer {name}" + }, + "diagnostics_count": { + "en": "Issues to resolve: {count}", + "ru": "Ошибок для исправления: {count}", + "fr": "Erreurs à corriger : {count}" + }, + "holiday_count": { + "en": "New holidays: {count}", + "ru": "Новых праздников: {count}", + "fr": "Nouveaux jours fériés : {count}" + }, + "err_person_ids_must_be_unique_2d45f9": { + "en": "Person IDs must be unique", + "ru": "ID участников должны быть уникальны", + "fr": "Les ID des personnes doivent être uniques" + }, + "err_choose_a_user_who_has_access_to_this_workspace_41cf15": { + "en": "Choose a user who has access to this workspace", + "ru": "Выберите пользователя с доступом к пространству", + "fr": "Choisissez un utilisateur ayant accès à cet espace" + }, + "err_choose_a_valid_country_code_e79c72": { + "en": "Choose a valid country code", + "ru": "Выберите корректный код страны", + "fr": "Choisissez un code pays valide" + }, + "err_enter_a_valid_email_address_1577ea": { + "en": "Enter a valid email address", + "ru": "Введите корректный email", + "fr": "Saisissez une adresse e-mail valide" + }, + "err_email_addresses_must_be_normalized_c8f704": { + "en": "Email addresses must be normalized", + "ru": "Email должны быть приведены к стандартному виду", + "fr": "Les adresses e-mail doivent être normalisées" + }, + "err_this_email_is_already_in_the_team_directory_2692f4": { + "en": "This email is already in the workspace directory", + "ru": "Этот email уже есть в справочнике пространства", + "fr": "Cet e-mail figure déjà dans l’annuaire de l’espace" + }, + "err_use_a_valid_iana_timezone_b90408": { + "en": "Use a valid IANA timezone", + "ru": "Укажите корректный часовой пояс IANA", + "fr": "Utilisez un fuseau IANA valide" + }, + "err_publication_horizon_must_be_7_366_days_95023e": { + "en": "Publication horizon must be 7–366 days", + "ru": "Горизонт публикации должен быть от 7 до 366 дней", + "fr": "L’horizon de publication doit être de 7 à 366 jours" + }, + "err_team_history_is_too_large_archive_old_data_befor_fc0b97": { + "en": "Workspace history is too large; archive old data before adding more", + "ru": "История пространства слишком велика; архивируйте старые данные", + "fr": "L’historique de l’espace est trop volumineux ; archivez les anciennes données" + }, + "err_schedule_ids_must_be_unique_4f12c8": { + "en": "Schedule IDs must be unique", + "ru": "ID расписаний должны быть уникальны", + "fr": "Les ID des plannings doivent être uniques" + }, + "err_choose_a_workspace_user_or_email_contact_for_eve_879191": { + "en": "Choose a workspace user or email contact for every participant", + "ru": "Для каждого участника выберите пользователя пространства или контакт по email", + "fr": "Choisissez un utilisateur de l’espace ou un contact e-mail pour chaque participant" + }, + "err_a_rotation_cannot_repeat_a_participant_adc324": { + "en": "A rotation cannot repeat a participant", + "ru": "Участник не может повторяться в ротации", + "fr": "Un participant ne peut pas apparaître deux fois dans une rotation" + }, + "err_shift_length_must_be_1_366_calendar_days_b772e3": { + "en": "Shift length must be 1–366 calendar days", + "ru": "Длительность смены должна быть от 1 до 366 календарных дней", + "fr": "Une astreinte doit durer de 1 à 366 jours calendaires" + }, + "err_anchor_year_must_be_2000_2100_5009d7": { + "en": "Anchor year must be 2000–2100", + "ru": "Год начала должен быть от 2000 до 2100", + "fr": "L’année de départ doit être comprise entre 2000 et 2100" + }, + "err_priority_coverage_needs_1_100_people_b162ae": { + "en": "Priority coverage needs 1–100 people", + "ru": "Для приоритетного дежурства нужно от 1 до 100 участников", + "fr": "La couverture prioritaire nécessite de 1 à 100 personnes" + }, + "err_choose_a_workspace_user_or_email_contact_for_eve_291413": { + "en": "Choose a workspace user or email contact for every responsible person", + "ru": "Для каждого ответственного выберите пользователя пространства или контакт по email", + "fr": "Choisissez un utilisateur ou un contact e-mail pour chaque responsable" + }, + "err_priority_coverage_cannot_repeat_a_person_c524c8": { + "en": "Priority coverage cannot repeat a person", + "ru": "Ответственные не должны повторяться", + "fr": "Une personne ne peut pas apparaître deux fois dans la couverture prioritaire" + }, + "err_fallback_schedule_does_not_exist_0ef8b9": { + "en": "Fallback schedule does not exist", + "ru": "Резервное расписание не существует", + "fr": "Le planning de repli n’existe pas" + }, + "err_primary_and_secondary_must_be_different_people_9fbd54": { + "en": "Primary and secondary must be different people", + "ru": "Первый и второй ответственные должны быть разными людьми", + "fr": "Les responsables principal et secondaire doivent être différents" + }, + "err_coverage_owners_must_be_team_members_afc392": { + "en": "Coverage owners must be workspace members", + "ru": "Ответственные должны быть участниками пространства", + "fr": "Les responsables doivent appartenir à l’espace" + }, + "err_fallback_schedules_cannot_contain_a_cycle_b96e58": { + "en": "Fallback schedules cannot contain a cycle", + "ru": "Резервные расписания не могут образовывать цикл", + "fr": "Les plannings de repli ne peuvent pas former de cycle" + }, + "err_missing_fallback_schedule_6234b6": { + "en": "Missing fallback schedule", + "ru": "Не задано резервное расписание", + "fr": "Planning de repli manquant" + }, + "err_absent_employee_must_be_a_team_member_510f37": { + "en": "Absent employee must be a workspace member", + "ru": "Отсутствующий сотрудник должен быть участником пространства", + "fr": "La personne absente doit appartenir à l’espace" + }, + "err_absence_must_be_a_valid_range_of_at_most_two_yea_db96bb": { + "en": "Absence must be a valid range of at most two years", + "ru": "Отсутствие должно быть корректным интервалом не более двух лет", + "fr": "L’absence doit être une période valide de deux ans au maximum" + }, + "err_holiday_country_must_be_an_iso_country_code_09ebeb": { + "en": "Holiday country must be an ISO country code", + "ru": "Страна праздника должна быть указана кодом ISO", + "fr": "Le pays du jour férié doit être un code ISO" + }, + "err_holiday_year_must_be_2000_2100_e542b1": { + "en": "Holiday year must be 2000–2100", + "ru": "Год праздника должен быть от 2000 до 2100", + "fr": "L’année du jour férié doit être comprise entre 2000 et 2100" + }, + "err_escalation_ids_must_be_unique_8346e7": { + "en": "Escalation IDs must be unique", + "ru": "ID эскалаций должны быть уникальны", + "fr": "Les ID des escalades doivent être uniques" + }, + "err_an_escalation_needs_1_20_steps_644fbf": { + "en": "An escalation needs 1–20 steps", + "ru": "Эскалация должна содержать от 1 до 20 шагов", + "fr": "Une escalade nécessite de 1 à 20 étapes" + }, + "err_escalation_delays_must_be_ordered_and_at_most_on_93513d": { + "en": "Escalation delays must be ordered and at most one week", + "ru": "Задержки эскалации должны идти по порядку и не превышать неделю", + "fr": "Les délais d’escalade doivent être ordonnés et ne pas dépasser une semaine" + }, + "err_escalation_p0_step_p1_references_a_missing_sched_a8026c": { + "en": "Escalation ‘{p0}’, step {p1} references a missing schedule. Update the step or use the schedule removal workflow.", + "ru": "Эскалация «{p0}», шаг {p1}: указанного расписания нет. Измените шаг или используйте удаление расписания с проверкой зависимостей.", + "fr": "L’escalade « {p0} », étape {p1}, référence un planning absent. Modifiez l’étape ou utilisez la procédure de suppression du planning." + }, + "err_escalation_employee_must_be_a_team_member_8b2c9f": { + "en": "Escalation employee must be a workspace member", + "ru": "Получатель эскалации должен быть участником пространства", + "fr": "Le destinataire de l’escalade doit appartenir à l’espace" + }, + "err_invalid_escalation_repeat_settings_508f67": { + "en": "Invalid escalation repeat settings", + "ru": "Некорректные настройки повторов эскалации", + "fr": "Paramètres de répétition d’escalade invalides" + }, + "err_routing_ids_and_priorities_must_be_unique_1753f6": { + "en": "Routing IDs and priorities must be unique", + "ru": "ID и приоритеты маршрутов должны быть уникальны", + "fr": "Les ID et priorités de routage doivent être uniques" + }, + "err_routing_escalation_does_not_exist_0c1581": { + "en": "Routing escalation does not exist", + "ru": "Политика эскалации маршрута не существует", + "fr": "La politique d’escalade du routage n’existe pas" + }, + "err_too_many_routing_conditions_7c419b": { + "en": "Too many routing conditions", + "ru": "Слишком много условий маршрутизации", + "fr": "Trop de conditions de routage" + }, + "err_unsupported_routing_field_0b98b6": { + "en": "Unsupported routing field", + "ru": "Неподдерживаемое поле маршрутизации", + "fr": "Champ de routage non pris en charge" + }, + "err_unsupported_routing_operation_c5c07c": { + "en": "Unsupported routing operation", + "ru": "Неподдерживаемая операция маршрутизации", + "fr": "Opération de routage non prise en charge" + }, + "err_routing_value_must_contain_1_500_characters_0f3a17": { + "en": "Routing value must contain 1–500 characters", + "ru": "Значение условия должно содержать от 1 до 500 символов", + "fr": "La valeur de routage doit contenir de 1 à 500 caractères" + }, + "err_jsm_cloud_and_team_ids_must_be_uuids_f75b5f": { + "en": "JSM cloud and team IDs must be UUIDs", + "ru": "ID облака и команды JSM должны быть UUID", + "fr": "Les ID du cloud et de l’équipe JSM doivent être des UUID" + }, + "err_jsm_account_ids_must_contain_1_200_characters_9fb430": { + "en": "JSM account IDs must contain 1–200 characters", + "ru": "ID аккаунтов JSM должны содержать от 1 до 200 символов", + "fr": "Les ID de compte JSM doivent contenir de 1 à 200 caractères" + }, + "err_names_must_contain_1_120_characters_8071e9": { + "en": "Names must contain 1–120 characters", + "ru": "Названия должны содержать от 1 до 120 символов", + "fr": "Les noms doivent contenir de 1 à 120 caractères" + }, + "err_use_a_valid_iana_timezone_for_working_hours_b4e2c6": { + "en": "Use a valid IANA timezone for working hours", + "ru": "Укажите корректный часовой пояс IANA для рабочего времени", + "fr": "Utilisez un fuseau IANA valide pour les horaires de travail" + }, + "err_choose_distinct_working_days_9a4ea4": { + "en": "Choose distinct working days", + "ru": "Рабочие дни не должны повторяться", + "fr": "Choisissez des jours de travail distincts" + }, + "err_working_hours_must_end_after_they_start_on_the_s_a19afe": { + "en": "Working hours must end after they start on the same day", + "ru": "Рабочее время должно заканчиваться после начала в тот же день", + "fr": "Les horaires doivent se terminer après leur début, le même jour" + }, + "err_unknown_timezone_9d07d7": { + "en": "Unknown timezone", + "ru": "Неизвестный часовой пояс", + "fr": "Fuseau horaire inconnu" + }, + "err_the_handover_on_p0_is_ambiguous_or_does_not_exis_e6f851": { + "en": "The handover on {p0} is ambiguous or does not exist in {p1}", + "ru": "Время передачи смены {p0} неоднозначно или не существует в {p1}", + "fr": "L’heure de relève du {p0} est ambiguë ou inexistante dans {p1}" + }, + "err_date_is_out_of_range_4ecf15": { + "en": "Date is out of range", + "ru": "Дата вне допустимого диапазона", + "fr": "Date hors limites" + }, + "err_calendar_range_must_be_positive_and_at_most_367_23a2e6": { + "en": "Calendar range must be positive and at most 367 days", + "ru": "Период календаря должен быть положительным и не превышать 367 дней", + "fr": "La période du calendrier doit être positive et ne pas dépasser 367 jours" + }, + "err_fallback_schedules_contain_a_cycle_e98539": { + "en": "Fallback schedules contain a cycle", + "ru": "Резервные расписания образуют цикл", + "fr": "Les plannings de repli forment un cycle" + }, + "err_missing_schedule_4dc6ee": { + "en": "Missing schedule", + "ru": "Расписание не найдено", + "fr": "Planning manquant" + }, + "err_approved_overrides_overlap_ce280c": { + "en": "Approved overrides overlap", + "ru": "Подтверждённые подмены пересекаются", + "fr": "Les remplacements approuvés se chevauchent" + }, + "err_approved_replacement_f0feba": { + "en": "Approved replacement", + "ru": "Подтверждённая подмена", + "fr": "Remplacement approuvé" + }, + "err_earlier_owners_off_responsible_p0_bb11c0": { + "en": "Earlier owners off · responsible {p0}", + "ru": "Предыдущие ответственные отсутствуют · ответственный {p0}", + "fr": "Responsables précédents absents · responsable {p0}" + }, + "err_owners_off_general_on_call_9cfec2": { + "en": "Owners off · general on-call", + "ru": "Ответственные отсутствуют · общий дежурный", + "fr": "Responsables absents · astreinte générale" + }, + "err_invalid_rotation_8f771c": { + "en": "Invalid rotation", + "ru": "Некорректная ротация", + "fr": "Rotation invalide" + }, + "err_holiday_general_on_call_30a96b": { + "en": "Holiday · general on-call", + "ru": "Праздник · общий дежурный", + "fr": "Jour férié · astreinte générale" + }, + "err_primary_off_secondary_baf501": { + "en": "Primary off · secondary", + "ru": "Первый отсутствует · второй ответственный", + "fr": "Principal absent · secondaire" + }, + "err_both_off_general_on_call_89c27f": { + "en": "Both off · general on-call", + "ru": "Оба отсутствуют · общий дежурный", + "fr": "Tous deux absents · astreinte générale" + }, + "err_schedule_does_not_exist_637210": { + "en": "Schedule does not exist", + "ru": "Расписание не существует", + "fr": "Le planning n’existe pas" + }, + "err_replacement_must_be_future_facing_and_at_most_90_c483a9": { + "en": "Replacement must be future-facing and at most 90 days", + "ru": "Подмена должна относиться к будущему и длиться не более 90 дней", + "fr": "Le remplacement doit porter sur le futur et durer au maximum 90 jours" + }, + "err_replacement_must_be_within_the_next_year_2cab72": { + "en": "Replacement must be within the next year", + "ru": "Подмена должна быть в пределах следующего года", + "fr": "Le remplacement doit être prévu dans l’année à venir" + }, + "err_choose_two_different_team_members_38153c": { + "en": "Choose two different workspace members", + "ru": "Выберите двух разных участников пространства", + "fr": "Choisissez deux membres différents de l’espace" + }, + "err_swap_intervals_overlap_740eb3": { + "en": "Swap intervals overlap", + "ru": "Интервалы обмена пересекаются", + "fr": "Les périodes d’échange se chevauchent" + }, + "err_this_interval_already_has_an_approved_replacemen_09700a": { + "en": "This interval already has an approved replacement", + "ru": "На этот интервал уже есть подтверждённая подмена", + "fr": "Cette période a déjà un remplacement approuvé" + }, + "err_the_original_employee_no_longer_owns_the_entire_c36051": { + "en": "The original employee no longer owns the entire interval; refresh the calendar", + "ru": "Исходный сотрудник больше не дежурит на всём интервале; обновите календарь", + "fr": "La personne initiale n’est plus affectée à toute la période ; actualisez le calendrier" + }, + "err_the_replacement_employee_is_absent_during_this_i_3b5cc2": { + "en": "The replacement employee is absent during this interval", + "ru": "Подменяющий сотрудник отсутствует в этом интервале", + "fr": "Le remplaçant est absent pendant cette période" + }, + "err_a_swap_must_exchange_shifts_between_the_same_two_dd579e": { + "en": "A swap must exchange shifts between the same two employees", + "ru": "Обмен должен происходить между теми же двумя сотрудниками", + "fr": "Un échange doit concerner les deux mêmes personnes" + }, + "err_password_must_contain_12_256_characters_3169d0": { + "en": "Password must contain 12–256 characters", + "ru": "Пароль должен содержать от 12 до 256 символов", + "fr": "Le mot de passe doit contenir de 12 à 256 caractères" + }, + "err_could_not_hash_password_212449": { + "en": "Could not hash password", + "ru": "Не удалось обработать пароль", + "fr": "Impossible de hacher le mot de passe" + }, + "err_username_must_use_3_64_letters_numbers_dots_unde_2c6b6f": { + "en": "Username must use 3–64 letters, numbers, dots, underscores, or hyphens", + "ru": "Имя пользователя: от 3 до 64 букв, цифр, точек, подчёркиваний или дефисов", + "fr": "L’identifiant doit contenir de 3 à 64 lettres, chiffres, points, tirets bas ou traits d’union" + }, + "err_this_username_is_already_registered_5ea7ea": { + "en": "This username is already registered", + "ru": "Это имя пользователя уже занято", + "fr": "Cet identifiant est déjà enregistré" + }, + "err_invalid_credentials_e41784": { + "en": "Invalid credentials", + "ru": "Неверные данные для входа", + "fr": "Identifiants incorrects" + }, + "err_a_contact_cannot_be_linked_to_a_login_through_an_68dd72": { + "en": "A contact cannot be linked to a login through an unverified email", + "ru": "Контакт нельзя связать с аккаунтом через неподтверждённый email", + "fr": "Un contact ne peut pas être lié à un compte via un e-mail non vérifié" + }, + "err_select_the_workspace_user_from_the_directory_725423": { + "en": "Select the workspace user from the directory", + "ru": "Выберите пользователя пространства из справочника", + "fr": "Sélectionnez l’utilisateur de l’espace dans l’annuaire" + }, + "err_cannot_remove_a_schedule_with_an_active_approved_a83aa7": { + "en": "Cannot remove a schedule with an active approved replacement", + "ru": "Нельзя удалить расписание с активной подтверждённой подменой", + "fr": "Impossible de supprimer un planning avec un remplacement approuvé actif" + }, + "err_an_approved_replacement_references_a_missing_mem_fd0f2a": { + "en": "An approved replacement references a missing member", + "ru": "Подтверждённая подмена ссылается на отсутствующего участника", + "fr": "Un remplacement approuvé référence un membre absent" + }, + "err_demo_mode_uses_the_local_preview_provider_extern_e125f3": { + "en": "Demo mode uses the local preview provider; external publication is disabled", + "ru": "В деморежиме используется локальный предпросмотр; внешняя публикация отключена", + "fr": "Le mode démo utilise l’aperçu local ; la publication externe est désactivée" + }, + "err_a_published_jsm_destination_cannot_be_changed_in_7bc9a5": { + "en": "A published JSM destination cannot be changed in place; create a new team binding to preserve ownership", + "ru": "Нельзя изменить уже опубликованное назначение JSM; создайте новую привязку команды для сохранения владения", + "fr": "Une destination JSM publiée ne peut pas être modifiée sur place ; créez une nouvelle liaison d’équipe pour conserver la propriété" + }, + "err_every_member_must_have_calendar_access_ca104e": { + "en": "Every member must have calendar access", + "ru": "Каждый участник должен иметь доступ к календарю", + "fr": "Chaque membre doit avoir accès au calendrier" + }, + "err_ask_this_person_to_create_an_account_first_897eb2": { + "en": "Ask this person to create an account first", + "ru": "Попросите этого человека сначала создать аккаунт", + "fr": "Demandez à cette personne de créer d’abord un compte" + }, + "err_add_at_most_100_contacts_at_once_e720fc": { + "en": "Add at most 100 contacts at once", + "ru": "Добавляйте не более 100 контактов за раз", + "fr": "Ajoutez au maximum 100 contacts à la fois" + }, + "err_edit_an_existing_contact_in_team_settings_ad289b": { + "en": "Edit an existing contact in workspace settings", + "ru": "Измените существующий контакт в настройках пространства", + "fr": "Modifiez le contact existant dans les paramètres de l’espace" + }, + "err_select_an_existing_workspace_user_from_the_direc_a09a03": { + "en": "Select an existing workspace user from the directory", + "ru": "Выберите существующего пользователя пространства из справочника", + "fr": "Choisissez un utilisateur existant de l’espace dans l’annuaire" + }, + "err_a_selected_replacement_has_no_team_access_to_con_dd538a": { + "en": "A selected replacement has no workspace access to confirm. Grant access or ask a lead to apply without confirmation.", + "ru": "У выбранного сотрудника нет доступа к пространстве для подтверждения. Выдайте доступ или попросите тимлида применить без согласования.", + "fr": "Le remplaçant choisi n’a pas accès à l’espace pour confirmer. Accordez-lui l’accès ou demandez à un responsable d’appliquer sans validation." + }, + "err_comment_must_be_at_most_1000_characters_e5d5ef": { + "en": "Comment must be at most 1000 characters", + "ru": "Комментарий должен быть не длиннее 1000 символов", + "fr": "Le commentaire ne doit pas dépasser 1000 caractères" + }, + "err_replacement_history_limit_reached_3b1168": { + "en": "Replacement history limit reached", + "ru": "Достигнут лимит истории подмен", + "fr": "Limite de l’historique des remplacements atteinte" + }, + "err_you_can_give_your_own_shift_or_offer_to_cover_a_5e6f57": { + "en": "You can give your own shift or offer to cover a colleague", + "ru": "Можно отдать свою смену или предложить подменить коллегу", + "fr": "Vous pouvez céder votre astreinte ou proposer de remplacer un collègue" + }, + "err_both_people_need_team_access_to_approve_ask_a_le_7b0fc5": { + "en": "Both people need workspace access to approve. Ask a lead to apply without approval for a contact.", + "ru": "Оба участника должны иметь доступ к пространстве для подтверждения. Для контакта попросите тимлида применить без согласования.", + "fr": "Les deux personnes doivent avoir accès à l’espace pour approuver. Pour un contact, demandez à un responsable d’appliquer sans accord." + }, + "err_this_request_has_already_been_resolved_26b69e": { + "en": "This request has already been resolved", + "ru": "Этот запрос уже обработан", + "fr": "Cette demande a déjà été traitée" + }, + "err_unknown_request_action_0db000": { + "en": "Unknown request action", + "ru": "Неизвестное действие запроса", + "fr": "Action de demande inconnue" + }, + "err_fix_the_csv_errors_before_importing_fe5a4e": { + "en": "Fix the CSV errors before importing", + "ru": "Исправьте ошибки CSV перед импортом", + "fr": "Corrigez les erreurs du CSV avant l’importation" + }, + "err_there_are_no_new_holidays_to_import_f77467": { + "en": "There are no new holidays to import", + "ru": "Нет новых праздников для импорта", + "fr": "Aucun nouveau jour férié à importer" + }, + "err_workspace_settings_changed_refresh_before_saving_445ae7": { + "en": "Workspace settings changed. Refresh before saving.", + "ru": "Настройки пространства изменились. Обновите перед сохранением.", + "fr": "Les paramètres de l’espace ont changé. Actualisez avant d’enregistrer." + }, + "err_invalid_stored_identifier_7c6a5b": { + "en": "Invalid stored identifier", + "ru": "Некорректный сохранённый идентификатор", + "fr": "Identifiant enregistré invalide" + }, + "err_this_team_changed_refresh_before_saving_7f93ad": { + "en": "This workspace changed. Refresh before saving.", + "ru": "Данные пространства изменились. Обновите перед сохранением.", + "fr": "L’équipe a changé. Actualisez avant d’enregistrer." + }, + "err_this_team_or_your_permissions_changed_refresh_be_c1e4c2": { + "en": "This workspace or your permissions changed. Refresh before saving.", + "ru": "Данные пространства или ваши права изменились. Обновите перед сохранением.", + "fr": "L’équipe ou vos autorisations ont changé. Actualisez avant d’enregistrer." + }, + "err_invalid_calendar_range_c177b5": { + "en": "Invalid calendar range", + "ru": "Некорректный период календаря", + "fr": "Période du calendrier invalide" + }, + "err_this_schedule_is_generated_remove_it_through_var_6da973": { + "en": "This schedule is generated. Remove it through Variables & rules so its source stays consistent", + "ru": "Это расписание сгенерировано. Удаляйте его через исходное правило на странице настройки.", + "fr": "Ce planning est généré. Supprimez-le via sa règle source dans la configuration." + }, + "err_this_schedule_has_current_or_future_approved_rep_49a098": { + "en": "This schedule has current or future approved replacements. It must remain until those intervals have ended.", + "ru": "В расписании есть текущие или будущие подтверждённые подмены. Оно должно сохраняться до их окончания.", + "fr": "Ce planning contient des remplacements approuvés en cours ou futurs. Il doit être conservé jusqu’à leur fin." + }, + "err_choose_a_different_existing_schedule_for_the_ref_ea3955": { + "en": "Choose a different existing schedule for the references", + "ru": "Выберите другое существующее расписание для ссылок", + "fr": "Choisissez un autre planning existant pour les références" + }, + "err_choose_a_replacement_fallback_for_p0_6e17d4": { + "en": "Choose a replacement fallback for: {p0}", + "ru": "Выберите резервное расписание для: {p0}", + "fr": "Choisissez un planning de repli pour : {p0}" + }, + "err_choose_a_replacement_schedule_or_edit_these_poli_f9b0c9": { + "en": "Choose a replacement schedule or edit these policies first; removing their last step would leave them empty: {p0}", + "ru": "Выберите другое расписание или сначала измените политики; удаление последнего шага оставит их пустыми: {p0}", + "fr": "Choisissez un autre planning ou modifiez d’abord ces politiques ; supprimer leur dernière étape les laisserait vides : {p0}" + }, + "err_the_ordered_list_must_contain_every_current_rout_a7854b": { + "en": "The ordered list must contain every current routing rule exactly once. Refresh and try again.", + "ru": "В списке должно быть каждое текущее правило маршрутизации ровно один раз. Обновите и повторите.", + "fr": "La liste doit contenir chaque règle de routage actuelle exactement une fois. Actualisez et réessayez." + }, + "err_validated_permutation_74a9c2": { + "en": "Validated permutation", + "ru": "Проверенный порядок", + "fr": "Permutation validée" + }, + "err_absence_coverage_2fccc7": { + "en": "Absence coverage", + "ru": "Подмена на время отсутствия", + "fr": "Couverture d’absence" + }, + "err_this_absence_no_longer_exists_8d51ae": { + "en": "This absence no longer exists", + "ru": "Это отсутствие больше не существует", + "fr": "Cette absence n’existe plus" + }, + "err_absence_coverage_supports_1_500_affected_interva_d61c7a": { + "en": "Absence coverage supports 1–500 affected intervals per range", + "ru": "Подмена на время отсутствия поддерживает от 1 до 500 интервалов на диапазон", + "fr": "La couverture d’absence prend en charge de 1 à 500 intervalles par période" + }, + "err_absence_coverage_can_replace_only_the_absent_emp_a99c60": { + "en": "Absence coverage can replace only the absent employee", + "ru": "Подмена при отсутствии может заменить только отсутствующего сотрудника", + "fr": "La couverture d’absence ne peut remplacer que la personne absente" + }, + "err_replacement_must_stay_inside_the_absence_8ec5a5": { + "en": "Replacement must stay inside the absence", + "ru": "Подмена должна оставаться в пределах отсутствия", + "fr": "Le remplacement doit rester dans la période d’absence" + }, + "err_absence_replacement_intervals_overlap_d053f4": { + "en": "Absence replacement intervals overlap", + "ru": "Интервалы подмены при отсутствии пересекаются", + "fr": "Les périodes de remplacement d’absence se chevauchent" + }, + "err_choose_an_absence_of_1_366_duty_days_090954": { + "en": "Choose an absence of 1–366 duty days", + "ru": "Выберите отсутствие от 1 до 366 дней дежурства", + "fr": "Choisissez une absence de 1 à 366 jours d’astreinte" + }, + "err_absence_must_end_within_the_next_year_767689": { + "en": "Absence must end within the next year", + "ru": "Отсутствие должно закончиться в течение следующего года", + "fr": "L’absence doit se terminer dans l’année à venir" + }, + "err_this_person_already_has_an_overlapping_absence_f17a77": { + "en": "This person already has an overlapping absence", + "ru": "У этого сотрудника уже есть пересекающееся отсутствие", + "fr": "Cette personne a déjà une absence sur cette période" + }, + "err_use_at_most_24_replacement_ranges_52203c": { + "en": "Use at most 24 replacement ranges", + "ru": "Используйте не более 24 интервалов подмены", + "fr": "Utilisez au maximum 24 périodes de remplacement" + }, + "err_each_replacement_range_must_cover_1_90_duty_days_f18f21": { + "en": "Each replacement range must cover 1–90 duty days inside this absence", + "ru": "Каждый интервал подмены должен покрывать от 1 до 90 дней внутри отсутствия", + "fr": "Chaque remplacement doit couvrir de 1 à 90 jours dans cette absence" + }, + "err_replacement_ranges_must_not_overlap_bf6aad": { + "en": "Replacement ranges must not overlap", + "ru": "Интервалы подмены не должны пересекаться", + "fr": "Les périodes de remplacement ne doivent pas se chevaucher" + }, + "err_choose_a_different_workspace_user_or_email_conta_a0a168": { + "en": "Choose a different workspace user or email contact as replacement", + "ru": "Выберите другого пользователя пространства или контакт по email для подмены", + "fr": "Choisissez un autre utilisateur de l’espace ou contact e-mail comme remplaçant" + }, + "err_timeline_covers_range_6c1844": { + "en": "Timeline covers range", + "ru": "Расписание покрывает интервал", + "fr": "Le planning couvre la période" + }, + "err__p0_direct_user_team_notifications_are_not_chang_66a117": { + "en": "{p0}: direct user/team notifications are not changed by schedule absence. Review these recipients in the provider.", + "ru": "{p0}: прямые уведомления пользователю или команде не меняются при отсутствии в расписании. Проверьте получателей во внешнем сервисе.", + "fr": "{p0} : les notifications directes à une personne ou une équipe ne changent pas avec une absence. Vérifiez ces destinataires chez le fournisseur." + }, + "err_csv_is_limited_to_256_kib_e30936": { + "en": "CSV is limited to 256 KiB", + "ru": "CSV ограничен 256 КиБ", + "fr": "Le CSV est limité à 256 Kio" + }, + "err_could_not_read_csv_headers_2bbd8d": { + "en": "Could not read CSV headers", + "ru": "Не удалось прочитать заголовки CSV", + "fr": "Impossible de lire les en-têtes CSV" + }, + "err_use_the_csv_headers_country_date_name_7d9639": { + "en": "Use the CSV headers country,date,name", + "ru": "Используйте заголовки CSV country,date,name", + "fr": "Utilisez les en-têtes CSV country,date,name" + }, + "err_csv_must_have_exactly_three_columns_country_date_723e07": { + "en": "CSV must have exactly three columns: country,date,name", + "ru": "CSV должен содержать ровно три столбца: country,date,name", + "fr": "Le CSV doit contenir exactement trois colonnes : country,date,name" + }, + "err_import_is_limited_to_2_000_rows_6e1c86": { + "en": "Import is limited to 2,000 rows", + "ru": "Импорт ограничен 2000 строками", + "fr": "L’import est limité à 2 000 lignes" + }, + "err_row_line_invalid_csv_or_column_count_be7745": { + "en": "Row {line}: invalid CSV or column count", + "ru": "Строка {line}: некорректный CSV или число столбцов", + "fr": "Ligne {line} : CSV ou nombre de colonnes incorrect" + }, + "err_row_line_use_an_iso_country_code_dd_mm_yyyy_or_y_bf011e": { + "en": "Row {line}: use an ISO country code, DD-MM-YYYY or YYYY-MM-DD date (2000–2100), and a name of 1–120 characters", + "ru": "Строка {line}: укажите ISO-код страны, дату DD-MM-YYYY или YYYY-MM-DD (2000–2100) и название из 1–120 символов", + "fr": "Ligne {line} : indiquez un code pays ISO, une date DD-MM-YYYY ou YYYY-MM-DD (2000–2100) et un nom de 1 à 120 caractères" + }, + "err_row_line_two_different_names_for_p0_on_p1_4c127b": { + "en": "Row {line}: two different names for {p0} on {p1}", + "ru": "Строка {line}: два разных названия для {p0} на {p1}", + "fr": "Ligne {line} : deux noms différents pour {p0} le {p1}" + }, + "err__p0_p1_existing_p2_is_kept_p3_will_not_replace_i_053cc0": { + "en": "{p0} · {p1}: existing ‘{p2}’ is kept; ‘{p3}’ will not replace it", + "ru": "{p0} · {p1}: существующий «{p2}» сохранится; «{p3}» не заменит его", + "fr": "{p0} · {p1} : « {p2} » est conservé ; « {p3} » ne le remplacera pas" + }, + "err_the_team_would_exceed_2_000_holidays_496996": { + "en": "The workspace would exceed 2,000 holidays", + "ru": "У пространства будет более 2000 праздников", + "fr": "L’équipe dépasserait 2 000 jours fériés" + }, + "err_data_is_limited_to_8_nested_levels_and_10_000_va_fa5d71": { + "en": "Data is limited to 8 nested levels and 10,000 values", + "ru": "Данные ограничены 8 уровнями вложенности и 10 000 значений", + "fr": "Les données sont limitées à 8 niveaux et 10 000 valeurs" + }, + "err_data_entry_ids_must_be_unique_4725be": { + "en": "Data entry IDs must be unique", + "ru": "Идентификаторы записей должны быть уникальны", + "fr": "Les identifiants des entrées doivent être uniques" + }, + "err_dictionary_keys_must_be_nonempty_and_unique_4e8ce4": { + "en": "Dictionary keys must be nonempty and unique", + "ru": "Ключи словаря должны быть непустыми и уникальными", + "fr": "Les clés du dictionnaire doivent être non vides et uniques" + }, + "err_enter_a_valid_p0_value_3b830b": { + "en": "Enter a valid {p0} value", + "ru": "Введите корректное значение {p0}", + "fr": "Saisissez une valeur {p0} valide" + }, + "err_use_at_most_100_workspace_variables_c7d391": { + "en": "Use at most 100 workspace variables", + "ru": "Используйте не более 100 переменных пространства", + "fr": "Utilisez au maximum 100 variables d’espace" + }, + "err_variable_p0_973058": { + "en": "Variable {p0}", + "ru": "Переменная {p0}", + "fr": "Variable {p0}" + }, + "err_choose_a_unique_variable_name_and_id_ee4fc3": { + "en": "Choose a unique variable name and ID", + "ru": "Укажите уникальные имя и идентификатор переменной", + "fr": "Choisissez un nom et un identifiant de variable uniques" + }, + "err_bindings_can_be_nested_at_most_16_levels_b78618": { + "en": "Bindings can be nested at most 16 levels", + "ru": "Привязки могут иметь не более 16 уровней вложенности", + "fr": "Les liaisons sont limitées à 16 niveaux" + }, + "err_choose_an_existing_workspace_variable_db6738": { + "en": "Choose an existing workspace variable", + "ru": "Выберите существующую переменную пространства", + "fr": "Choisissez une variable existante de l’espace" + }, + "err_choose_an_earlier_iteration_an_item_is_available_4aef3d": { + "en": "Choose an earlier iteration; an item is available only inside its loop", + "ru": "Выберите предыдущую итерацию; элемент доступен только внутри своего цикла", + "fr": "Choisissez une itération précédente ; un élément est disponible uniquement dans sa boucle" + }, + "err_choose_an_earlier_iteration_3562e3": { + "en": "Choose an earlier iteration", + "ru": "Выберите предыдущую итерацию", + "fr": "Choisissez une itération précédente" + }, + "err_dictionary_lookup_needs_a_dictionary_variable_867cbc": { + "en": "Dictionary lookup needs a dictionary variable", + "ru": "Для поиска нужна переменная-словарь", + "fr": "La recherche nécessite une variable de type dictionnaire" + }, + "err_choose_the_dictionary_lookup_key_66d49d": { + "en": "Choose the dictionary lookup key", + "ru": "Выберите ключ для поиска в словаре", + "fr": "Choisissez la clé de recherche du dictionnaire" + }, + "err_dictionary_keys_must_be_text_8bcea8": { + "en": "Dictionary keys must be text", + "ru": "Ключи словаря должны быть текстом", + "fr": "Les clés du dictionnaire doivent être du texte" + }, + "err_no_responsible_value_entry_for_key_key_06146d": { + "en": "No responsible/value entry for key ‘{key}’", + "ru": "Для ключа «{key}» нет ответственных или значения", + "fr": "Aucune entrée de responsables ou de valeur pour la clé « {key} »" + }, + "err_choose_an_output_block_in_this_rule_or_a_shared_0758e5": { + "en": "Choose an output block in this rule or a shared rule without iterations", + "ru": "Выберите блок результата этого правила или общего правила без итераций", + "fr": "Choisissez un bloc de ce modèle ou d’un modèle partagé sans itérations" + }, + "err_choose_a_value_source_00b9ad": { + "en": "Choose a value source", + "ru": "Выберите источник значения", + "fr": "Choisissez une source de valeur" + }, + "err_field_or_list_position_key_does_not_exist_4a5e94": { + "en": "Field or list position ‘{key}’ does not exist", + "ru": "Поле или позиция списка «{key}» не существует", + "fr": "Le champ ou la position « {key} » n’existe pas" + }, + "err_name_prefixes_suffixes_need_a_text_value_96730e": { + "en": "Name prefixes/suffixes need a text value", + "ru": "Префиксы и суффиксы названия требуют текстового значения", + "fr": "Les préfixes et suffixes nécessitent une valeur textuelle" + }, + "err_condition_values_must_have_the_same_type_e949f9": { + "en": "Condition values must have the same type", + "ru": "Значения условия должны иметь одинаковый тип", + "fr": "Les valeurs de la condition doivent avoir le même type" + }, + "err_contains_needs_a_list_and_a_value_or_two_text_va_b878ec": { + "en": "Contains needs a list and a value, or two text values", + "ru": "«Содержит» требует список и значение либо два текстовых значения", + "fr": "« Contient » nécessite une liste et une valeur, ou deux textes" + }, + "err_choose_a_supported_condition_f7daba": { + "en": "Choose a supported condition", + "ru": "Выберите поддерживаемое условие", + "fr": "Choisissez une condition prise en charge" + }, + "err_set_key_a7389f": { + "en": "Set {key}", + "ru": "Задайте {key}", + "fr": "Définissez {key}" + }, + "err__key_must_be_text_4fd030": { + "en": "{key} must be text", + "ru": "{key} должно быть текстом", + "fr": "{key} doit être du texte" + }, + "err__key_must_be_a_nonnegative_whole_number_2fb848": { + "en": "{key} must be a nonnegative whole number", + "ru": "{key} должно быть целым неотрицательным числом", + "fr": "{key} doit être un entier positif ou nul" + }, + "err_choose_a_person_or_enter_an_email_address_a24e0a": { + "en": "Choose a person or enter an email address", + "ru": "Выберите сотрудника или введите email", + "fr": "Choisissez une personne ou saisissez un e-mail" + }, + "err_person_is_not_in_this_workspace_or_team_director_c6d509": { + "en": "Person is not in this workspace or workspace directory", + "ru": "Сотрудника нет в справочнике пространства или пространства", + "fr": "Cette personne ne figure pas dans l’annuaire de l’espace ou de l’espace" + }, + "err__value_is_not_an_email_address_a7e20a": { + "en": "‘{value}’ is not an email address", + "ru": "«{value}» не является email-адресом", + "fr": "« {value} » n’est pas une adresse e-mail" + }, + "err_choose_a_schedule_or_policy_reference_f55953": { + "en": "Choose a schedule or policy reference", + "ru": "Выберите расписание или политику", + "fr": "Choisissez un planning ou une politique" + }, + "err_name_needs_1_120_characters_408c00": { + "en": "Name needs 1–120 characters", + "ru": "Название должно содержать 1–120 символов", + "fr": "Le nom doit contenir de 1 à 120 caractères" + }, + "err_handover_use_hh_mm_25d33e": { + "en": "handover: use HH:MM", + "ru": "handover: используйте HH:MM", + "fr": "handover : utilisez HH:MM" + }, + "err_participants_choose_a_list_of_people_emails_d13655": { + "en": "participants: choose a list of people/emails", + "ru": "participants: выберите список сотрудников или email", + "fr": "participants : choisissez une liste de personnes ou d’e-mails" + }, + "err_participants_choose_1_100_people_45308a": { + "en": "participants: choose 1–100 people", + "ru": "participants: выберите 1–100 сотрудников", + "fr": "participants : choisissez de 1 à 100 personnes" + }, + "err_anchor_choose_a_valid_date_497062": { + "en": "anchor: choose a valid date", + "ru": "anchor: укажите корректную дату", + "fr": "anchor : choisissez une date valide" + }, + "err_shift_days_use_1_366_days_801e00": { + "en": "shift_days: use 1–366 days", + "ru": "shift_days: укажите 1–366 дней", + "fr": "shift_days : indiquez de 1 à 366 jours" + }, + "err_step_p0_delay_must_be_0_10080_minutes_b384d0": { + "en": "Step {p0}: delay must be 0–10080 minutes", + "ru": "Шаг {p0}: задержка должна быть 0–10080 минут", + "fr": "Étape {p0} : le délai doit être de 0 à 10080 minutes" + }, + "err_escalation_repeat_is_limited_to_20_repeats_10080_9b06f3": { + "en": "Escalation repeat is limited to 20 repeats / 10080 minutes", + "ru": "Повтор эскалации ограничен 20 повторами / 10080 минутами", + "fr": "L’escalade est limitée à 20 répétitions / 10080 minutes" + }, + "err_routing_condition_value_must_be_text_233f0e": { + "en": "Routing condition value must be text", + "ru": "Значение условия маршрутизации должно быть текстом", + "fr": "La valeur de la condition de routage doit être du texte" + }, + "err_working_must_be_true_or_false_10625c": { + "en": "working must be true or false", + "ru": "working должно быть true или false", + "fr": "working doit être true ou false" + }, + "err_choose_an_output_type_4ab1b5": { + "en": "Choose an output type", + "ru": "Выберите тип результата", + "fr": "Choisissez un type de résultat" + }, + "err_use_at_most_50_generation_rules_abf459": { + "en": "Use at most 50 generation rules", + "ru": "Используйте не более 50 правил генерации", + "fr": "Utilisez au maximum 50 modèles de génération" + }, + "err_rule_p0_eb6431": { + "en": "Rule {p0}", + "ru": "Правило {p0}", + "fr": "Modèle {p0}" + }, + "err_use_a_unique_rule_id_name_at_most_4_iterations_a_c413b3": { + "en": "Use a unique rule ID/name, at most 4 iterations and 20 output blocks", + "ru": "Укажите уникальные имя и ID правила, не более 4 итераций и 20 блоков результата", + "fr": "Utilisez un nom et un ID uniques, au maximum 4 itérations et 20 blocs" + }, + "err_iteration_ids_must_be_unique_bfb5cc": { + "en": "Iteration IDs must be unique", + "ru": "Идентификаторы итераций должны быть уникальными", + "fr": "Les identifiants d’itération doivent être uniques" + }, + "err_iteration_p0_contains_duplicate_value_label_1743fe": { + "en": "Iteration {p0} contains duplicate value ‘{label}’", + "ru": "Итерация {p0} содержит повторяющееся значение «{label}»", + "fr": "L’itération {p0} contient la valeur « {label} » en double" + }, + "err_for_each_needs_a_list_or_dictionary_c87f69": { + "en": "For each needs a list or dictionary", + "ru": "Для перебора нужен список или словарь", + "fr": "La boucle nécessite une liste ou un dictionnaire" + }, + "err_expansion_is_limited_to_500_iteration_combinatio_81a6ef": { + "en": "Expansion is limited to 500 iteration combinations", + "ru": "Генерация ограничена 500 комбинациями итераций", + "fr": "La génération est limitée à 500 combinaisons d’itérations" + }, + "err_output_block_ids_must_be_unique_across_rules_1efed6": { + "en": "Output block IDs must be unique across rules", + "ru": "Идентификаторы блоков результата должны быть уникальны во всех правилах", + "fr": "Les identifiants des blocs doivent être uniques entre les modèles" + }, + "err_generated_identity_collides_with_a_manual_resour_4403f1": { + "en": "Generated identity collides with a manual resource", + "ru": "Идентификатор результата совпадает с ресурсом, созданным вручную", + "fr": "L’identifiant généré entre en conflit avec une ressource manuelle" + }, + "err_expansion_exceeds_the_team_resource_budget_5669f8": { + "en": "Expansion exceeds the workspace resource budget", + "ru": "Результат превышает лимит ресурсов пространства", + "fr": "La génération dépasse la limite de ressources de l’espace" + }, + "err_a_generated_schedule_has_current_future_approved_0abed7": { + "en": "A generated schedule has current/future approved replacements and cannot be removed", + "ru": "У сгенерированного расписания есть текущие или будущие подтверждённые подмены, его нельзя удалить", + "fr": "Un planning généré possède des remplacements approuvés en cours ou futurs et ne peut pas être supprimé" + }, + "err_generated_configuration_61fd64": { + "en": "Generated configuration", + "ru": "Сгенерированная конфигурация", + "fr": "Configuration générée" + }, + "err_generated_calendar_5e4edc": { + "en": "Generated calendar", + "ru": "Сгенерированный календарь", + "fr": "Calendrier généré" + }, + "err_edit_generation_sources_in_variables_rules_8ec0b0": { + "en": "Edit generation sources in Variables & rules", + "ru": "Измените источники в разделе «Переменные и правила»", + "fr": "Modifiez les sources dans « Variables et modèles »" + }, + "err_this_resource_is_generated_edit_its_source_in_va_b45498": { + "en": "This resource is generated. Edit its source in Variables & rules", + "ru": "Этот ресурс сгенерирован. Измените его источник в разделе «Переменные и правила»", + "fr": "Cette ressource est générée. Modifiez sa source dans « Variables et modèles »" + }, + "err_a_generated_routing_rule_is_missing_df7dc4": { + "en": "A generated routing rule is missing", + "ru": "Отсутствует сгенерированное правило маршрутизации", + "fr": "Une règle de routage générée est manquante" + }, + "err_generated_routing_rules_move_as_one_block_set_th_d09425": { + "en": "Generated routing rules move as one block. Set their position in Variables & rules", + "ru": "Сгенерированные правила маршрутизации перемещаются вместе. Задайте позицию в разделе «Переменные и правила»", + "fr": "Les règles de routage générées se déplacent ensemble. Définissez leur position dans « Variables et modèles »" + }, + "err_the_preview_is_stale_check_the_updated_impact_be_85cacd": { + "en": "The preview is stale. Check the updated impact before applying.", + "ru": "Предпросмотр устарел. Проверьте обновлённые изменения перед применением.", + "fr": "L’aperçu est périmé. Vérifiez les effets actualisés avant d’appliquer." + }, + "err_sign_in_to_continue_9a7843": { + "en": "Sign in to continue", + "ru": "Войдите, чтобы продолжить", + "fr": "Connectez-vous pour continuer" + }, + "err_you_do_not_have_permission_for_this_action_6306e9": { + "en": "You do not have permission for this action", + "ru": "У вас нет прав на это действие", + "fr": "Vous n’avez pas les droits pour cette action" + }, + "err_resource_not_found_2d1d4c": { + "en": "Resource not found", + "ru": "Ресурс не найден", + "fr": "Ressource introuvable" + }, + "err_an_internal_error_occurred_96c51c": { + "en": "An internal error occurred", + "ru": "Произошла внутренняя ошибка", + "fr": "Une erreur interne est survenue" + }, + "err_send_a_json_request_34adc9": { + "en": "Send a JSON request", + "ru": "Отправьте запрос в формате JSON", + "fr": "Envoyez une requête JSON" + }, + "err_request_body_is_too_large_fda0d1": { + "en": "Request body is too large", + "ru": "Тело запроса слишком большое", + "fr": "Le corps de la requête est trop volumineux" + }, + "err_invalid_json_request_650225": { + "en": "Invalid JSON request", + "ru": "Некорректный JSON-запрос", + "fr": "Requête JSON invalide" + }, + "err_choose_a_valid_iana_timezone_176bd9": { + "en": "Choose a valid IANA timezone", + "ru": "Выберите корректный часовой пояс IANA", + "fr": "Choisissez un fuseau horaire IANA valide" + }, + "err_choose_a_drift_policy_5a1cd6": { + "en": "Choose a drift policy", + "ru": "Выберите политику внешних изменений", + "fr": "Choisissez une politique de modifications externes" + }, + "err_invalid_calendar_query_feced6": { + "en": "Invalid calendar query", + "ru": "Некорректный запрос календаря", + "fr": "Requête de calendrier invalide" + }, + "err_choose_a_calendar_date_e25e18": { + "en": "Choose a calendar date", + "ru": "Выберите дату календаря", + "fr": "Choisissez une date du calendrier" + }, + "err_calendar_year_must_be_2000_2100_8681fc": { + "en": "Calendar year must be 2000–2100", + "ru": "Год календаря должен быть от 2000 до 2100", + "fr": "L’année doit être comprise entre 2000 et 2100" + }, + "err_calendar_range_must_be_1_366_days_8e177e": { + "en": "Calendar range must be 1–366 days", + "ru": "Период календаря должен быть 1–366 дней", + "fr": "La période doit être de 1 à 366 jours" + }, + "err_invalid_start_date_92aea1": { + "en": "Invalid start date", + "ru": "Некорректная дата начала", + "fr": "Date de début invalide" + }, + "err_invalid_end_date_68eb79": { + "en": "Invalid end date", + "ru": "Некорректная дата окончания", + "fr": "Date de fin invalide" + }, + "err_missing_key_364001": { + "en": "Missing {key}", + "ru": "Не указано: {key}", + "fr": "Champ manquant : {key}" + }, + "err_missing_configuration_revision_07cf43": { + "en": "Missing configuration revision", + "ru": "Не указана версия конфигурации", + "fr": "Version de configuration manquante" + }, + "err_invalid_request_e_2228c5": { + "en": "Invalid request: {e}", + "ru": "Некорректный запрос: {e}", + "fr": "Requête invalide : {e}" + }, + "err_login_limiter_unavailable_4f8aae": { + "en": "Login limiter unavailable", + "ru": "Ограничитель входа недоступен", + "fr": "Le limiteur de connexions est indisponible" + }, + "err_too_many_sign_in_attempts_try_again_later_abceb2": { + "en": "Too many sign-in attempts. Try again later.", + "ru": "Слишком много попыток входа. Попробуйте позже.", + "fr": "Trop de tentatives de connexion. Réessayez plus tard." + }, + "err_too_many_sign_in_attempts_try_again_in_five_minu_cc6ccd": { + "en": "Too many sign-in attempts. Try again in five minutes.", + "ru": "Слишком много попыток входа. Попробуйте через пять минут.", + "fr": "Trop de tentatives de connexion. Réessayez dans cinq minutes." + }, + "err_random_team_recipients_and_repeats_require_jsm_p_69c0b1": { + "en": "Random team recipients and repeats require JSM Premium or Enterprise", + "ru": "Случайные получатели команды и повторы требуют JSM Premium или Enterprise", + "fr": "Les destinataires aléatoires et les répétitions nécessitent JSM Premium ou Enterprise" + }, + "err_add_an_email_or_atlassian_account_id_for_every_s_7d378f": { + "en": "Add an email or Atlassian account ID for every scheduled person before publishing", + "ru": "Перед публикацией укажите email или ID аккаунта Atlassian для каждого дежурного", + "fr": "Avant publication, ajoutez un e-mail ou un ID de compte Atlassian pour chaque personne" + }, + "err_publication_blocked_an_assigned_employee_is_abse_9a003e": { + "en": "Publication blocked: an assigned employee is absent. Arrange a replacement first.", + "ru": "Публикация заблокирована: назначенный сотрудник отсутствует. Сначала организуйте подмену.", + "fr": "Publication bloquée : une personne affectée est absente. Organisez d’abord un remplacement." + }, + "err_external_publication_is_disabled_in_demo_mode_712662": { + "en": "External publication is disabled in demo mode", + "ru": "Внешняя публикация отключена в деморежиме", + "fr": "La publication externe est désactivée en mode démo" + }, + "err_jsm_is_not_configured_4e6594": { + "en": "JSM is not configured", + "ru": "JSM не настроен", + "fr": "JSM n’est pas configuré" + }, + "err_missing_prefix_email_deployment_secret_6e5753": { + "en": "Missing {prefix}_EMAIL deployment secret", + "ru": "Отсутствует секрет развёртывания {prefix}_EMAIL", + "fr": "Le secret de déploiement {prefix}_EMAIL est manquant" + }, + "err_missing_prefix_token_deployment_secret_1f0352": { + "en": "Missing {prefix}_TOKEN deployment secret", + "ru": "Отсутствует секрет развёртывания {prefix}_TOKEN", + "fr": "Le secret de déploiement {prefix}_TOKEN est manquant" + }, + "err_could_not_initialize_jsm_client_09bad0": { + "en": "Could not initialize JSM client", + "ru": "Не удалось инициализировать клиент JSM", + "fr": "Impossible d’initialiser le client JSM" + }, + "err_configure_the_atlassian_directory_or_provide_acc_266b32": { + "en": "Configure the Atlassian directory or provide account IDs", + "ru": "Настройте справочник Atlassian или укажите ID аккаунтов", + "fr": "Configurez l’annuaire Atlassian ou indiquez les ID de comptes" + }, + "err_atlassian_user_lookup_could_not_connect_6f312b": { + "en": "Atlassian user lookup could not connect", + "ru": "Не удалось подключиться к поиску пользователей Atlassian", + "fr": "Impossible de se connecter à la recherche d’utilisateurs Atlassian" + }, + "err_atlassian_user_lookup_returned_http_p0_grant_bro_a0f887": { + "en": "Atlassian user lookup returned HTTP {p0}. Grant Browse users and groups permission, or provide an account ID.", + "ru": "Поиск пользователей Atlassian вернул HTTP {p0}. Предоставьте право Browse users and groups или укажите ID аккаунта.", + "fr": "La recherche Atlassian a renvoyé HTTP {p0}. Accordez le droit Browse users and groups ou indiquez un ID de compte." + }, + "err_invalid_atlassian_directory_response_1c1889": { + "en": "Invalid Atlassian directory response", + "ru": "Некорректный ответ справочника Atlassian", + "fr": "Réponse invalide de l’annuaire Atlassian" + }, + "err_atlassian_search_limit_reached_provide_an_explic_d1fe8a": { + "en": "Atlassian search limit reached. Provide an explicit account ID.", + "ru": "Достигнут лимит поиска Atlassian. Укажите ID аккаунта явно.", + "fr": "Limite de recherche Atlassian atteinte. Indiquez un ID de compte explicite." + }, + "err_could_not_find_one_active_atlassian_account_with_39e9a9": { + "en": "Could not find one active Atlassian account with exact email {email}. Ensure it exists and the email is visible, or enter its account ID in provider settings.", + "ru": "Не найден единственный активный аккаунт Atlassian с точным email {email}. Проверьте наличие аккаунта и видимость email либо укажите ID аккаунта в настройках провайдера.", + "fr": "Aucun compte Atlassian actif unique avec l’e-mail exact {email}. Vérifiez son existence et la visibilité de l’e-mail, ou saisissez son ID dans les paramètres du fournisseur." + }, + "err_person_id_needs_an_email_or_an_atlassian_account_360521": { + "en": "Person {id} needs an email or an Atlassian account ID", + "ru": "Для сотрудника {id} нужен email или ID аккаунта Atlassian", + "fr": "La personne {id} nécessite un e-mail ou un ID de compte Atlassian" + }, + "err_jsm_request_timed_out_or_could_not_connect_5ce428": { + "en": "JSM request timed out or could not connect", + "ru": "Истекло время запроса JSM или не удалось подключиться", + "fr": "Délai JSM dépassé ou connexion impossible" + }, + "err_jsm_returned_http_p0_for_p1_check_credentials_pl_b31caf": { + "en": "JSM returned HTTP {p0} for {p1}. Check credentials, plan capabilities, and provider permissions.", + "ru": "JSM вернул HTTP {p0} для {p1}. Проверьте учётные данные, возможности тарифа и права провайдера.", + "fr": "JSM a renvoyé HTTP {p0} pour {p1}. Vérifiez les identifiants, l’abonnement et les droits du fournisseur." + }, + "err_jsm_returned_an_invalid_json_response_af8daf": { + "en": "JSM returned an invalid JSON response", + "ru": "JSM вернул некорректный JSON-ответ", + "fr": "JSM a renvoyé une réponse JSON invalide" + }, + "err_jsm_collection_was_not_found_60d46c": { + "en": "JSM collection was not found", + "ru": "Коллекция JSM не найдена", + "fr": "Collection JSM introuvable" + }, + "err_jsm_collection_exceeds_the_supported_pagination_a1136e": { + "en": "JSM collection exceeds the supported pagination limit", + "ru": "Коллекция JSM превышает поддерживаемый лимит страниц", + "fr": "La collection JSM dépasse la limite de pagination prise en charge" + }, + "err_employee_id_has_no_jsm_account_mapping_7b6db1": { + "en": "Employee {id} has no JSM account mapping", + "ru": "У сотрудника {id} нет привязки аккаунта JSM", + "fr": "La personne {id} n’a pas de compte JSM associé" + }, + "err_multiple_jsm_resources_have_the_same_meguri_owne_889887": { + "en": "Multiple JSM resources have the same Meguri ownership name", + "ru": "Несколько ресурсов JSM имеют одинаковое имя владения Meguri", + "fr": "Plusieurs ressources JSM portent le même nom de gestion Meguri" + }, + "err_jsm_kind_was_deleted_externally_publication_paus_88543d": { + "en": "JSM {kind} was deleted externally. Publication paused.", + "ru": "Ресурс JSM {kind} удалён извне. Публикация приостановлена.", + "fr": "La ressource JSM {kind} a été supprimée à l’extérieur. Publication suspendue." + }, + "err_jsm_kind_changed_outside_meguri_publication_paus_ac8220": { + "en": "JSM {kind} changed outside Meguri. Publication paused.", + "ru": "Ресурс JSM {kind} изменён вне Meguri. Публикация приостановлена.", + "fr": "La ressource JSM {kind} a été modifiée hors de Meguri. Publication suspendue." + }, + "err_jsm_schedule_contains_external_overrides_publica_ff6a5d": { + "en": "JSM schedule contains external overrides. Publication paused.", + "ru": "Расписание JSM содержит внешние подмены. Публикация приостановлена.", + "fr": "Le planning JSM contient des remplacements externes. Publication suspendue." + }, + "err_override_has_no_alias_0bab5d": { + "en": "Override has no alias", + "ru": "У подмены нет псевдонима", + "fr": "Le remplacement n’a pas d’alias" + }, + "err_unexpected_jsm_override_identifier_896f12": { + "en": "Unexpected JSM override identifier", + "ru": "Неожиданный идентификатор подмены JSM", + "fr": "Identifiant de remplacement JSM inattendu" + }, + "err_jsm_create_endpoint_was_not_found_1be86a": { + "en": "JSM create endpoint was not found", + "ru": "Не найден адрес создания ресурса JSM", + "fr": "Point de création JSM introuvable" + }, + "err_unexpected_jsm_resource_identifier_dedd70": { + "en": "Unexpected JSM resource identifier", + "ru": "Неожиданный идентификатор ресурса JSM", + "fr": "Identifiant de ressource JSM inattendu" + }, + "err_jsm_resource_is_missing_after_publication_3448ad": { + "en": "JSM resource is missing after publication", + "ru": "После публикации ресурс JSM отсутствует", + "fr": "La ressource JSM est absente après publication" + }, + "err_jsm_kind_read_back_does_not_match_the_requested_63440e": { + "en": "JSM {kind} read-back does not match the requested configuration", + "ru": "Проверка JSM {kind} после записи не соответствует запрошенной конфигурации", + "fr": "La relecture JSM {kind} ne correspond pas à la configuration demandée" + }, + "err_managed_by_meguri_p0_e8ef37": { + "en": "Managed by Meguri · {p0}", + "ru": "Управляется Meguri · {p0}", + "fr": "Géré par Meguri · {p0}" + }, + "err_escalation_references_an_empty_schedule_2a0f3c": { + "en": "Escalation references an empty schedule", + "ru": "Эскалация ссылается на пустое расписание", + "fr": "L’escalade référence un planning vide" + }, + "err_missing_escalation_mapping_3fd412": { + "en": "Missing escalation mapping", + "ru": "Отсутствует привязка эскалации", + "fr": "Correspondance d’escalade manquante" + }, + "err_an_obsolete_jsm_resource_changed_externally_dele_a26bda": { + "en": "An obsolete JSM resource changed externally; deletion paused", + "ru": "Устаревший ресурс JSM изменён извне; удаление приостановлено", + "fr": "Une ressource JSM obsolète a changé à l’extérieur ; suppression suspendue" + }, + "err_an_obsolete_jsm_schedule_has_external_overrides_c6de63": { + "en": "An obsolete JSM schedule has external overrides; deletion paused", + "ru": "Устаревшее расписание JSM содержит внешние подмены; удаление приостановлено", + "fr": "Un planning JSM obsolète contient des remplacements externes ; suppression suspendue" + }, + "err_publication_time_limit_reached_completed_steps_a_e404c5": { + "en": "Publication time limit reached; completed steps are checkpointed for retry", + "ru": "Истекло время публикации; выполненные шаги сохранены для повтора", + "fr": "Délai de publication atteint ; les étapes terminées sont enregistrées pour la reprise" + }, + "schedule_setup": { + "en": "Schedule setup", + "ru": "Настройка расписаний", + "fr": "Configuration des plannings" + }, + "generated_resource": { + "en": "✦ Generated · edit the source", + "ru": "✦ Сгенерировано · изменение через источник", + "fr": "✦ Généré · modifier la source" + }, + "manual_resource": { + "en": "Manual", + "ru": "Вручную", + "fr": "Manuel" + }, + "generated_resources": { + "en": "Generated from your rules", + "ru": "Сгенерировано по вашим правилам", + "fr": "Généré à partir de vos modèles" + }, + "which_projects": { + "en": "For which projects?", + "ru": "Для каких проектов?", + "fr": "Pour quels projets ?" + }, + "repeat_for": { + "en": "One set for every item in", + "ru": "Один набор для каждого элемента", + "fr": "Un ensemble pour chaque élément de" + }, + "one_schedule_set": { + "en": "One shared set of schedules", + "ru": "Один общий набор расписаний", + "fr": "Un ensemble de plannings partagé" + }, + "custom_iterations": { + "en": "Custom loops and conditions", + "ru": "Свои циклы и условия", + "fr": "Boucles et conditions personnalisées" + }, + "what_to_create": { + "en": "Schedules and alert rules", + "ru": "Расписания и правила алертов", + "fr": "Plannings et règles d’alerte" + }, + "add_another_resource": { + "en": "+ Add schedule or rule", + "ru": "+ Расписание или правило", + "fr": "+ Planning ou règle" + }, + "owners_by_project": { + "en": "By project → {name}", + "ru": "По проекту → {name}", + "fr": "Par projet → {name}" + }, + "each_project_name": { + "en": "Project name → {name}", + "ru": "Имя проекта → {name}", + "fr": "Nom du projet → {name}" + }, + "custom_binding": { + "en": "Custom source…", + "ru": "Другой источник…", + "fr": "Source personnalisée…" + }, + "customize_value": { + "en": "Customize", + "ru": "Настроить", + "fr": "Personnaliser" + }, + "result_example": { + "en": "Example result", + "ru": "Пример результата", + "fr": "Exemple de résultat" + }, + "sample_missing": { + "en": "Complete the sources to see an example", + "ru": "Заполните источники для примера", + "fr": "Complétez les sources pour voir un exemple" + }, + "search_people_or_email": { + "en": "Search people or enter email", + "ru": "Поиск сотрудника или email", + "fr": "Rechercher une personne ou saisir un e-mail" + }, + "add_person_c126e2": { + "en": "+ Add person", + "ru": "+ Добавить сотрудника", + "fr": "+ Ajouter une personne" + }, + "publication_absent_detail": { + "en": "Publication blocked: {person} is absent in “{schedule}”, {start} → {end}. Arrange cover for this interval.", + "ru": "Публикация заблокирована: {person} отсутствует в расписании «{schedule}», {start} → {end}. Назначьте подмену на этот интервал.", + "fr": "Publication bloquée : {person} est absent dans « {schedule} », {start} → {end}. Organisez un remplacement sur cet intervalle." + }, + "coverage_to_arrange": { + "en": "Uncovered intervals: {count}", + "ru": "Интервалы без подмены: {count}", + "fr": "Intervalles sans remplacement : {count}" + }, + "open_affected_day": { + "en": "Arrange cover", + "ru": "Назначить подмену", + "fr": "Organiser un remplacement" + }, + "duration_hours": { + "en": "{count}h", + "ru": "{count}ч", + "fr": "{count}h" + }, + "duration_minutes": { + "en": "{count}m", + "ru": "{count}м", + "fr": "{count}min" + }, + "enum_tags": { + "en": "tags", + "ru": "теги", + "fr": "étiquettes" + }, + "enum_message": { + "en": "message", + "ru": "сообщение", + "fr": "message" + }, + "enum_description": { + "en": "description", + "ru": "описание", + "fr": "description" + }, + "enum_source": { + "en": "source", + "ru": "источник", + "fr": "source" + }, + "enum_equals": { + "en": "equals", + "ru": "равно", + "fr": "égal à" + }, + "enum_contains": { + "en": "contains", + "ru": "содержит", + "fr": "contient" + }, + "enum_starts-with": { + "en": "starts with", + "ru": "начинается с", + "fr": "commence par" + }, + "enum_matches": { + "en": "matches", + "ru": "соответствует", + "fr": "correspond à" + }, + "err_jsm_mapping_contains_a_non_member": { + "en": "JSM mapping contains a non-member", + "ru": "Привязка JSM содержит сотрудника вне команды", + "fr": "La correspondance JSM contient une personne hors de l’équipe" + }, + "err_jsm_collection_has_no_values": { + "en": "JSM collection has no values", + "ru": "Коллекция JSM не содержит значений", + "fr": "La collection JSM ne contient aucune valeur" + }, + "err_jsm_resource_disappeared_during_publication": { + "en": "JSM resource disappeared during publication", + "ru": "Ресурс JSM исчез во время публикации", + "fr": "La ressource JSM a disparu pendant la publication" + }, + "err_jsm_created_resource_without_an_id": { + "en": "JSM created resource without an ID", + "ru": "JSM создал ресурс без идентификатора", + "fr": "JSM a créé une ressource sans identifiant" + }, + "err_jsm_supports_at_most_500_rotations_per_schedule_shorten_the_publication_horizon": { + "en": "JSM supports at most 500 rotations per schedule; shorten the publication horizon", + "ru": "JSM поддерживает не более 500 ротаций на расписание; сократите горизонт публикации", + "fr": "JSM prend en charge au maximum 500 rotations par planning ; réduisez l’horizon de publication" + }, + "err_a_collection_supports_at_most_1_000_entries": { + "en": "A collection supports at most 1,000 entries", + "ru": "Коллекция поддерживает не более 1000 записей", + "fr": "Une collection prend en charge au maximum 1 000 entrées" + }, + "enum_configuration.updated": { + "en": "Configuration updated", + "ru": "Конфигурация обновлена", + "fr": "Configuration mise à jour" + }, + "enum_schedule.removed": { + "en": "Schedule removed", + "ru": "Расписание удалено", + "fr": "Planning supprimé" + }, + "enum_routing.reordered": { + "en": "Routing reordered", + "ru": "Порядок маршрутизации изменён", + "fr": "Routage réordonné" + }, + "enum_integration.updated": { + "en": "Provider updated", + "ru": "Провайдер обновлён", + "fr": "Fournisseur mis à jour" + }, + "enum_member.permissions_changed": { + "en": "Access changed", + "ru": "Доступ изменён", + "fr": "Droits modifiés" + }, + "enum_absence.created": { + "en": "Absence created", + "ru": "Отсутствие создано", + "fr": "Absence créée" + }, + "enum_absence.forced": { + "en": "Absence and cover applied", + "ru": "Отсутствие и подмены применены", + "fr": "Absence et remplacements appliqués" + }, + "enum_replacement.requested": { + "en": "Replacement requested", + "ru": "Запрошена подмена", + "fr": "Remplacement demandé" + }, + "enum_replacement.forced": { + "en": "Replacement applied without approval", + "ru": "Подмена применена без согласования", + "fr": "Remplacement appliqué sans accord" + }, + "enum_replacement.approve": { + "en": "Replacement approved", + "ru": "Подмена подтверждена", + "fr": "Remplacement approuvé" + }, + "enum_replacement.reject": { + "en": "Replacement declined", + "ru": "Подмена отклонена", + "fr": "Remplacement refusé" + }, + "enum_replacement.cancel": { + "en": "Replacement cancelled", + "ru": "Подмена отменена", + "fr": "Remplacement annulé" + }, + "enum_holidays.imported": { + "en": "Holidays imported", + "ru": "Праздники импортированы", + "fr": "Jours fériés importés" + }, + "enum_generation.applied": { + "en": "Generation applied", + "ru": "Генерация применена", + "fr": "Génération appliquée" + }, + "enum_publication.succeeded": { + "en": "Publication succeeded", + "ru": "Публикация выполнена", + "fr": "Publication réussie" + }, + "enum_tenant.drift_policy_changed": { + "en": "Drift policy changed", + "ru": "Политика внешних изменений обновлена", + "fr": "Politique de modifications externes mise à jour" + }, + "enum_tenant.working_interval_updated": { + "en": "Working interval updated", + "ru": "Рабочий интервал обновлён", + "fr": "Plage de travail mise à jour" + }, + "enum_person.country_changed": { + "en": "Country updated", + "ru": "Страна обновлена", + "fr": "Pays mis à jour" + }, + "enum_publication.requested": { + "en": "Publication requested", + "ru": "Публикация запрошена", + "fr": "Publication demandée" + }, + "enum_workspace.variables_applied": { + "en": "Shared variables applied", + "ru": "Общие переменные применены", + "fr": "Variables partagées appliquées" + }, + "enum_demo.requests_expired": { + "en": "Demo requests expired", + "ru": "Срок демозапросов истёк", + "fr": "Demandes de démonstration expirées" + }, + "cover_invalid_time": { + "en": "Enter a valid time as HH:MM", + "ru": "Введите корректное время HH:MM", + "fr": "Saisissez une heure valide au format HH:MM" + }, + "cover_start_date": { + "en": "Start date", + "ru": "Дата начала", + "fr": "Date de début" + }, + "cover_start_time": { + "en": "Start time", + "ru": "Время начала", + "fr": "Heure de début" + }, + "cover_end_date": { + "en": "End date", + "ru": "Дата окончания", + "fr": "Date de fin" + }, + "cover_end_time": { + "en": "End time", + "ru": "Время окончания", + "fr": "Heure de fin" + }, + "cover_return_date": { + "en": "Return start date", + "ru": "Начало ответной подмены", + "fr": "Date de début en retour" + }, + "cover_return_time": { + "en": "Return start time", + "ru": "Время начала ответной подмены", + "fr": "Heure de début en retour" + }, + "cover_return_end_date": { + "en": "Return end date", + "ru": "Окончание ответной подмены", + "fr": "Date de fin en retour" + }, + "cover_return_end_time": { + "en": "Return end time", + "ru": "Время окончания ответной подмены", + "fr": "Heure de fin en retour" + }, + "cover_timezone": { + "en": "Dates and times in {zone}", + "ru": "Даты и время в {zone}", + "fr": "Dates et heures en {zone}" + }, + "cover_swap_optional": { + "en": "Offer a shift in return", + "ru": "Предложить смену взамен", + "fr": "Proposer un créneau en retour" + }, + "cover_checking": { + "en": "Checking…", + "ru": "Проверяем…", + "fr": "Vérification…" + }, + "generation_check_failed": { + "en": "Could not check the draft", + "ru": "Не удалось проверить черновик", + "fr": "Impossible de vérifier le brouillon" + }, + "generation_checked": { + "en": "Draft checked", + "ru": "Черновик проверен", + "fr": "Brouillon vérifié" + }, + "generation_show_errors": { + "en": "Show details", + "ru": "Подробности", + "fr": "Afficher les détails" + }, + "generation_hide_errors": { + "en": "Hide details", + "ru": "Скрыть подробности", + "fr": "Masquer les détails" + }, + "cover_local_time_invalid": { + "en": "The local time {date} {time} is ambiguous or does not exist in {zone}. Choose another time.", + "ru": "Местное время {date} {time} неоднозначно или не существует в зоне {zone}. Выберите другое время.", + "fr": "L’heure locale {date} {time} est ambiguë ou n’existe pas dans le fuseau {zone}. Choisissez une autre heure." + }, + "binding_preview_title": { + "en": "Value preview", + "ru": "Предпросмотр значения", + "fr": "Aperçu de la valeur" + }, + "binding_preview_hint": { + "en": "Example · validation runs on the server", + "ru": "Пример · проверка выполняется на сервере", + "fr": "Exemple · validation sur le serveur" + }, + "binding_preview_row": { + "en": "Example · {row}", + "ru": "Пример · {row}", + "fr": "Exemple · {row}" + }, + "binding_preview_resource": { + "en": "Resource reference · example, not its identifier", + "ru": "Ссылка на ресурс · пример, не его идентификатор", + "fr": "Référence de ressource · exemple, pas son identifiant" + }, + "source_saved_resource": { + "en": "Manual schedule · {name}", + "ru": "Ручное расписание · {name}", + "fr": "Planning manuel · {name}" + }, + "source_saved_policy": { + "en": "Manual escalation · {name}", + "ru": "Ручная эскалация · {name}", + "fr": "Escalade manuelle · {name}" + }, + "source_generated_schedule": { + "en": "Generated schedule", + "ru": "Генерируемое расписание", + "fr": "Planning généré" + }, + "source_generated_policy": { + "en": "Generated escalation", + "ru": "Генерируемая эскалация", + "fr": "Escalade générée" + }, + "source_generated_resource": { + "en": "Generated resource", + "ru": "Генерируемый ресурс", + "fr": "Ressource générée" + }, + "generated_reference_hint": { + "en": "The reference is resolved during generation; repeated rules use the resource for the current item.", + "ru": "Ссылка подставится при генерации; для повторяющихся правил — на ресурс текущего элемента.", + "fr": "La référence est résolue à la génération ; les règles répétées utilisent la ressource de l’élément courant." + }, + "generated_direct_reference_hint": { + "en": "This saved reference points to a generated resource. Choose “From a generated resource” or select a manual resource.", + "ru": "Сохранённая ссылка указывает на сгенерированный ресурс. Выберите «Из генерируемого ресурса» или ресурс, созданный вручную.", + "fr": "Cette référence enregistrée pointe vers une ressource générée. Choisissez « Depuis une ressource générée » ou une ressource manuelle." + }, + "audit_raw_details": { + "en": "Original JSON · identifiers and UTC timestamps", + "ru": "Исходный JSON · идентификаторы и время UTC", + "fr": "JSON original · identifiants et dates UTC" + }, + "setup_sources": { + "en": "Data & generation", + "ru": "Данные и генерация", + "fr": "Données et génération" + }, + "setup_sources_hint": { + "en": "Shared data and generation rules. Review changes before applying.", + "ru": "Общие данные и правила генерации. Проверьте изменения перед применением.", + "fr": "Données partagées et règles de génération. Vérifiez les modifications avant de les appliquer." + }, + "setup_review_changes": { + "en": "Review changes", + "ru": "Проверка изменений", + "fr": "Vérifier les modifications" + }, + "setup_availability": { + "en": "Absences & holidays", + "ru": "Отсутствия и праздники", + "fr": "Absences et jours fériés" + }, + "setup_view_absences": { + "en": "View absences", + "ru": "Посмотреть отсутствия", + "fr": "Voir les absences" + }, + "setup_resources": { + "en": "Resources", + "ru": "Ресурсы", + "fr": "Ressources" + }, + "manual_resources": { + "en": "Manually created", + "ru": "Созданные вручную", + "fr": "Créées manuellement" + }, + "create_schedule": { + "en": "+ Schedule", + "ru": "+ Расписание", + "fr": "+ Planning" + }, + "create_escalation": { + "en": "+ Escalation policy", + "ru": "+ Политика эскалации", + "fr": "+ Politique d’escalade" + }, + "create_routing": { + "en": "+ Routing rule", + "ru": "+ Правило маршрутизации", + "fr": "+ Règle de routage" + }, + "resource_schedule": { + "en": "Schedule", + "ru": "Расписание", + "fr": "Planning" + }, + "resource_escalation": { + "en": "Escalation", + "ru": "Эскалация", + "fr": "Escalade" + }, + "resource_routing": { + "en": "Routing", + "ru": "Маршрутизация", + "fr": "Routage" + }, + "generated_symbol": { + "en": "✦", + "ru": "✦", + "fr": "✦" + }, + "rule_iterations_short": { + "en": "Iterations: {count}", + "ru": "Итераций: {count}", + "fr": "Itérations : {count}" + }, + "rule_blocks_short": { + "en": "Blocks: {count}", + "ru": "Блоков: {count}", + "fr": "Blocs : {count}" + }, + "generated_by_rule": { + "en": "Generated by: {name}", + "ru": "Создано правилом: {name}", + "fr": "Généré par : {name}" + }, + "rotation_brief": { + "en": "Every {days} d · {time} {zone}", + "ru": "Каждые {days} д. · {time} {zone}", + "fr": "Tous les {days} j · {time} {zone}" + }, + "fallback_brief": { + "en": "Fallback: {name}", + "ru": "Резерв: {name}", + "fr": "Repli : {name}" + }, + "escalation_step_brief": { + "en": "{minutes} min: {recipient}", + "ru": "{minutes} мин: {recipient}", + "fr": "{minutes} min : {recipient}" + }, + "validation_details": { + "en": "Validation status and details", + "ru": "Статус проверки и подробности", + "fr": "État de validation et détails" + }, + "validation_not_checked": { + "en": "Not checked yet", + "ru": "Ещё не проверено", + "fr": "Pas encore vérifié" + }, + "validation_no_errors": { + "en": "No errors found.", + "ru": "Ошибок не найдено.", + "fr": "Aucune erreur détectée." + }, + "validation_on_save": { + "en": "This form is validated when saved.", + "ru": "Эта форма проверяется при сохранении.", + "fr": "Ce formulaire est validé lors de l’enregistrement." + }, + "validation_secondary": { + "en": "May be a consequence of an earlier error. Fix the source error first.", + "ru": "Может быть следствием предыдущей ошибки. Сначала исправьте исходную.", + "fr": "Peut découler d’une erreur précédente. Corrigez d’abord la cause." + }, + "validation_edit_rule": { + "en": "Edit rule: {name}", + "ru": "Изменить правило: {name}", + "fr": "Modifier la règle : {name}" + }, + "validation_edit_variable": { + "en": "Edit variable: {name}", + "ru": "Изменить переменную: {name}", + "fr": "Modifier la variable : {name}" + }, + "validation_other_team": { + "en": "Open affected team in a new tab", + "ru": "Открыть затронутую команду в новой вкладке", + "fr": "Ouvrir l’équipe concernée dans un nouvel onglet" + }, + "absence_all_schedules": { + "en": "All affected schedules", + "ru": "Все затронутые расписания", + "fr": "Tous les plannings concernés" + }, + "absence_end_exclusive": { + "en": "Range ends (exclusive)", + "ru": "Конец интервала (не включительно)", + "fr": "Fin de la plage (exclue)" + }, + "absence_interval_zone": { + "en": "Dates and times: {zone}", + "ru": "Даты и время: {zone}", + "fr": "Dates et heures : {zone}" + }, + "absence_invalid_interval": { + "en": "Choose a positive interval of at most 90 days within the absence.", + "ru": "Выберите интервал до 90 дней с концом после начала, в пределах отсутствия.", + "fr": "Choisissez une plage positive de 90 jours maximum dans l’absence." + }, + "start_time": { + "en": "Start time", + "ru": "Время начала", + "fr": "Heure de début" + }, + "end_time": { + "en": "End time", + "ru": "Время окончания", + "fr": "Heure de fin" + }, + "coverage_attention": { + "en": "Coverage to complete", + "ru": "Нужно завершить подмены", + "fr": "Remplacements à finaliser" + }, + "coverage_publication_wait": { + "en": "Absence is already active. The new schedule cannot be published until all coverage is confirmed.", + "ru": "Отсутствие уже действует. Новое расписание нельзя опубликовать, пока все подмены не подтверждены.", + "fr": "L’absence est déjà active. Le nouvel horaire ne peut pas être publié tant que tous les remplacements ne sont pas confirmés." + }, + "coverage_waiting": { + "en": "Awaiting approval", + "ru": "Ожидает подтверждения", + "fr": "En attente de confirmation" + }, + "coverage_missing": { + "en": "No substitute assigned", + "ru": "Замена не назначена", + "fr": "Aucun remplaçant désigné" + }, + "coverage_substitute": { + "en": "Substitute: {names}", + "ru": "Заменяет: {names}", + "fr": "Remplaçant : {names}" + }, + "coverage_waiting_for": { + "en": "Waiting for: {names}", + "ru": "Ждём подтверждения: {names}", + "fr": "Confirmation attendue de : {names}" + }, + "coverage_open_requests": { + "en": "View requests", + "ru": "Открыть запросы", + "fr": "Voir les demandes" + }, + "generated_routing_after_named": { + "en": "After “{name}”", + "ru": "После «{name}»", + "fr": "Après « {name} »" + }, + "schedule_type_named": { + "en": "Schedule: {name}", + "ru": "Расписание: {name}", + "fr": "Planning : {name}" + }, + "resource_detail_type": { + "en": "Schedule type", + "ru": "Тип расписания", + "fr": "Type de planning" + }, + "resource_detail_cycle": { + "en": "Rotation cycle", + "ru": "Цикл ротации", + "fr": "Cycle de rotation" + }, + "resource_rotation_cycle": { + "en": "Every {days} days · starts {date}", + "ru": "Каждые {days} дней · начало {date}", + "fr": "Tous les {days} jours · début le {date}" + }, + "resource_person_order": { + "en": "Assignee {number}", + "ru": "Ответственный {number}", + "fr": "Responsable {number}" + }, + "resource_rotation_order": { + "en": "Takes the next shift in this order", + "ru": "Принимает смену в указанном порядке", + "fr": "Prend le prochain tour dans cet ordre" + }, + "resource_fixed_owner": { + "en": "Always assigned to this schedule", + "ru": "Постоянно назначен на это расписание", + "fr": "Toujours affecté à ce planning" + }, + "resource_fallback_when": { + "en": "When everyone above is unavailable", + "ru": "Если все ответственные выше недоступны", + "fr": "Si tous les responsables précédents sont indisponibles" + }, + "resource_detail_trigger": { + "en": "Trigger", + "ru": "Начало", + "fr": "Déclencheur" + }, + "resource_step_number": { + "en": "Step {number}", + "ru": "Шаг {number}", + "fr": "Étape {number}" + }, + "resource_detail_repeat": { + "en": "Repeat", + "ru": "Повторение", + "fr": "Répétition" + }, + "resource_all_conditions": { + "en": "Match all conditions", + "ru": "Все условия должны совпасть", + "fr": "Toutes les conditions doivent correspondre" + }, + "resource_detail_destination": { + "en": "Route to", + "ru": "Направить в", + "fr": "Acheminer vers" + }, + "builder_rule_name": { + "en": "Rule name", + "ru": "Название правила", + "fr": "Nom de la règle" + }, + "builder_iterations": { + "en": "Iterations", + "ru": "Итерации", + "fr": "Itérations" + }, + "builder_once": { + "en": "No iterations · generate once", + "ru": "Без итераций · создать один раз", + "fr": "Sans itération · générer une fois" + }, + "builder_alias": { + "en": "Item alias", + "ru": "Имя элемента", + "fr": "Alias de l’élément" + }, + "builder_alias_placeholder": { + "en": "Item", + "ru": "Элемент", + "fr": "Élément" + }, + "builder_choose_collection": { + "en": "Select a list or dictionary", + "ru": "Выберите список или словарь", + "fr": "Choisir une liste ou un dictionnaire" + }, + "builder_remove_iteration": { + "en": "Remove iteration {name}", + "ru": "Удалить итерацию {name}", + "fr": "Supprimer l’itération {name}" + }, + "builder_filter": { + "en": "Condition", + "ru": "Условие", + "fr": "Condition" + }, + "builder_outputs": { + "en": "Output blocks", + "ru": "Выходные блоки", + "fr": "Blocs de sortie" + }, + "builder_add_output": { + "en": "Add a schedule, escalation policy or routing rule.", + "ru": "Добавьте расписание, политику эскалации или правило роутинга.", + "fr": "Ajouter un planning, une politique d’escalade ou une règle de routage." + }, + "binding_picker_button": { + "en": "⋯", + "ru": "⋯", + "fr": "⋯" + }, + "preview_created": { + "en": "+{count} new", + "ru": "+{count} новых", + "fr": "+{count} nouveaux" + }, + "preview_updated": { + "en": "{count} updated", + "ru": "{count} изменено", + "fr": "{count} modifiés" + }, + "preview_removed": { + "en": "−{count} removed", + "ru": "−{count} удалено", + "fr": "−{count} supprimés" + }, + "preview_unchanged": { + "en": "Unchanged: {count}", + "ru": "Без изменений: {count}", + "fr": "Inchangés : {count}" + }, + "demo_extra_users": { + "en": "dave / erin", + "ru": "dave / erin", + "fr": "dave / erin" + }, + "demo_extra_users_hint": { + "en": "Engineering · service ownership and cover", + "ru": "Engineering · ответственные и подмены", + "fr": "Engineering · responsables et remplacements" + }, + "rule_result_title": { + "en": "Generated resources", + "ru": "Ресурсы правила", + "fr": "Ressources générées" + }, + "rule_result_count": { + "en": "Resources: {count}", + "ru": "Ресурсов: {count}", + "fr": "Ressources : {count}" + }, + "rule_result_invalid": { + "en": "Check the configuration errors below", + "ru": "Исправьте ошибки конфигурации — подробности ниже", + "fr": "Corrigez les erreurs de configuration ci-dessous" + }, + "rule_result_waiting": { + "en": "Waiting for server validation", + "ru": "Ожидание проверки сервера", + "fr": "En attente de validation du serveur" + }, + "rule_result_disabled": { + "en": "Rule disabled — no resources", + "ru": "Правило выключено — ресурсов нет", + "fr": "Règle désactivée — aucune ressource" + }, + "rule_result_empty": { + "en": "No resources — check iterations, filter and output blocks", + "ru": "Ресурсов нет — проверьте итерации, условие и выходные блоки", + "fr": "Aucune ressource — vérifiez les itérations, le filtre et les blocs de sortie" + }, + "enum_workspace.created": { + "en": "Workspace created", + "ru": "Пространство создано", + "fr": "Espace créé" + }, + "workspace_settings_only": { + "en": "Change workspace settings using their dedicated forms.", + "ru": "Меняйте настройки пространства в соответствующих формах.", + "fr": "Modifiez les paramètres de l’espace dans leurs formulaires dédiés." + }, + "data_values_mode": { + "en": "Values", + "ru": "Значения", + "fr": "Valeurs" + }, + "data_structure_mode": { + "en": "Structure", + "ru": "Структура", + "fr": "Structure" + }, + "data_toggle": { + "en": "Expand or collapse {name}", + "ru": "Развернуть или свернуть {name}", + "fr": "Développer ou réduire {name}" + }, + "data_duplicate": { + "en": "Duplicate entry", + "ru": "Дублировать элемент", + "fr": "Dupliquer l’entrée" + }, + "data_expanded_symbol": { + "en": "▾", + "ru": "▾", + "fr": "▾" + }, + "data_collapsed_symbol": { + "en": "▸", + "ru": "▸", + "fr": "▸" + }, + "data_duplicate_symbol": { + "en": "⧉", + "ru": "⧉", + "fr": "⧉" + }, + "map_open": { + "en": "Map", + "ru": "Карта", + "fr": "Carte" + }, + "map_title": { + "en": "Generation map", + "ru": "Карта генерации", + "fr": "Carte de génération" + }, + "map_hint": { + "en": "Rules run from left to right. Their resources appear below; stacked cards represent repeated outputs.", + "ru": "Правила выполняются слева направо. Результаты — под своим правилом; стопки карточек обозначают повторяющиеся ресурсы.", + "fr": "Les règles s’exécutent de gauche à droite. Leurs ressources sont en dessous ; les cartes empilées indiquent des sorties répétées." + }, + "map_close": { + "en": "Close", + "ru": "Закрыть", + "fr": "Fermer" + }, + "map_refresh": { + "en": "Refresh preview", + "ru": "Обновить превью", + "fr": "Actualiser l’aperçu" + }, + "map_zoom_out": { + "en": "Zoom out", + "ru": "Уменьшить", + "fr": "Dézoomer" + }, + "map_zoom_in": { + "en": "Zoom in", + "ru": "Увеличить", + "fr": "Zoomer" + }, + "map_minus": { + "en": "−", + "ru": "−", + "fr": "−" + }, + "map_plus": { + "en": "+", + "ru": "+", + "fr": "+" + }, + "map_fit": { + "en": "Fit all", + "ru": "Показать всё", + "fr": "Tout afficher" + }, + "map_selected": { + "en": "Selected node", + "ru": "Выбранный узел", + "fr": "Nœud sélectionné" + }, + "map_expand": { + "en": "Expand outputs", + "ru": "Показать выходные блоки", + "fr": "Afficher les sorties" + }, + "map_collapse": { + "en": "Collapse outputs", + "ru": "Свернуть выходные блоки", + "fr": "Réduire les sorties" + }, + "map_edit": { + "en": "Edit source", + "ru": "Редактировать источник", + "fr": "Modifier la source" + }, + "map_pass": { + "en": "Iteration pass", + "ru": "Проход итерации", + "fr": "Passage d’itération" + }, + "map_all": { + "en": "All passes", + "ru": "Все проходы", + "fr": "Tous les passages" + }, + "map_inputs": { + "en": "Inputs", + "ru": "Входы", + "fr": "Entrées" + }, + "map_resources": { + "en": "Resources · {count}", + "ru": "Ресурсы · {count}", + "fr": "Ressources · {count}" + }, + "map_empty": { + "en": "No resources produced here.", + "ru": "Здесь ресурсы не созданы.", + "fr": "Aucune ressource produite ici." + }, + "map_unverified": { + "en": "Fix validation errors to inspect verified resources.", + "ru": "Исправьте ошибки проверки, чтобы увидеть проверенные ресурсы.", + "fr": "Corrigez les erreurs pour voir les ressources validées." + }, + "map_variables": { + "en": "Variables", + "ru": "Переменные", + "fr": "Variables" + }, + "map_rules": { + "en": "Rule order →", + "ru": "Порядок правил →", + "fr": "Ordre des règles →" + }, + "map_iterations": { + "en": "Iteration / resource group", + "ru": "Итерация / группа ресурсов", + "fr": "Itération / groupe de ressources" + }, + "map_readonly": { + "en": "Drag to pan · Scroll to zoom · Moving nodes only changes this view", + "ru": "Перетаскивайте поле · Масштаб колёсиком · Перемещение узлов меняет только вид", + "fr": "Glisser pour naviguer · Molette pour zoomer · Déplacer les nœuds ne modifie que cette vue" + }, + "map_once": { + "en": "Single run", + "ru": "Один запуск", + "fr": "Exécution unique" + }, + "map_included": { + "en": "Included", + "ru": "Включён", + "fr": "Inclus" + }, + "map_filtered": { + "en": "Filtered out · {count}", + "ru": "Отфильтровано · {count}", + "fr": "Exclus · {count}" + }, + "map_error": { + "en": "Error", + "ru": "Ошибка", + "fr": "Erreur" + }, + "map_invalid": { + "en": "Validation error", + "ru": "Ошибка проверки", + "fr": "Erreur de validation" + }, + "map_count": { + "en": "Resources · {count}", + "ru": "Ресурсы · {count}", + "fr": "Ressources · {count}" + }, + "map_loop": { + "en": "Iteration · {count} passes", + "ru": "Итерация · {count} проходов", + "fr": "Itération · {count} passages" + }, + "map_condition": { + "en": "Condition", + "ru": "Условие", + "fr": "Condition" + }, + "map_group": { + "en": "Generated resource group", + "ru": "Группа созданных ресурсов", + "fr": "Groupe de ressources générées" + }, + "map_condition_left": { + "en": "Compared value", + "ru": "Проверяемое значение", + "fr": "Valeur comparée" + }, + "map_condition_operation": { + "en": "Comparison", + "ru": "Сравнение", + "fr": "Comparaison" + }, + "map_condition_right": { + "en": "Expected value", + "ru": "Ожидаемое значение", + "fr": "Valeur attendue" + }, + "map_step_delay": { + "en": "Step {step} · delay (minutes)", + "ru": "Шаг {step} · задержка (минуты)", + "fr": "Étape {step} · délai (minutes)" + }, + "map_step_recipient": { + "en": "Step {step} · recipient", + "ru": "Шаг {step} · получатель", + "fr": "Étape {step} · destinataire" + }, + "map_not": { + "en": "Not", + "ru": "Не", + "fr": "Non" + }, + "map_matches_all": { + "en": "All alerts", + "ru": "Все алерты", + "fr": "Toutes les alertes" + }, + "map_person": { + "en": "Person", + "ru": "Сотрудник", + "fr": "Personne" + }, + "map_empty_value": { + "en": "Empty list", + "ru": "Пустой список", + "fr": "Liste vide" + }, + "map_missing_value": { + "en": "Value could not be resolved", + "ru": "Значение не удалось вычислить", + "fr": "Valeur impossible à résoudre" + }, + "map_missing_reference": { + "en": "Resource could not be resolved", + "ru": "Ресурс не удалось найти", + "fr": "Ressource introuvable" + }, + "map_no_inputs": { + "en": "No evaluated inputs for this selection. Choose an included pass or fix the validation errors.", + "ru": "Для этого выбора нет вычисленных значений. Выберите включённый проход или исправьте ошибки.", + "fr": "Aucune valeur évaluée pour cette sélection. Choisissez un passage inclus ou corrigez les erreurs." + }, + "map_distinct_values": { + "en": "{count} different values", + "ru": "Разных значений: {count}", + "fr": "{count} valeurs différentes" + }, + "map_no_sources": { + "en": "Uses fixed values only.", + "ru": "Использует только заданные значения.", + "fr": "Utilise uniquement des valeurs fixes." + }, + "map_view_source": { + "en": "View source", + "ru": "Открыть источник", + "fr": "Voir la source" + }, + "map_choose_pass_values": { + "en": "Select a pass above to inspect its values.", + "ru": "Выберите проход выше, чтобы увидеть его значения.", + "fr": "Sélectionnez un passage ci-dessus pour voir ses valeurs." + } +} diff --git a/static/resource-details.html b/static/resource-details.html new file mode 100644 index 0000000..7660562 --- /dev/null +++ b/static/resource-details.html @@ -0,0 +1,20 @@ +
+ + + +
diff --git a/static/resource-inventory.html b/static/resource-inventory.html new file mode 100644 index 0000000..9244210 --- /dev/null +++ b/static/resource-inventory.html @@ -0,0 +1,43 @@ +
+

+
+
+ +
+
+
diff --git a/static/validation-status.html b/static/validation-status.html new file mode 100644 index 0000000..55acb37 --- /dev/null +++ b/static/validation-status.html @@ -0,0 +1,6 @@ +
+ +
diff --git a/static/vendor/ALPINE-LICENSE.md b/static/vendor/ALPINE-LICENSE.md new file mode 100644 index 0000000..c3cc2dd --- /dev/null +++ b/static/vendor/ALPINE-LICENSE.md @@ -0,0 +1,21 @@ +# MIT License + +Copyright © 2019-2021 Caleb Porzio and contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/static/vendor/README.md b/static/vendor/README.md new file mode 100644 index 0000000..040bac8 --- /dev/null +++ b/static/vendor/README.md @@ -0,0 +1,5 @@ +Alpine.js 3.14.9, vendored from the npm package's `dist/cdn.min.js`. +Source: https://github.com/alpinejs/alpine/tree/v3.14.9 +License: `ALPINE-LICENSE.md` (MIT). + +The application serves this asset locally and has no runtime CDN dependency. Standard Alpine requires the documented CSP `unsafe-eval` allowance. A future move to Alpine's CSP build needs a browser regression run because expressions differ. diff --git a/static/vendor/alpine.js b/static/vendor/alpine.js new file mode 100644 index 0000000..2fdd6ec --- /dev/null +++ b/static/vendor/alpine.js @@ -0,0 +1,5 @@ +(()=>{var nt=!1,it=!1,W=[],ot=-1;function Ut(e){Rn(e)}function Rn(e){W.includes(e)||W.push(e),Mn()}function Wt(e){let t=W.indexOf(e);t!==-1&&t>ot&&W.splice(t,1)}function Mn(){!it&&!nt&&(nt=!0,queueMicrotask(Nn))}function Nn(){nt=!1,it=!0;for(let e=0;ee.effect(t,{scheduler:r=>{st?Ut(r):r()}}),at=e.raw}function ct(e){N=e}function Yt(e){let t=()=>{};return[n=>{let i=N(n);return e._x_effects||(e._x_effects=new Set,e._x_runEffects=()=>{e._x_effects.forEach(o=>o())}),e._x_effects.add(i),t=()=>{i!==void 0&&(e._x_effects.delete(i),$(i))},i},()=>{t()}]}function ve(e,t){let r=!0,n,i=N(()=>{let o=e();JSON.stringify(o),r?n=o:queueMicrotask(()=>{t(o,n),n=o}),r=!1});return()=>$(i)}var Xt=[],Zt=[],Qt=[];function er(e){Qt.push(e)}function te(e,t){typeof t=="function"?(e._x_cleanups||(e._x_cleanups=[]),e._x_cleanups.push(t)):(t=e,Zt.push(t))}function Ae(e){Xt.push(e)}function Oe(e,t,r){e._x_attributeCleanups||(e._x_attributeCleanups={}),e._x_attributeCleanups[t]||(e._x_attributeCleanups[t]=[]),e._x_attributeCleanups[t].push(r)}function lt(e,t){e._x_attributeCleanups&&Object.entries(e._x_attributeCleanups).forEach(([r,n])=>{(t===void 0||t.includes(r))&&(n.forEach(i=>i()),delete e._x_attributeCleanups[r])})}function tr(e){for(e._x_effects?.forEach(Wt);e._x_cleanups?.length;)e._x_cleanups.pop()()}var ut=new MutationObserver(mt),ft=!1;function ue(){ut.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),ft=!0}function dt(){kn(),ut.disconnect(),ft=!1}var le=[];function kn(){let e=ut.takeRecords();le.push(()=>e.length>0&&mt(e));let t=le.length;queueMicrotask(()=>{if(le.length===t)for(;le.length>0;)le.shift()()})}function m(e){if(!ft)return e();dt();let t=e();return ue(),t}var pt=!1,Se=[];function rr(){pt=!0}function nr(){pt=!1,mt(Se),Se=[]}function mt(e){if(pt){Se=Se.concat(e);return}let t=[],r=new Set,n=new Map,i=new Map;for(let o=0;o{s.nodeType===1&&s._x_marker&&r.add(s)}),e[o].addedNodes.forEach(s=>{if(s.nodeType===1){if(r.has(s)){r.delete(s);return}s._x_marker||t.push(s)}})),e[o].type==="attributes")){let s=e[o].target,a=e[o].attributeName,c=e[o].oldValue,l=()=>{n.has(s)||n.set(s,[]),n.get(s).push({name:a,value:s.getAttribute(a)})},u=()=>{i.has(s)||i.set(s,[]),i.get(s).push(a)};s.hasAttribute(a)&&c===null?l():s.hasAttribute(a)?(u(),l()):u()}i.forEach((o,s)=>{lt(s,o)}),n.forEach((o,s)=>{Xt.forEach(a=>a(s,o))});for(let o of r)t.some(s=>s.contains(o))||Zt.forEach(s=>s(o));for(let o of t)o.isConnected&&Qt.forEach(s=>s(o));t=null,r=null,n=null,i=null}function Ce(e){return z(B(e))}function k(e,t,r){return e._x_dataStack=[t,...B(r||e)],()=>{e._x_dataStack=e._x_dataStack.filter(n=>n!==t)}}function B(e){return e._x_dataStack?e._x_dataStack:typeof ShadowRoot=="function"&&e instanceof ShadowRoot?B(e.host):e.parentNode?B(e.parentNode):[]}function z(e){return new Proxy({objects:e},Dn)}var Dn={ownKeys({objects:e}){return Array.from(new Set(e.flatMap(t=>Object.keys(t))))},has({objects:e},t){return t==Symbol.unscopables?!1:e.some(r=>Object.prototype.hasOwnProperty.call(r,t)||Reflect.has(r,t))},get({objects:e},t,r){return t=="toJSON"?Pn:Reflect.get(e.find(n=>Reflect.has(n,t))||{},t,r)},set({objects:e},t,r,n){let i=e.find(s=>Object.prototype.hasOwnProperty.call(s,t))||e[e.length-1],o=Object.getOwnPropertyDescriptor(i,t);return o?.set&&o?.get?o.set.call(n,r)||!0:Reflect.set(i,t,r)}};function Pn(){return Reflect.ownKeys(this).reduce((t,r)=>(t[r]=Reflect.get(this,r),t),{})}function Te(e){let t=n=>typeof n=="object"&&!Array.isArray(n)&&n!==null,r=(n,i="")=>{Object.entries(Object.getOwnPropertyDescriptors(n)).forEach(([o,{value:s,enumerable:a}])=>{if(a===!1||s===void 0||typeof s=="object"&&s!==null&&s.__v_skip)return;let c=i===""?o:`${i}.${o}`;typeof s=="object"&&s!==null&&s._x_interceptor?n[o]=s.initialize(e,c,o):t(s)&&s!==n&&!(s instanceof Element)&&r(s,c)})};return r(e)}function Re(e,t=()=>{}){let r={initialValue:void 0,_x_interceptor:!0,initialize(n,i,o){return e(this.initialValue,()=>In(n,i),s=>ht(n,i,s),i,o)}};return t(r),n=>{if(typeof n=="object"&&n!==null&&n._x_interceptor){let i=r.initialize.bind(r);r.initialize=(o,s,a)=>{let c=n.initialize(o,s,a);return r.initialValue=c,i(o,s,a)}}else r.initialValue=n;return r}}function In(e,t){return t.split(".").reduce((r,n)=>r[n],e)}function ht(e,t,r){if(typeof t=="string"&&(t=t.split(".")),t.length===1)e[t[0]]=r;else{if(t.length===0)throw error;return e[t[0]]||(e[t[0]]={}),ht(e[t[0]],t.slice(1),r)}}var ir={};function y(e,t){ir[e]=t}function fe(e,t){let r=Ln(t);return Object.entries(ir).forEach(([n,i])=>{Object.defineProperty(e,`$${n}`,{get(){return i(t,r)},enumerable:!1})}),e}function Ln(e){let[t,r]=_t(e),n={interceptor:Re,...t};return te(e,r),n}function or(e,t,r,...n){try{return r(...n)}catch(i){re(i,e,t)}}function re(e,t,r=void 0){e=Object.assign(e??{message:"No error message given."},{el:t,expression:r}),console.warn(`Alpine Expression Error: ${e.message} + +${r?'Expression: "'+r+`" + +`:""}`,t),setTimeout(()=>{throw e},0)}var Me=!0;function ke(e){let t=Me;Me=!1;let r=e();return Me=t,r}function R(e,t,r={}){let n;return x(e,t)(i=>n=i,r),n}function x(...e){return sr(...e)}var sr=xt;function ar(e){sr=e}function xt(e,t){let r={};fe(r,e);let n=[r,...B(e)],i=typeof t=="function"?$n(n,t):Fn(n,t,e);return or.bind(null,e,t,i)}function $n(e,t){return(r=()=>{},{scope:n={},params:i=[]}={})=>{let o=t.apply(z([n,...e]),i);Ne(r,o)}}var gt={};function jn(e,t){if(gt[e])return gt[e];let r=Object.getPrototypeOf(async function(){}).constructor,n=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(async()=>{ ${e} })()`:e,o=(()=>{try{let s=new r(["__self","scope"],`with (scope) { __self.result = ${n} }; __self.finished = true; return __self.result;`);return Object.defineProperty(s,"name",{value:`[Alpine] ${e}`}),s}catch(s){return re(s,t,e),Promise.resolve()}})();return gt[e]=o,o}function Fn(e,t,r){let n=jn(t,r);return(i=()=>{},{scope:o={},params:s=[]}={})=>{n.result=void 0,n.finished=!1;let a=z([o,...e]);if(typeof n=="function"){let c=n(n,a).catch(l=>re(l,r,t));n.finished?(Ne(i,n.result,a,s,r),n.result=void 0):c.then(l=>{Ne(i,l,a,s,r)}).catch(l=>re(l,r,t)).finally(()=>n.result=void 0)}}}function Ne(e,t,r,n,i){if(Me&&typeof t=="function"){let o=t.apply(r,n);o instanceof Promise?o.then(s=>Ne(e,s,r,n)).catch(s=>re(s,i,t)):e(o)}else typeof t=="object"&&t instanceof Promise?t.then(o=>e(o)):e(t)}var wt="x-";function C(e=""){return wt+e}function cr(e){wt=e}var De={};function d(e,t){return De[e]=t,{before(r){if(!De[r]){console.warn(String.raw`Cannot find directive \`${r}\`. \`${e}\` will use the default order of execution`);return}let n=G.indexOf(r);G.splice(n>=0?n:G.indexOf("DEFAULT"),0,e)}}}function lr(e){return Object.keys(De).includes(e)}function pe(e,t,r){if(t=Array.from(t),e._x_virtualDirectives){let o=Object.entries(e._x_virtualDirectives).map(([a,c])=>({name:a,value:c})),s=Et(o);o=o.map(a=>s.find(c=>c.name===a.name)?{name:`x-bind:${a.name}`,value:`"${a.value}"`}:a),t=t.concat(o)}let n={};return t.map(dr((o,s)=>n[o]=s)).filter(mr).map(zn(n,r)).sort(Kn).map(o=>Bn(e,o))}function Et(e){return Array.from(e).map(dr()).filter(t=>!mr(t))}var yt=!1,de=new Map,ur=Symbol();function fr(e){yt=!0;let t=Symbol();ur=t,de.set(t,[]);let r=()=>{for(;de.get(t).length;)de.get(t).shift()();de.delete(t)},n=()=>{yt=!1,r()};e(r),n()}function _t(e){let t=[],r=a=>t.push(a),[n,i]=Yt(e);return t.push(i),[{Alpine:K,effect:n,cleanup:r,evaluateLater:x.bind(x,e),evaluate:R.bind(R,e)},()=>t.forEach(a=>a())]}function Bn(e,t){let r=()=>{},n=De[t.type]||r,[i,o]=_t(e);Oe(e,t.original,o);let s=()=>{e._x_ignore||e._x_ignoreSelf||(n.inline&&n.inline(e,t,i),n=n.bind(n,e,t,i),yt?de.get(ur).push(n):n())};return s.runCleanups=o,s}var Pe=(e,t)=>({name:r,value:n})=>(r.startsWith(e)&&(r=r.replace(e,t)),{name:r,value:n}),Ie=e=>e;function dr(e=()=>{}){return({name:t,value:r})=>{let{name:n,value:i}=pr.reduce((o,s)=>s(o),{name:t,value:r});return n!==t&&e(n,t),{name:n,value:i}}}var pr=[];function ne(e){pr.push(e)}function mr({name:e}){return hr().test(e)}var hr=()=>new RegExp(`^${wt}([^:^.]+)\\b`);function zn(e,t){return({name:r,value:n})=>{let i=r.match(hr()),o=r.match(/:([a-zA-Z0-9\-_:]+)/),s=r.match(/\.[^.\]]+(?=[^\]]*$)/g)||[],a=t||e[r]||r;return{type:i?i[1]:null,value:o?o[1]:null,modifiers:s.map(c=>c.replace(".","")),expression:n,original:a}}}var bt="DEFAULT",G=["ignore","ref","data","id","anchor","bind","init","for","model","modelable","transition","show","if",bt,"teleport"];function Kn(e,t){let r=G.indexOf(e.type)===-1?bt:e.type,n=G.indexOf(t.type)===-1?bt:t.type;return G.indexOf(r)-G.indexOf(n)}function J(e,t,r={}){e.dispatchEvent(new CustomEvent(t,{detail:r,bubbles:!0,composed:!0,cancelable:!0}))}function D(e,t){if(typeof ShadowRoot=="function"&&e instanceof ShadowRoot){Array.from(e.children).forEach(i=>D(i,t));return}let r=!1;if(t(e,()=>r=!0),r)return;let n=e.firstElementChild;for(;n;)D(n,t,!1),n=n.nextElementSibling}function E(e,...t){console.warn(`Alpine Warning: ${e}`,...t)}var _r=!1;function gr(){_r&&E("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),_r=!0,document.body||E("Unable to initialize. Trying to load Alpine before `` is available. Did you forget to add `defer` in Alpine's `