commit d718c5a372e96348dae7495fbda1716cd0e94b06 Author: wehub-resource-sync Date: Mon Jul 13 12:31:57 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..9247b71 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,10 @@ +[env] +# To use built-in math functions, this compile time flag must be set +# See https://www.sqlite.org/draft/lang_mathfunc.html as a reference +# According to Cargo docs this will not overwrite any env var that was already +# set by the user, and this is a good thing. If the user already set some +# LIBSQLITE3_FLAGS, he probably knows what he is doing. +LIBSQLITE3_FLAGS = "-DSQLITE_ENABLE_MATH_FUNCTIONS" + +[build] +rustflags = [] diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5d17eab --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +/target +.git/ +sqlpage.db +docs/ +README.md +CHANGELOG.md +LICENSE.txt +configuration.md +*Dockerfile +.dockerignore +.github/ +examples/ +mssql/ +tests/ +.idea/ diff --git a/.env b/.env new file mode 100644 index 0000000..13d90c5 --- /dev/null +++ b/.env @@ -0,0 +1,2 @@ +# Set COMPOSE_PROFILES to one of the following: postgres, mysql, mssql. If set to mssql, you will need to change the depends_on propery in docker-compose.yml +COMPOSE_PROFILES=mariadb \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..37dba0e --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sql linguist-detectable=true \ No newline at end of file diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..f9dd7f2 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,15 @@ +# These are supported funding model platforms + +github: [lovasoa] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry +polar: # Replace with a single Polar username +buy_me_a_coffee: # Replace with a single Buy Me a Coffee username +thanks_dev: # Replace with a single thanks.dev username +custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..4667f24 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,44 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: bug +assignees: '' + +--- + +### Introduction + +A clear and concise description of what the bug is. + +### To Reproduce + +List of steps to reproduce the behavior. Include the sql file you are using and the eventual relevant parts of your database schema + +```sql +-- your sql here +``` + +### Actual behavior + +After following these steps, what happened ? +If you saw an error on the command line or inside your page, then paste it here + +``` +your error message here +``` + +### Screenshots +If applicable, add screenshots to help explain your problem. + +### Expected behavior +A clear and concise description of what you expected to happen. + +### Version information + + - OS: + - Database [e.g. SQLite, Postgres] + - SQLPage Version [found when hovering the default footer of pages]: + +### Additional context +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..875b81a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,27 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: enhancement +assignees: '' + +--- + +What are you building with SQLPage ? + +(If you are not an actual user of SQLPage, then you can open a discussion thread in the discussion section instead of an issue here.) + +What is your problem ? A description of the problem, not the solution you are proposing. + +What are you currently doing ? Since your solution is not implemented in SQLPage currently, what are you doing instead ? + +**Describe the solution you'd like** + +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** + +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..50909f6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,539 @@ +name: CI + +on: + push: + tags: + - "v*" # Push events to matching v*, i.e. v1.0, v20.15.10 + branches: + - "main" + paths-ignore: + - "docs/**" + - "README.md" + - ".github/workflows/release.yml" + - ".github/workflows/official-site.yml" + pull_request: + branches: + - "main" + paths-ignore: + - "docs/**" + - "README.md" + - ".github/workflows/release.yml" + - ".github/workflows/official-site.yml" + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + CARGO_TERM_COLOR: always + HURL_VERSION: 8.0.0 + REGISTRY_USERNAME: lovasoa + REGISTRY_IMAGE: lovasoa/sqlpage + +jobs: + compile_and_lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - run: npm ci + - run: npm test + - name: Set up cargo cache + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 + env: + NODE_OPTIONS: --no-deprecation + with: + shared-key: rust-sqlpage-proj-test + save-if: ${{ github.ref == 'refs/heads/main' }} + - run: cargo fmt --all -- --check + - run: cargo clippy --all-targets --all-features -- -D warnings + # The database matrix below runs the same Rust test harnesses against + # different DATABASE_URL values. Package the Linux test executables once + # here so those jobs do not recompile SQLPage or its dependencies. + - name: Package Linux Rust test binaries + run: | + set -euo pipefail + rm -rf target/sqlpage-test-binaries + mkdir -p target/sqlpage-test-binaries + cargo test --features odbc-static --no-run --message-format=json \ + | jq -r 'select(.profile.test == true and .executable != null) | .executable' \ + | while IFS= read -r test_binary; do + cp -- "$test_binary" target/sqlpage-test-binaries/ + done + test -n "$(find target/sqlpage-test-binaries -maxdepth 1 -type f -print -quit)" + tar -C target/sqlpage-test-binaries -czf target/sqlpage-linux-test-binaries.tar.gz . + - name: Build Linux binary + run: cargo build --features odbc-static + - name: Upload Linux Rust test binaries + uses: actions/upload-artifact@v7 + with: + name: sqlpage-linux-test-binaries + path: "target/sqlpage-linux-test-binaries.tar.gz" + - name: Upload Linux binary + uses: actions/upload-artifact@v7 + with: + name: sqlpage-linux-debug + path: "target/debug/sqlpage" + + test: + runs-on: ubuntu-latest + needs: compile_and_lint + strategy: + matrix: + include: + - database: sqlite + container: "" + db_url: "sqlite::memory:" + - database: postgres + container: postgres + db_url: "postgres://root:Password123!@127.0.0.1/sqlpage" + - database: mysql + container: mysql + db_url: "mysql://root:Password123!@127.0.0.1/sqlpage" + - database: mssql + container: mssql + db_url: "mssql://root:Password123!@127.0.0.1/sqlpage" + - database: odbc + container: postgres + db_url: "Driver=PostgreSQL Unicode;Server=127.0.0.1;Port=5432;Database=sqlpage;UID=root;PWD=Password123!" + setup_odbc: true + - database: oracle + container: oracle + db_url: "Driver=Oracle 21 ODBC driver;Dbq=//127.0.0.1:1521/FREEPDB1;Uid=root;Pwd=Password123!" + steps: + - uses: actions/checkout@v6 + # Reuse the exact Linux test harnesses produced by compile_and_lint. + # This keeps the DB matrix focused on database behavior instead of + # compiling the same Rust crate five more times. + - name: Download Linux Rust test binaries + uses: actions/download-artifact@v8 + with: + name: sqlpage-linux-test-binaries + path: target + - name: Extract Linux Rust test binaries + run: | + mkdir -p target/sqlpage-test-binaries + tar -xzf target/sqlpage-linux-test-binaries.tar.gz -C target/sqlpage-test-binaries + - name: Install PostgreSQL ODBC driver + if: matrix.setup_odbc + run: sudo apt-get install -y odbc-postgresql + - name: Install Oracle ODBC driver + if: matrix.database == 'oracle' + run: | + sudo apt-get install -y alien libaio1t64 libodbcinst2 unixodbc + sudo rpm --import https://yum.oracle.com/RPM-GPG-KEY-oracle-ol8 + wget https://yum.oracle.com/repo/OracleLinux/OL8/oracle/instantclient21/x86_64/getPackage/oracle-instantclient-{basic,odbc}-21.21.0.0.0-1.el8.x86_64.rpm + sudo alien -i oracle-instantclient-basic-21.21.0.0.0-1.el8.x86_64.rpm + sudo alien -i oracle-instantclient-odbc-21.21.0.0.0-1.el8.x86_64.rpm + sudo ln -s /usr/lib/x86_64-linux-gnu/libaio.so.1t64 /usr/lib/libaio.so.1 + sudo /usr/lib/oracle/21/client64/bin/odbc_update_ini.sh / /usr/lib/oracle/21/client64/lib + echo "LD_LIBRARY_PATH=/usr/lib/oracle/21/client64/lib:$LD_LIBRARY_PATH" >> "$GITHUB_ENV" + - name: Start database container + if: matrix.container != '' + run: docker compose up --wait ${{ matrix.container }} + - name: Show container logs + if: failure() && matrix.container != '' + run: docker compose logs ${{ matrix.container }} + - name: Run tests against ${{ matrix.database }} + timeout-minutes: 5 + run: | + set -euo pipefail + shopt -s nullglob + test_binaries=(target/sqlpage-test-binaries/*) + if ((${#test_binaries[@]} == 0)); then + echo "No test binaries were found in target/sqlpage-test-binaries" >&2 + exit 1 + fi + for test_binary in "${test_binaries[@]}"; do + echo "::group::$(basename "$test_binary")" + "$test_binary" + echo "::endgroup::" + done + env: + DATABASE_URL: ${{ matrix.db_url }} + MALLOC_CHECK_: 3 + MALLOC_PERTURB_: 10 + + windows_test: + runs-on: windows-latest + steps: + - uses: actions/checkout@v6 + - name: Set up cargo cache + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 + env: + NODE_OPTIONS: --no-deprecation + - name: Set up Windows incremental cache + uses: actions/cache@v5 + with: + path: | + target/debug/.fingerprint/sqlpage-* + target/debug/build/sqlpage-* + target/debug/deps/libsqlpage-*.rlib + target/debug/deps/libsqlpage-*.rmeta + target/debug/deps/sqlpage-*.d + target/debug/incremental/sqlpage-* + key: windows-incremental-${{ github.event.pull_request.number || github.ref_name }}-${{ github.sha }} + restore-keys: | + windows-incremental-${{ github.event.pull_request.number || github.ref_name }}- + - run: cargo test + env: + CARGO_INCREMENTAL: 1 + RUST_BACKTRACE: 1 + - name: Upload Windows binary + uses: actions/upload-artifact@v7 + with: + name: sqlpage-windows-debug + path: "target/debug/sqlpage.exe" + + playwright: + runs-on: ubuntu-latest + timeout-minutes: 10 + needs: compile_and_lint + defaults: + run: + working-directory: ./tests/end-to-end + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: lts/* + cache: 'npm' + cache-dependency-path: ./tests/end-to-end/package-lock.json + - run: sudo apt-get update && sudo apt-get install -y unixodbc-dev + - run: npm ci && npx playwright install --with-deps chromium + # The browser tests exercise the official site, but they do not need a + # separate Rust build. Reuse the binary compiled and tested above. + - name: Download Linux binary + uses: actions/download-artifact@v8 + with: + name: sqlpage-linux-debug + path: ${{ runner.temp }}/sqlpage-bin + - name: Start official site and wait for it to be ready + timeout-minutes: 1 + run: | + chmod +x "${{ runner.temp }}/sqlpage-bin/sqlpage" + "${{ runner.temp }}/sqlpage-bin/sqlpage" 2>/tmp/stderrlog & + tail -f /tmp/stderrlog | grep -q "started successfully" + working-directory: ./examples/official-site + - name: Run Playwright tests + run: npx playwright test + - name: show server logs + if: failure() + run: cat /tmp/stderrlog + - uses: actions/upload-artifact@v7 + if: always() + with: + name: playwright-report + path: ./tests/end-to-end/playwright-report/ + retention-days: 30 + + docker_build: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - platform: linux/arm/v7 + variant: minimal + tag_suffix: -linux-arm-v7 + - platform: linux/arm64 + variant: minimal + tag_suffix: -linux-arm64 + - platform: linux/amd64 + variant: duckdb + tag_suffix: -linux-amd64-duckdb + - platform: linux/arm64 + variant: duckdb + tag_suffix: -linux-arm64-duckdb + steps: + - name: Checkout + uses: actions/checkout@v6 + - id: cache-scope + name: Docker cache scope + run: | + ref_scope="main" + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + ref_scope="pr-${{ github.event.pull_request.number }}" + fi + + recipe_hash="${{ hashFiles('Dockerfile', '.cargo/**', 'Cargo.toml', 'Cargo.lock', 'build.rs', 'scripts/**', 'sqlpage/**') }}" + { + echo "current=sqlpage-${ref_scope}${{ matrix.tag_suffix }}-${recipe_hash}" + echo "main=sqlpage-main${{ matrix.tag_suffix }}-${recipe_hash}" + } >> "$GITHUB_OUTPUT" + - name: Docker meta + id: meta + uses: docker/metadata-action@v6 + with: + images: ${{ env.REGISTRY_IMAGE }} + flavor: suffix=${{ matrix.tag_suffix }} + labels: | + org.opencontainers.image.created=1970-01-01T00:00:00Z + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + - name: Login to Docker Hub + if: github.event_name != 'pull_request' + uses: docker/login-action@v4 + with: + username: ${{ env.REGISTRY_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Build and push by digest + id: build + uses: docker/build-push-action@v7 + with: + # Use BuildKit's Git context instead of the mutable runner workspace. + # The dependency cache should be keyed by committed source, not by a + # per-job local context stream. + context: "{{defaultContext}}" + platforms: ${{ matrix.platform }} + target: ${{ matrix.variant }} + labels: ${{ steps.meta.outputs.labels }} + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + cache-from: | + type=gha,scope=${{ steps.cache-scope.outputs.current }} + type=gha,scope=${{ steps.cache-scope.outputs.main }} + type=registry,ref=${{ env.REGISTRY_IMAGE }}:main${{ matrix.tag_suffix }} + cache-to: type=gha,scope=${{ steps.cache-scope.outputs.current }},mode=max + - name: Export digest + if: github.event_name != 'pull_request' + run: | + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + - name: Upload digest + uses: actions/upload-artifact@v7 + if: github.event_name != 'pull_request' + with: + name: digests-${{ matrix.variant }}${{ matrix.tag_suffix }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + docker_build_amd64_minimal: + name: docker_build (linux/amd64, minimal) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Docker meta + id: meta + uses: docker/metadata-action@v6 + with: + images: ${{ env.REGISTRY_IMAGE }} + flavor: suffix=-linux-amd64 + labels: | + org.opencontainers.image.created=1970-01-01T00:00:00Z + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + - id: cache-scope + name: Docker cache scope + run: | + ref_scope="main" + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + ref_scope="pr-${{ github.event.pull_request.number }}" + fi + + recipe_hash="${{ hashFiles('Dockerfile', '.cargo/**', 'Cargo.toml', 'Cargo.lock', 'build.rs', 'scripts/**', 'sqlpage/**') }}" + { + echo "artifact=sqlpage-${ref_scope}-linux-amd64-hurl-${recipe_hash}" + echo "current=sqlpage-${ref_scope}-linux-amd64-${recipe_hash}" + echo "main=sqlpage-main-linux-amd64-${recipe_hash}" + } >> "$GITHUB_OUTPUT" + - name: Login to Docker Hub + if: github.event_name != 'pull_request' + uses: docker/login-action@v4 + with: + username: ${{ env.REGISTRY_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Build image for Hurl examples + uses: docker/build-push-action@v7 + with: + # Use BuildKit's Git context instead of the mutable runner workspace. + # The dependency cache should be keyed by committed source, not by a + # per-job local context stream. + context: "{{defaultContext}}" + platforms: linux/amd64 + target: minimal + labels: ${{ steps.meta.outputs.labels }} + tags: | + ${{ steps.meta.outputs.tags }} + ${{ env.REGISTRY_IMAGE }}:main + outputs: type=docker,dest=${{ runner.temp }}/sqlpage.tar + cache-from: | + type=gha,scope=${{ steps.cache-scope.outputs.artifact }} + type=gha,scope=${{ steps.cache-scope.outputs.current }} + type=gha,scope=${{ steps.cache-scope.outputs.main }} + type=registry,ref=${{ env.REGISTRY_IMAGE }}:main-linux-amd64 + cache-to: type=gha,scope=${{ steps.cache-scope.outputs.artifact }},mode=max + - name: Upload SQLPage image + uses: actions/upload-artifact@v7 + with: + name: sqlpage-linux-amd64-minimal-image + path: ${{ runner.temp }}/sqlpage.tar + if-no-files-found: error + retention-days: 1 + - name: Build and push by digest + id: build + if: github.event_name != 'pull_request' + uses: docker/build-push-action@v7 + with: + # Use BuildKit's Git context instead of the mutable runner workspace. + # The dependency cache should be keyed by committed source, not by a + # per-job local context stream. + context: "{{defaultContext}}" + platforms: linux/amd64 + target: minimal + labels: ${{ steps.meta.outputs.labels }} + push: true + tags: ${{ steps.meta.outputs.tags }} + cache-from: | + type=gha,scope=${{ steps.cache-scope.outputs.current }} + type=gha,scope=${{ steps.cache-scope.outputs.main }} + type=registry,ref=${{ env.REGISTRY_IMAGE }}:main-linux-amd64 + cache-to: type=gha,scope=${{ steps.cache-scope.outputs.current }},mode=max + - name: Export digest + if: github.event_name != 'pull_request' + run: | + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + - name: Upload digest + uses: actions/upload-artifact@v7 + if: github.event_name != 'pull_request' + with: + name: digests-minimal-linux-amd64 + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + hurl_examples: + runs-on: ubuntu-latest + outputs: + examples: ${{ steps.examples.outputs.examples }} + steps: + - uses: actions/checkout@v6 + - id: examples + run: | + examples="$(find examples -mindepth 2 -maxdepth 2 -name test.hurl -print | sed 's#/test.hurl$##' | sort | jq -R -s -c 'split("\n")[:-1]')" + echo "examples=$examples" >> "$GITHUB_OUTPUT" + - name: Restore Hurl archive + id: hurl-cache + uses: actions/cache@v5 + with: + path: .cache/hurl + key: hurl-${{ runner.os }}-x86_64-unknown-linux-gnu-${{ env.HURL_VERSION }} + - name: Download Hurl archive + if: steps.hurl-cache.outputs.cache-hit != 'true' + run: | + set -euo pipefail + archive="hurl-${HURL_VERSION}-x86_64-unknown-linux-gnu.tar.gz" + base_url="https://github.com/Orange-OpenSource/hurl/releases/download/${HURL_VERSION}" + mkdir -p .cache/hurl + curl --fail --location --retry 5 --retry-delay 2 --output ".cache/hurl/${archive}" "${base_url}/${archive}" + curl --fail --location --retry 5 --retry-delay 2 --output ".cache/hurl/${archive}.sha256" "${base_url}/${archive}.sha256" + ( + cd .cache/hurl + expected_sha="$(tr -d '[:space:]' < "${archive}.sha256")" + actual_sha="$(sha256sum "$archive" | cut -d ' ' -f 1)" + test "$expected_sha" = "$actual_sha" + ) + - name: Upload Hurl archive + uses: actions/upload-artifact@v7 + with: + name: hurl-${{ env.HURL_VERSION }}-x86_64-unknown-linux-gnu + path: .cache/hurl/* + if-no-files-found: error + retention-days: 1 + + hurl: + name: hurl (${{ matrix.example }}) + runs-on: ubuntu-latest + timeout-minutes: 15 + needs: + - docker_build_amd64_minimal + - hurl_examples + strategy: + fail-fast: false + matrix: + example: ${{ fromJSON(needs.hurl_examples.outputs.examples) }} + steps: + - uses: actions/checkout@v6 + - name: Download Hurl archive + uses: actions/download-artifact@v8 + with: + name: hurl-${{ env.HURL_VERSION }}-x86_64-unknown-linux-gnu + path: ${{ runner.temp }}/hurl + - name: Install Hurl + run: | + set -euo pipefail + archive="hurl-${HURL_VERSION}-x86_64-unknown-linux-gnu.tar.gz" + ( + cd "$RUNNER_TEMP/hurl" + expected_sha="$(tr -d '[:space:]' < "${archive}.sha256")" + actual_sha="$(sha256sum "$archive" | cut -d ' ' -f 1)" + test "$expected_sha" = "$actual_sha" + tar xzf "$archive" -C "$RUNNER_TEMP/hurl" + ) + echo "$RUNNER_TEMP/hurl/hurl-${HURL_VERSION}-x86_64-unknown-linux-gnu/bin" >> "$GITHUB_PATH" + - name: Download SQLPage image + uses: actions/download-artifact@v8 + with: + name: sqlpage-linux-amd64-minimal-image + path: ${{ runner.temp }}/sqlpage-image + - name: Load SQLPage image + run: | + docker load --input "${{ runner.temp }}/sqlpage-image/sqlpage.tar" + docker tag "${{ env.REGISTRY_IMAGE }}:main" "${{ env.REGISTRY_IMAGE }}:latest" + - name: Run example Hurl test + run: scripts/test-examples-hurl.sh "${{ matrix.example }}" + + docker_push: + runs-on: ubuntu-latest + if: github.event_name != 'pull_request' + needs: + - docker_build_amd64_minimal + - docker_build + strategy: + matrix: + variant: + - minimal + - duckdb + steps: + - name: Download digests + uses: actions/download-artifact@v8 + env: + NODE_OPTIONS: --no-deprecation + with: + pattern: digests-${{ matrix.variant }}* + merge-multiple: true + path: /tmp/digests + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + - name: Login to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ env.REGISTRY_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Create manifest list and push + working-directory: /tmp/digests + env: + TAG_SUFFIX: ${{ matrix.variant != 'minimal' && format('-{0}', matrix.variant) || '' }} + run: | + tags=(-t "${REGISTRY_IMAGE}:${GITHUB_REF_NAME}${TAG_SUFFIX}") + if [[ "$GITHUB_REF_TYPE" == "tag" ]]; then + tags+=(-t "${REGISTRY_IMAGE}:latest${TAG_SUFFIX}") + fi + shopt -s nullglob + sources=() + for digest in *; do + sources+=("${REGISTRY_IMAGE}@sha256:${digest}") + done + ((${#sources[@]} > 0)) + docker buildx imagetools create "${tags[@]}" "${sources[@]}" + - name: Inspect image + env: + TAG_SUFFIX: ${{ matrix.variant != 'minimal' && format('-{0}', matrix.variant) || '' }} + run: | + docker buildx imagetools inspect "${REGISTRY_IMAGE}:${GITHUB_REF_NAME}${TAG_SUFFIX}" diff --git a/.github/workflows/official-site.yml b/.github/workflows/official-site.yml new file mode 100644 index 0000000..529b44a --- /dev/null +++ b/.github/workflows/official-site.yml @@ -0,0 +1,39 @@ +name: "deploy website" + +on: + push: + branches: + - main + +concurrency: site-deploy + +jobs: + deploy_official_site: + runs-on: ubuntu-latest + steps: + - name: Cloning repo + uses: actions/checkout@v6 + with: + fetch-depth: 0 + - id: get_gitsha + name: Make a commit for the official site + run: | + cp -r examples/official-site /tmp/ + rm -rf * .[!.]* + cp -r /tmp/official-site/* . + git init . + git config user.name "GitHub Actions Bot" + git config user.email "<>" + git add . + git commit -am "site update $(date)" + echo "gitsha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + - name: Push to dokku + uses: dokku/github-action@master + with: + git_remote_url: "dokku@${{ secrets.DEPLOY_IP }}:sqlpage" + ssh_private_key: ${{ secrets.SSH_PRIVATE_KEY }} + ssh_host_key: ${{ secrets.SSH_HOST_KEY }} + git_push_flags: "--force" + branch: "main" + ci_commit: ${{ steps.get_gitsha.outputs.gitsha }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..979dffb --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,174 @@ +on: + workflow_dispatch: {} + push: + # Sequence of patterns matched against refs/tags + tags: + - "v*" # Push events to matching v*, i.e. v1.0, v20.15.10 + branches: + - "release-test" + +name: Create Release + +permissions: + contents: write + actions: read + +jobs: + build-macos-windows: + name: Build sqlpage binaries (macOS & Windows) + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [macos-latest, windows-latest] + include: + - os: windows-latest + binary_extension: .exe + target: x86_64-pc-windows-msvc + features: "" + - os: macos-latest + target: x86_64-apple-darwin + features: "odbc-static" + steps: + - uses: actions/checkout@v6 + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + - name: Set up cargo cache + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 + env: + NODE_OPTIONS: --no-deprecation + - name: Build + run: cargo build --profile superoptimized --locked --target ${{ matrix.target }} --features "${{ matrix.features }}" + - name: Upload unsigned Windows artifact + if: matrix.os == 'windows-latest' + id: upload_unsigned + uses: actions/upload-artifact@v7 + with: + name: unsigned-windows + path: target/${{ matrix.target }}/superoptimized/sqlpage.exe + if-no-files-found: error + + - name: Submit signing request to SignPath + if: matrix.os == 'windows-latest' + id: signpath + uses: signpath/github-action-submit-signing-request@v2.2 + with: + api-token: ${{ secrets.SIGNPATH_API_TOKEN }} + organization-id: '45fd8443-c7ca-4d29-a68b-608948185335' + project-slug: 'sqlpage' + signing-policy-slug: 'release-signing' + github-artifact-id: ${{ steps.upload_unsigned.outputs.artifact-id }} + wait-for-completion: true + output-artifact-directory: './signed-windows' + wait-for-completion-timeout-in-seconds: 7200 + service-unavailable-timeout-in-seconds: 1800 + download-signed-artifact-timeout-in-seconds: 1800 + + - name: Upload signed Windows artifact + if: matrix.os == 'windows-latest' + uses: actions/upload-artifact@v7 + with: + name: sqlpage windows-latest + path: signed-windows/sqlpage.exe + if-no-files-found: error + + - name: Upload artifact (non-Windows) + if: matrix.os != 'windows-latest' + uses: actions/upload-artifact@v7 + with: + name: sqlpage ${{ matrix.os }} + path: target/${{ matrix.target }}/superoptimized/sqlpage${{ matrix.binary_extension }} + if-no-files-found: error + + build-linux: + name: Build sqlpage binaries (Linux) + runs-on: ubuntu-latest + container: quay.io/pypa/manylinux_2_28_x86_64 + steps: + - uses: actions/checkout@v6 + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-unknown-linux-gnu + - name: Set up cargo cache + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 + env: + NODE_OPTIONS: --no-deprecation + - name: Build + run: cargo build --profile superoptimized --locked --target x86_64-unknown-linux-gnu --features "odbc-static" + - uses: actions/upload-artifact@v7 + with: + name: sqlpage ubuntu-latest + path: target/x86_64-unknown-linux-gnu/superoptimized/sqlpage + if-no-files-found: error + + build-aws: + name: Build AWS Lambda Serverless zip image + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - run: docker build -t sqlpage-lambda-builder . -f lambda.Dockerfile --target builder + - run: docker run sqlpage-lambda-builder cat deploy.zip > sqlpage-aws-lambda.zip + - uses: actions/upload-artifact@v7 + with: + name: sqlpage aws lambda serverless image + path: sqlpage-aws-lambda.zip + + create_release: + name: Create Github Release + needs: [build-macos-windows, build-linux, build-aws] + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') + steps: + - uses: actions/checkout@v6 + - uses: actions/download-artifact@v8 + env: + NODE_OPTIONS: --no-deprecation + - run: | + rm -rf sqlpage/templates/*.handlebars; + chmod +x sqlpage*/sqlpage; + mv 'sqlpage macos-latest/sqlpage' sqlpage.bin; + tar --create --file sqlpage-macos.tgz --gzip sqlpage.bin sqlpage/sqlpage.json sqlpage/migrations sqlpage/templates sqlpage/sqlpage.json; + mv 'sqlpage ubuntu-latest/sqlpage' sqlpage.bin; + tar --create --file sqlpage-linux.tgz --gzip sqlpage.bin sqlpage/migrations sqlpage/templates sqlpage/sqlpage.json; + mv 'sqlpage windows-latest/sqlpage.exe' . + zip -r sqlpage-windows.zip sqlpage.exe sqlpage/migrations sqlpage/templates sqlpage/sqlpage.json; + - name: Create Release + id: create_release + uses: softprops/action-gh-release@v3.0.0 + with: + name: ${{ github.ref_name }} + tag_name: ${{ github.ref_name }} + draft: false + prerelease: ${{ contains(github.ref_name, 'beta') }} + files: | + sqlpage-windows.zip + sqlpage-linux.tgz + sqlpage-macos.tgz + sqlpage aws lambda serverless image/sqlpage-aws-lambda.zip + + cargo_publish: + name: Publish to crates.io + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') + steps: + - uses: actions/checkout@v6 + - name: Set up cargo cache + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 + env: + NODE_OPTIONS: --no-deprecation + - run: sudo apt-get update && sudo apt-get install -y unixodbc-dev + - name: Publish to crates.io + run: | + set +e + cargo publish --token ${{ secrets.CRATES_IO_TOKEN }} 2>&1 | tee cargo-publish.log + status=${PIPESTATUS[0]} + set -e + if [ "$status" -ne 0 ]; then + if grep -Eiq 'already (uploaded|exists)|version .* is already' cargo-publish.log; then + echo "sqlpage ${GITHUB_REF_NAME#v} is already published on crates.io" + else + exit "$status" + fi + fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0902ee9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +.DS_STORE +/target +sqlpage.db +.idea/ +*.mm_profdata +docs/presentation-pgconf.html +examples/inrap_badass/ +sqlpage/https/* +x.sql +xbed.sql +**/sqlpage.bin +node_modules/ +sqlpage/sqlpage.db +tests_uploads/ diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..74ef38d --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,64 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "lldb", + "request": "launch", + "name": "Debug unit tests in library 'sqlpage'", + "cargo": { + "args": ["test", "--no-run", "--lib", "--package=sqlpage"], + "filter": { + "name": "sqlpage", + "kind": "lib" + } + }, + "args": [], + "cwd": "${workspaceFolder}" + }, + { + "type": "lldb", + "request": "launch", + "name": "Debug executable 'sqlpage'", + "cargo": { + "args": ["build", "--bin=sqlpage", "--package=sqlpage"], + "filter": { + "name": "sqlpage", + "kind": "bin" + } + }, + "args": [], + "cwd": "${workspaceFolder}" + }, + { + "type": "lldb", + "request": "launch", + "name": "Debug unit tests in executable 'sqlpage'", + "cargo": { + "args": ["test", "--no-run", "--bin=sqlpage", "--package=sqlpage"], + "filter": { + "name": "sqlpage", + "kind": "bin" + } + }, + "args": [], + "cwd": "${workspaceFolder}" + }, + { + "type": "lldb", + "request": "launch", + "name": "Debug integration test 'index'", + "cargo": { + "args": ["test", "--no-run", "--test=index", "--package=sqlpage"], + "filter": { + "name": "index", + "kind": "test" + } + }, + "args": [], + "cwd": "${workspaceFolder}" + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..0ee0666 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "rust-analyzer.linkedProjects": ["./Cargo.toml"] +} diff --git a/.zed/debug.json b/.zed/debug.json new file mode 100644 index 0000000..d0cc6db --- /dev/null +++ b/.zed/debug.json @@ -0,0 +1,21 @@ +// Project-local debug tasks +// +// For more documentation on how to configure debug tasks, +// see: https://zed.dev/docs/debugger +[ + { + "label": "Debug with DuckDB", + "build": { + "command": "cargo", + "args": ["build"], + }, + "program": "$ZED_WORKTREE_ROOT/target/debug/sqlpage", + "env": { + "DATABASE_URL": "DSN=DuckDB", + }, + // sourceLanguages is required for CodeLLDB (not GDB) when using Rust + "sourceLanguages": ["rust"], + "request": "launch", + "adapter": "CodeLLDB", + }, +] diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..00b1779 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,137 @@ +# SQLPage architecture + +SQLPage is an SQL-only web application builder and web server. An application is primarily a set of `.sql` +files: SQLPage routes an HTTP request to a file, executes its statements against a database, interprets rows +whose `component` column names a UI component, and streams the resulting HTML (or another response) to the +client. It is intended for fast, data-centric applications while still allowing custom HTML, CSS, and +JavaScript where needed. + +## Features and repository layout + +- **Application entry point and configuration** (`src/main.rs`, `src/lib.rs`, `src/app_config.rs`, `src/cli/`). + The executable starts the server; application state, configuration, environment variables, and command-line + handling are defined here. `configuration.md` documents the user-facing settings. +- **SQL semantics and execution** (`src/webserver/database/`). SQLPage uses the database's SQL for selects, joins, aggregation, inserts, updates, + deletes, transactions, JSON processing, and database-specific features. It parses SQL, recognizes SQLPage + extensions, binds request values safely, and sends ordinary SQL to the selected database. SQL files contain + sequential statements; result sets become component invocations in response order. `SET` assigns a value + to a mutable SQLPage variable and is useful for reusing query results or controlling later statements. +- **Request variables** (`src/webserver/request_variables.rs`, `src/webserver/http_request_info.rs`, + `src/webserver/database/syntax_tree.rs`). `?name` refers to a URL/GET parameter, `:name` explicitly refers to a form/POST + value, and `$name` is the compatibility shorthand that uses a POST value when present and otherwise a GET + value (a SET variable takes precedence where applicable). Values are passed as parameters, not interpolated + into SQL. GET and POST variables are request inputs; SET variables are mutable during request execution. + `sqlpage.variables()` exposes them as JSON, with SET > POST > GET precedence. +- **SQLPage functions** (`src/webserver/database/sqlpage_functions/`). Calls such as `sqlpage.fetch`, `sqlpage.run_sql`, `sqlpage.set_variable`, file + readers, hashing/HMAC helpers, request metadata, uploads, headers/cookies, URL helpers, OIDC user info, + and HTTP fetch are registered in `src/webserver/database/sqlpage_functions/functions.rs`. Functions can + return values, alter response/request state, include another SQL file, or raise an error. `sqlpage.exec` + is deliberately disabled by default because it runs server processes. +- **Database support and pooling** (`src/webserver/database/connect.rs`, `execute_queries.rs`, `migrations.rs`). + Native drivers support SQLite, PostgreSQL, MySQL, and Microsoft SQL + Server; the ODBC driver provides access to other ODBC-compatible databases. SQLPage uses `sqlx` and a + reusable connection pool, with configurable maximum connections, idle/lifetime timeouts, acquire timeout, + retries, and optional `on_connect.sql`/`on_reset.sql` hooks. Database-specific SQL should be isolated or + covered by the relevant database tests. +- **Rendering and components** (`src/render.rs`, `src/templates.rs`, `src/dynamic_component.rs`, + `src/template_helpers.rs`, `sqlpage/templates/`, `sqlpage/sqlpage.css`, `sqlpage/sqlpage.js`). Built-in components live in `sqlpage/templates/*.handlebars` and cover shells, text, + tables, lists, cards, charts, forms, navigation, modals, downloads, maps, and more. Query columns map to + component properties; nested/dynamic components and `sqlpage.run_sql` support composition and lazy loading. + Custom Handlebars components can be placed in the configured `sqlpage/templates` directory. Raw HTML and + custom assets are possible through the HTML/shell components. Rendering is streamed so the response can + start while later query results are still being processed. +- **Control flow and errors** (`src/webserver/error.rs`, `error_with_status.rs`, `routing.rs`, + `src/default_404.sql`). SQL remains declarative: use predicates, `CASE`, `SET`, component rows, and + the `redirect` component to conditionally continue, redirect, or implement guards/error pages. There is no + general SQLPage `IF` statement. Parse, database, function, component, and response errors are converted to + contextual HTTP errors; `default_404.sql` handles missing routes. Do not hide errors by changing unrelated + error handling or tests. +- **HTTP server and client** (`src/webserver/http.rs`, `http_client.rs`, `response_writer.rs`, `static_content.rs`, + `https.rs`, `content_security_policy.rs`, `server_timing.rs`). The server is built on Actix Web, supports normal HTTP request/response + handling, streaming, uploads, static assets, HTTP/2, HTTPS, and optional Unix sockets/serverless adapters. + The shared outbound client is used by HTTP-fetch and OIDC integrations and honors configured/native TLS + certificates and timeouts. Content-security-policy and response/header helpers are part of the request + pipeline. +- **OIDC** (`src/webserver/oidc.rs`, `src/webserver/database/sqlpage_functions/functions/user_info.rs`). + Optional OpenID Connect middleware protects configured path prefixes, performs provider discovery, + login/callback/logout, validates tokens, maintains an authenticated cookie, and exposes identity claims to + SQLPage functions. Configuration is in `AppConfig`/`configuration.md`; public paths can be excluded. +- **Caching and files** (`src/filesystem.rs`, `src/file_cache.rs`, `src/telemetry*.rs`). Parsed SQL files are cached. Files may come from the web root/filesystem or from the + database-backed `sqlpage_files` store, and templates/migrations are loaded from the configuration directory. + Telemetry, request timing, and debug logging help diagnose query, pool, and rendering performance. + +- **Examples, tests, and project operations** (`examples/`, `tests/`, `configuration.md`, `CONTRIBUTING.md`, + `README.md`, `.github/workflows/ci.yml`). Examples include the official documentation site and its migrations; + tests cover SQL fixtures, database variants, uploads, OIDC, and server timing. The contribution guide and CI + workflow define the development and validation conventions. +- **Deployment and local infrastructure** (`Dockerfile`, `lambda.Dockerfile`, `docker-compose.yml`, + `sqlpage.service`). These provide container, serverless, local database-testing, and service deployment support. + +## Documentation and release notes + +The official documentation site is itself an SQLPage application in `examples/official-site/`. Its database +schema and documentation content are created by the SQL migrations in +`examples/official-site/sqlpage/migrations/`; the site is recreated from scratch during deployment. Existing +official-site migrations are editable source files: update the migration that already documents a component, +function, configuration option, or feature in place. Do not create a new migration merely to update existing +documentation. Add a new migration only for genuinely new documentation content when that is the established +pattern for the relevant area. + +- Add or update a component's row, parameters, and examples in the component documentation migrations when + changing `sqlpage/templates/` or component behavior. +- Add or update a function's description, parameters, examples, and caveats in the function documentation + migrations when changing `sqlpage_functions` or any `sqlpage.*` function behavior. +- Update the relevant configuration documentation when adding or changing `AppConfig` settings, environment + variables, defaults, authentication behavior, HTTP/TLS behavior, database settings, or custom components. +- Document OIDC changes in the authentication/OIDC migrations, including configuration requirements, exposed + claims/functions, login/logout behavior, and security implications. +- Document other user-visible behavior—SQL syntax extensions, variables, control flow, errors, uploads, + rendering, HTTP endpoints, performance, or deployment—in the corresponding official-site SQL page or + migration. Follow nearby migrations and keep examples executable and database-portable where possible. +- Update `CHANGELOG.md` for user-visible changes, bug fixes, breaking changes, deprecations, and noteworthy + internal changes. Keep the entry concise and use the existing version/section conventions. + +## Validation + +### When working on rust code +Mandatory formatting (rust): `cargo fmt --all` +Mandatory linting: `cargo clippy --all-targets --all-features -- -D warnings` + +### When working on css or js +Frontend formatting: `npm run format` + +More about testing: see [github actions](./.github/workflows/ci.yml). +Project structure: see [contribution guide](./CONTRIBUTING.md) + +NEVER reformat/lint/touch files unrelated to your task. Always run tests/lints/format before stopping when you changed code. + +### Testing + +``` +cargo test # tests with inmemory sqlite by default +``` + +For other databases, see [docker testing setup](./docker-compose.yml) + +``` +docker compose up -d mssql # or postgres or mysql +DATABASE_URL='mssql://root:Password123!@localhost/sqlpage' cargo test # all dbms use the same user:pass and db name +``` + +### Documentation + +Components and functions are documented in [official website](./examples/official-site/sqlpage/migrations/); one migration per component and per function. You CAN update existing migrations, the official site database is recreated from scratch on each deployment. + +official documentation website sql tables: + - `component(name,description,icon,introduced_in_version)` -- icon name from tabler icon + - `parameter(top_level BOOLEAN, name, component REFERENCES component(name), description, description_md, type, optional BOOLEAN)` parameter types: BOOLEAN, COLOR, HTML, ICON, INTEGER, JSON, REAL, TEXT, TIMESTAMP, URL + - `example(component REFERENCES component(name), description, properties JSON)` + +#### Project Conventions + +- Components: defined in `./sqlpage/templates/*.handlebars` +- Functions: `src/webserver/database/sqlpage_functions/functions.rs` registered with `make_function!`. +- [Configuration](./configuration.md): see [AppConfig](./src/app_config.rs) +- Routing: file-based in `src/webserver/routing.rs`; not found handled via `src/default_404.sql`. +- Follow patterns from similar modules before introducing new abstractions. +- frontend: see [css](./sqlpage/sqlpage.css) and [js](./sqlpage/sqlpage.js) diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d25915e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,1765 @@ +# CHANGELOG.md + +## unreleased + +- **Access logs now go to stdout.** SQLPage now writes the single per-request completion log line to stdout with the target `sqlpage::access`, matching common application-server and container logging conventions. Diagnostic logs, warnings, and internal errors still go to stderr. If your `LOG_LEVEL` or `RUST_LOG` filter is scoped to a specific old target such as `sqlpage::webserver::http=info`, add `sqlpage::access=info` so request-completion logs are still emitted. If your log pipeline only collects stderr, update it to collect stdout too. +- **OIDC redirects are no longer cacheable.** Authorization redirects contain one-time state and post-login redirects set session cookies. SQLPage now sends `Cache-Control: no-store` for these responses, preventing a browser or intermediary from replaying an expired authorization redirect. + +## v0.44.1 + +An AI-assisted security audit found three vulnerabilities: one authentication bypass that is high severity for affected OIDC deployments, and two lower-severity issues. It also led to three hardening changes. Upgrade now if you use custom OIDC protected paths. + +Security fixes: + +- **High severity for affected OIDC deployments: protected path bypass.** Affected: sites using OIDC with custom `oidc_protected_paths`, such as `["/admin"]`, to protect only part of the site. Not affected: sites not using OIDC, or using the default `oidc_protected_paths = ["/"]` to protect the whole site. Impact: an unauthenticated attacker could use percent-encoded URLs to access pages that should require login. The fix checks decoded request paths against decoded `oidc_protected_paths` and `oidc_public_paths`. +- **Medium severity: private SQL files could be served after privileged `run_sql` includes.** Affected: apps that call `sqlpage.run_sql(...)` on private paths such as `sqlpage/`, dotfiles, absolute paths, or `../` paths. Impact: an attacker who knew the path could request the cached file directly and run it as a public page for a few milliseconds. +- **Low severity: debug error messages displayed in production** Affected: `environment = "production"` and pages that can error while serving JSON, NDJSON, SSE, or CSV contents. Impact: an attacker could gather private information about your database schema through error messages. + +Additional hardening: + +- Safely quote `csv` and `download` `filename` values in `Content-Disposition`, preventing download filename corruption. +- Reject unsafe OIDC redirect targets containing backslashes or control characters, affecting user-controlled login return targets and `sqlpage.oidc_logout_url`. +- Bind `sqlpage.oidc_logout_url` links to the current session, preventing forced logout of another browser. + +## v0.44.0 + +This release focuses on making production SQLPage apps easier to understand, debug, and operate. Most apps should keep working without SQL changes, but maintainers should review the notes about logging and uploaded-file permissions. + +- **Find out why a page is slow.** SQLPage can now produce a timeline for every request: when the request arrived, which `.sql` file ran, how long it waited for a database connection, which SQL queries ran, and how long calls to `sqlpage.fetch`, `sqlpage.run_sql`, or `sqlpage.exec` took. This kind of request timeline is called a trace. SQLPage emits it using [OpenTelemetry](https://opentelemetry.io/), the standard format understood by tools such as Grafana, Jaeger, Datadog, Honeycomb, New Relic, and others. + - **Easy start:** run the ready-to-use [`examples/telemetry`](https://github.com/sqlpage/SQLPage/tree/main/examples/telemetry#readme) Docker Compose setup. It starts SQLPage, PostgreSQL, Grafana, Tempo, Loki, Prometheus, and an OpenTelemetry collector, so you can click through a sample app and immediately see request timelines and logs. + - **If you already have a monitoring backend:** set `OTEL_EXPORTER_OTLP_ENDPOINT` and, optionally, `OTEL_SERVICE_NAME=sqlpage`. + image +- **Logging is now structured and safer.** `LOG_LEVEL` is the preferred environment variable for SQLPage log filtering. `RUST_LOG` still works as an alias, so existing deployments do not need an immediate change. Debug logs for OIDC and `sqlpage.fetch` no longer dump raw tokens, cookies, claims, or response bodies, while still keeping useful request and response metadata. +- **New function: `sqlpage.regex_match(pattern, text)`.** It returns regex capture groups as JSON, or `NULL` when there is no match. This is especially useful in custom `404.sql` files for clean dynamic routes such as `/categories/sql/post/42` without creating one SQL file per possible URL. +- **Uploaded files can now get explicit Unix permissions.** `sqlpage.persist_uploaded_file(field, folder, allowed_extensions, mode)` accepts a fourth `mode` argument such as `'644'`. On Unix, uploaded files default to `600`, meaning only the SQLPage process owner can read them. If you serve uploaded files directly from nginx, Caddy, or another reverse proxy, pass an appropriate mode such as `'644'`. +- **Charts are easier to tune and more accessible.** The chart component now supports `show_legend` to hide or show the series legend. ApexCharts was updated from 5.3.6 to [5.13.0](https://github.com/apexcharts/apexcharts.js/releases/tag/v5.13.0). It brings fixes for datetime axes, annotations, data labels, legend state, tooltips, keyboard navigation, reduced-motion handling, and built-in palette contrast. SQLPage also fixes the chart toolbar menu in dark mode. +- **The SQL parser was updated to [sqlparser-rs 0.62.0](https://github.com/apache/datafusion-sqlparser-rs/blob/3dd0e30d8bb1d2a6775f62d2b84839b60133effb/changelog/0.62.0.md), which is the latest published version at release time.** For SQLPage users, this mainly means fewer false parse errors when using database-specific SQL. Notable additions include more PostgreSQL, MySQL, MSSQL, Snowflake, Redshift, Databricks, Spark SQL, and Teradata syntax, plus a SQLite parser panic fix for incomplete `REGEXP`/`MATCH` expressions. +- **Card image galleries can avoid layout shifts.** The card component now supports `top_image_lazy`, `top_image_width`, and `top_image_height`, so pages with many card images can load more smoothly. +- **Datagrid rows can now have stable HTML anchors.** The datagrid component supports a row-level `id` parameter, useful for links, targeted CSS, and small bits of custom JavaScript. +- **OIDC login is more robust under repeated unauthenticated requests.** SQLPage now caps temporary login-state cookies, avoiding the unbounded cookie growth that could happen when many protected pages were requested before authentication completed. +- **HTTP error statuses are more accurate.** Malformed multipart form data and invalid UTF-8 text fields now return `400 Bad Request`; database connection-pool exhaustion now returns `429 Too Many Requests`; invalid non-Unicode static paths now return `400 Bad Request`; and paths that accidentally descend into a file now behave like normal missing resources. This should make monitoring dashboards and reverse-proxy logs easier to interpret. +- **Invalid response headers no longer crash SQLPage.** If a header-only page tries to return an invalid header value, SQLPage now returns a normal error response instead of crashing the request handling path. +- **DuckDB `::` casts are handled better.** SQLPage no longer warns unnecessarily when using DuckDB-style casts. +- **Database-backed filesystems fail earlier and more clearly when misconfigured.** SQLPage now checks that the `sqlpage_files` table is available before preparing database filesystem queries, so a missing or inaccessible table produces a direct startup error. +- **Dependencies and release tooling were refreshed.** This includes updates to Rust, OpenTelemetry, `sqlx-oldapi`, frontend assets, Docker images, and GitHub Actions used by CI and release builds. + +## 0.43.0 + +- OIDC protected and public paths now respect the site prefix when it is defined. +- Fix: OIDC provider metadata refreshes now always happen in the background, and with a timeout. Previously, a slow OIDC provider could prevent SQLPage from handling requests for an arbitrary amount of time. +- Fix: forms without submit or reset buttons no longer keep extra bottom spacing. +- add submit and reset form button icons: validate_icon, reset_icon, reset_color +- improve error messages when sqlpage functions are used incorrectly. Include precise file reference and line number +- updated sql parser: https://github.com/apache/datafusion-sqlparser-rs/blob/main/changelog/0.61.0.md +- Add margin bottom in the [big number](https://sql-page.com/component?component=big%5Fnumber) component +- In forms without a submit button (such as auto_submit forms), remove awkward padding at the end of the form + +## 0.42.0 (2026-01-17) + +- **New Function**: `sqlpage.web_root()` - Returns the web root directory where SQLPage serves `.sql` files from. This is more reliable than `sqlpage.current_working_directory()` when you need to reference the location of your SQL files, especially when the `--web-root` argument or `WEB_ROOT` environment variable is used. +- **New Function**: `sqlpage.configuration_directory()` - Returns the configuration directory where SQLPage looks for `sqlpage.json`, templates, and migrations. Useful when you need to reference configuration-related files in your SQL code. +- fix: The default welcome page (`index.sql`) now correctly displays the web root and configuration directory paths instead of showing the current working directory. +- fix: `sqlpage.variables()` now does not return json objects with duplicate keys when post, get and set variables of the same name are present. The semantics of the returned values remains the same (precedence: set > post > get). +- add support for some duckdb-specific (like `select {'a': 1, 'b': 2}`), and oracle-specific syntax dynamically when connected through odbc. +- better oidc support. Single-sign-on now works with sites: + - using a non-default `site_prefix` + - hosted behind an ssl-terminating reverse proxy +- New docker image variant: `lovasoa/sqlpage:latest-duckdb`, `lovasoa/sqlpage:main-duckdb` with preconfigured duckdb odbc drivers. +- New config option: `cache_stale_duration_ms` to control the duration for which cached sql files are considered fresh. + +## 0.41.0 (2025-12-28) + +- **New Function**: `sqlpage.oidc_logout_url(redirect_uri)` - Generates a secure logout URL for OIDC-authenticated users with support for [RP-Initiated Logout](https://openid.net/specs/openid-connect-rpinitiated-1_0.html#RPLogout) +- Fix compatibility with Auth0 for OpenID-Connect authentification. See https://github.com/ramosbugs/openidconnect-rs/issues/23 +- updated sql parser: https://github.com/apache/datafusion-sqlparser-rs/blob/main/changelog/0.60.0.md +- updated apexcharts to 5.3.6: + - https://github.com/apexcharts/apexcharts.js/compare/v5.3.0...v5.3.6 + - https://github.com/apexcharts/apexcharts.js/releases/tag/v5.3.6 +- re-add the `lime` color option to charts +- update default chart color palette; use [Open Colors](https://yeun.github.io/open-color/) + - image +- re-enable text drop shadow in chart data labels + +## 0.40.0 (2025-11-28) + +- OIDC login redirects now use HTTP 303 responses so POST submissions are converted to safe GET requests before reaching the identity provider, fixing incorrect reuse of the original POST (HTTP 307) that could break standard auth flows. +- SQLPage now respects [HTTP accept headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept) for JSON. You can now easily process the contents of any existing sql page programmatically with: + - `curl -H "Accept: application/json" http://example.com/page.sql`: returns a json array + - `curl -H "Accept: application/x-ndjson" http://example.com/page.sql`: returns one json object per line. +- Fixed a bug in `sqlpage.link`: a link with no path (link to the current page) and no url parameter now works as expected. It used to keep the existing url parameters instead of removing them. `sqlpage.link('', '{}')` now returns `'?'` instead of the empty string. +- `sqlpage.fetch(null)` and `sqlpage.fetch_with_meta(null)` now return `null` instead of throwing an error. +- **New Function**: `sqlpage.set_variable(name, value)` + - Returns a URL with the specified variable set to the given value, preserving other existing variables. + - This is a shorthand for `sqlpage.link(sqlpage.path(), json_patch(sqlpage.variables('get'), json_object(name, value)))`. +- **Variable System Improvements**: URL and POST parameters are now immutable, preventing accidental modification. User-defined variables created with `SET` remain mutable. + - **BREAKING**: `$variable` no longer accesses POST parameters. Use `:variable` instead. + - **What changed**: Previously, `$x` would return a POST parameter value if no GET parameter named `x` existed. + - **Fix**: Replace `$x` with `:x` when you need to access form field values. + - **Example**: Change `SELECT $username` to `SELECT :username` when reading form submissions. + - **BREAKING**: `SET $name` no longer makes GET (URL) parameters inaccessible when a URL parameter with the same name exists. + - **What changed**: `SET $name = 'value'` would previously overwrite the URL parameter `$name`. Now it creates an independent SET variable that shadows the URL parameter. + - **Fix**: This is generally the desired behavior. If you need to access the original URL parameter after setting a variable with the same name, extract it from the JSON returned by `sqlpage.variables('get')`. + - **Example**: If your URL is `page.sql?name=john`, and you do `SET $name = 'modified'`, then: + - `$name` will be `'modified'` (the SET variable) + - The original URL parameter is still preserved and accessible: + - `sqlpage.variables('get')->>'name'` returns `'john'` + - **New behavior**: Variable lookup now follows this precedence: + - `$variable` checks SET variables first, then URL parameters + - SET variables always shadow URL/POST parameters with the same name + - **New sqlpage.variables() filters**: + - `sqlpage.variables('get')` returns only URL parameters as JSON + - `sqlpage.variables('post')` returns only POST parameters as JSON + - `sqlpage.variables('set')` returns only user-defined SET variables as JSON + - `sqlpage.variables()` returns all variables merged together, with SET variables taking precedence + - **Deprecation warnings**: Using `$var` when both a URL parameter and POST parameter exist with the same name now shows a warning. In a future version, you'll need to explicitly choose between `$var` (URL) and `:var` (POST). +- Improved performance of `sqlpage.run_sql`. + - On a simple test that just runs 4 run_sql calls, the new version is about 2.7x faster (15,708 req/s vs 5,782 req/s) with lower latency (0.637 ms vs 1.730 ms per request). +- add support for postgres range types + +## v0.39.1 (2025-11-08) + +- More precise server timing tracking to debug performance issues +- Fix missing server timing header in some cases +- Implement nice error messages for some header-related errors such as invalid header values. +- `compress_responses` is now set to `false` by default in the configuration. +- When response compression is enabled, additional buffering is needed. Users reported a better experience with pages that load more progressively, reducing the time before the pages' shell is rendered. +- When SQLPage is deployed behind a reverse proxy, compressing responses between sqlpage and the proxy is wasteful. +- In the table component, allow simple objects in custom_actions instead of requiring arrays of objects. +- Fatser icon loading. Previously, even a page containing a single icon required downloading and parsing a ~2MB file. This resulted in a delay where pages initially appeared with a blank space before icons appeared. Icons are now inlined inside pages and appear instantaneously. +- Updated tabler icons to 3.35 +- Fix inaccurate ODBC warnings +- Added support for Microsoft SQL Server named instances: `mssql://user:pass@localhost/db?instance_name=xxx` +- Added a detailed [performance guide](https://sql-page.com/blog?post=Performance+Guide) to the docs. + +## v0.39.0 (2025-10-28) + +- Ability to execute sql for URL paths with another extension. If you create sitemap.xml.sql, it will be executed for example.com/sitemap.xml +- Display source line info in errors even when the database does not return a precise error position. In this case, the entire problematic SQL statement is referenced. +- The shell with a vertical sidebar can now have "active" elements, just like the horizontal header bar. +- New `edit_url`, `delete_url`, and `custom_actions` properties in the [table](https://sql-page.com/component.sql?component=table) component to easily add nice icon buttons to a table. +- SQLPage now sets the [`Server-Timing` header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Server-Timing) in development. So when you have a page that loads slowly, you can open your browser's network inspector, click on the slow request, then open the timing tab to understand where it's spending its time. + - image +- Fixed a memory corruption issue in the builtin odbc driver manager +- ODBC: fix using globally installed system drivers by their name in debian-based linux distributions. +- New [login](https://sql-page.com/component.sql?component=table) component. + +## v0.38.0 + +- Added support for the Open Database Connectivity (ODBC) standard. + - This makes SQLPage compatible with many new databases, including: + - [_ClickHouse_](https://github.com/ClickHouse/clickhouse-odbc), + - [_MongoDB_](https://www.mongodb.com/docs/atlas/data-federation/query/sql/drivers/odbc/connect), + - [_DuckDB_](https://duckdb.org/docs/stable/clients/odbc/overview.html), and through it [many other data sources](https://duckdb.org/docs/stable/data/data_sources), + - [_Oracle_](https://www.oracle.com/database/technologies/releasenote-odbc-ic.html), + - [_Snowflake_](https://docs.snowflake.com/en/developer-guide/odbc/odbc), + - [_BigQuery_](https://cloud.google.com/bigquery/docs/reference/odbc-jdbc-drivers), + - [_IBM DB2_](https://www.ibm.com/support/pages/db2-odbc-cli-driver-download-and-installation-information), + - [_Trino_](https://docs.starburst.io/clients/odbc/odbc-v2.html), and through it [many other data sources](https://trino.io/docs/current/connector.html) +- Added a new `sqlpage.hmac()` function for cryptographic HMAC (Hash-based Message Authentication Code) operations. + - Create and verify secure signatures for webhooks (Shopify, Stripe, GitHub, etc.) + - Generate tamper-proof tokens for API authentication + - Secure download links and temporary access codes + - Supports SHA-256 (default) and SHA-512 algorithms + - Output formats: hexadecimal (default) or base64 (e.g., `sha256-base64`) + - See the [function documentation](https://sql-page.com/functions.sql?function=hmac) for detailed examples +- Fixed a slight spacing issue in the list components empty value display. +- Improved performance of setting a variable to a literal value. `SET x = 'hello'` is now executed locally by SQLPage and does not send anything to the database. This completely removes the cost of extracting static values into variables for cleaner SQL files. +- Enable arbitrary precision in the internal representation of numbers. This guarantees zero precision loss when the database returns very large or very small DECIMAL or NUMERIC values. + +## v0.37.1 + +- fixed decoding of UUID values +- Fixed handling of NULL values in `sqlpage.link`. They were encoded as the string `'null'` instead of being omitted from the link's parameters. +- Enable submenu autoclosing (on click) in the shell. This is not ideal, but this prevents a bug introduced in v0.36.0 where the page would scroll back to the top when clicking anywhere on the page after navigating from a submenu. The next version will fix this properly. See https://github.com/sqlpage/SQLPage/issues/1011 +- Adopt the new nice visual errors introduced in v0.37.1 for "403 Forbidden" and "429 Too Many Requests" errors. +- Fix a bug in oidc login flows. When two tabs in the same browser initiated a login at the same time, an infinite redirect loop could be triggered. This mainly occured when restoring open tabs after a period of inactivity, often in mobile browsers. +- Multiple small sql parser improvements. + - Adds support for MERGE queries inside CTEs, and MERGE queries with a RETURNING clause. + - https://github.com/apache/datafusion-sqlparser-rs/blob/main/changelog/0.59.0.md + +## v0.37.0 + +- We now cryptographically sign the Windows app during releases, which proves the file hasn’t been tampered with. Once the production certificate is active, Windows will show a "verified publisher" and should stop showing screens saying "This app might harm your device", "Windows protected your PC" or "Are you sure you want to run this application ?". + - Thanks to https://signpath.io for providing us with a windows signing certificate ! +- Added a new parameter `encoding` to the [fetch](https://sql-page.com/functions.sql?function=fetch) function: +- All [standard web encodings](https://encoding.spec.whatwg.org/#concept-encoding-get) are supported. +- Additionally, `base64` can be specified to decode binary data as base64 (compatible with [data URI](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs)) +- By default, the old behavior of the `fetch_with_meta` function is preserved: the response body is decoded as `utf-8` if possible, otherwise the response is encoded in `base64`. +- Added a specific warning when a URL parameter and a form field have the same name. The previous general warning about referencing form fields with the `$var` syntax was confusing in that case. +- [modal](https://sql-page.com/component.sql?component=modal) component: allow opening modals with a simple link. + - This allows you to trigger modals from any other component, including tables, maps, forms, lists and more. + - Since modals have their own url inside the page, you can now link to a modal from another page, and if you refresh a page while the modal is open, the modal will stay open. + - modals now have an `open` parameter to open the modal automatically when the page is loaded. +- New [download](https://sql-page.com/component.sql?component=download) component to let the user download files. The files may be stored as BLOBs in the database, local files on the server, or may be fetched from a different server. +- **Enhanced BLOB Support**. You can now return binary data (BLOBs) directly to sqlpage, and it will automatically convert them to data URLs. This allows you to use database BLOBs directly wherever a link is expected, including in the new download component. + - supports columns of type `BYTEA` (PostgreSQL), `BLOB` (MySQL, SQLite), `VARBINARY` and `IMAGE` (mssql) + - Automatic detection of common file types based on magic bytes + - This means you can use a BLOB wherever an image url is expected. For instance: + ```sql + select 'list' as component; + select username as title, avatar_blob as image_url + from users; + ``` +- When a sql file is saved with the wrong character encoding (not UTF8), SQLPage now displays a helpful error messages that points to exactly where in the file the problem is. +- More visual error messages: errors that occured before (such as file access issues) used to generate plain text messages that looked scary to non-technical users. All errors are now displayed nicely in the browser. +- The form component now considers numbers and their string representation as equal when comparing the `value` parameter and the values from the `options` parameter in dropdowns. This makes it easier to use variables (which are always strings) in the value parameter in order to preserve a dropdown field value across page reloads. The following is now valid: + - ```sql + select 'form' as component; + select + 'select' as type, + true as create_new, + true as dropdown, + '2' as value, -- passed as text even if the option values are passed as integers + '[{"label": "A", "value": 1}, {"label": "B", "value": 2}]' as options; + ``` + +## v0.36.1 + +- Fix regression introduced in v0.36.0: PostgreSQL money values showed as 0.0 + - The recommended way to display money values in postgres is still to format them in the way you expect in SQL. See https://github.com/sqlpage/SQLPage/issues/983 +- updated dependencies + +## v0.36.0 + +- added support for the MONEY and SMALLMONEY types in MSSQL. +- include [math functions](https://sqlite.org/lang_mathfunc.html) in the builtin sqlite3 database. +- the sqlpage binary can now help you create new empty migration files from the command line: + ``` + ❯ ./sqlpage create-migration my_new_table + Migration file created: sqlpage/migrations/20250627095944_my_new_table.sql + ``` +- New [modal](https://sql-page.com/component.sql?component=modal) component +- In bar charts: Sort chart categories by name instead of first appearance. This is useful when displaying cumulative bar charts with some series missing data for some x values. +- Updated tabler to v1.4 https://github.com/tabler/tabler/releases/tag/%40tabler%2Fcore%401.4.0 +- Updated tabler-icons to v3.34 (19 new icons) https://tabler.io/changelog#/changelog/tabler-icons-3.34 +- Added support for partially private sites when using OIDC single sign-on: + - The same SQLPage application can now have both publicly accessible and private pages accessible to users authenticated with SSO. + - This allows easily creating a "log in page" that redirects to the OIDC provider. + - See the [configuration](./configuration.md) for `oidc_protected_paths` +- Chart component: accept numerical values passed as strings in pie charts. +- updated sql parser: [v0.57](https://github.com/apache/datafusion-sqlparser-rs/blob/main/changelog/0.57.0.md) [v0.58](https://github.com/apache/datafusion-sqlparser-rs/blob/main/changelog/0.58.0.md) + - **Postgres text search types**: allows `tsquery` and `tsvector` data types + ```sql + SELECT 'OpenAI'::text @@ 'open:*'::tsquery; + ``` + - **LIMIT in subqueries**: fixes parsing of `LIMIT` inside subselects + ```sql + SELECT id FROM (SELECT id FROM users ORDER BY id LIMIT 5) AS sub; + ``` + - **MySQL `MEMBER OF`**: JSON array membership test + ```sql + SELECT 17 MEMBER OF('[23, "abc", 17, "ab", 10]') + ``` + - **Join precedence fix**: corrects interpretation of mixed `JOIN` types without join conditions + ```sql + SELECT * FROM t1 NATURAL JOIN t2 + ``` + - **Unicode identifiers**: allows non‑ASCII names in MySQL/Postgres/SQLite + ```sql + SELECT 用户 AS chinese_name FROM accounts; + ``` + - **Regex and `LIKE` operator fixes**: allow using `~` and `LIKE` with arrays + ```sql + select a ~ any(array['x']); + ``` + - MSSQL output and default keywords in `EXEC` statements + ```sql + EXECUTE dbo.proc1 DEFAULT + ``` +- The file-based routing system was improved. Now, requests to `/xxx` redirect to `/xxx/` only if `/xxx/index.sql` exists. +- fix: When single sign on is enabled, and an anonymous user visits a page with URL parameters, the user is correctly redirected to the page with the parameters after login. +- SQLPage can now read custom claims from Single-Sign-On (SSO) tokens. This allows you to configure your identity provider to include user-specific data, such as roles or permissions, directly in the login token. This information becomes available in your SQL queries, enabling you to build pages that dynamically adapt their content to the authenticated user. +- A bug that caused SSO logins to fail over time has been fixed. The issue occurred because identity providers regularly rotate their security keys, but SQLPage previously only fetched them at startup. The application now automatically refreshes this provider metadata periodically and after login errors, ensuring stable authentication without requiring manual restarts. + +## v0.35.2 + +- Fix a bug with zero values being displayed with a non-zero height in stacked bar charts. +- Updated dependencies, including the embedded SQLite database. +- Release binaries are now dynamically linked again, but use GLIBC 2.28 ([released in 2018](https://sourceware.org/glibc/wiki/Glibc%20Timeline)), with is compatible with older linux distributions. +- fixes an issue introduced in 0.35 where custom SQLite extension loading would not work. +- When an user requests a page that does not exist (and the site owner did not provide a custom 404.sql file), we now serve a nice visual 404 web page instead of the ugly textual message and the verbose log messages we used to have. + - ![screenshot 404](https://github.com/user-attachments/assets/02525f9e-91ec-4657-a70f-1b7990cbe25f) + - still returns plain text 404 for non-HTML requests +- Rich text editor: implement a readonly mode, activated when the field is not editable +- [chart](https://sql-page.com/component.sql?component=chart): remove automatic sorting of categories. Values are now displayed in the order they are returned by the query. + +## v0.35.1 + +- improve color palette for charts +- Fix some color names not working in the datagrid component + +## v0.35 + +- Add support for [single sign-on using OIDC](sql-page.com/sso) + - Allows protecting access to your website using "Sign in with Google/Microsoft/..." +- Fix tooltips not showing on line charts with one or more hidden series +- Update default chart colors and text shadows for better readability with all themes +- Optimize memory layout by boxing large structs. Slightly reduces memory usage. +- New example: [Rich text editor](./examples/rich-text-editor/). Let your users safely write formatted text with links and images. +- Update the Tabler CSS library to [v1.3](https://tabler.io/changelog#/changelog/tabler-1.3). This fixes issues with + - the alignment inside chart tooltips + - the display of lists + - update to [tabler incons v1.33](https://tabler.io/changelog#/changelog/tabler-icons-3.33) with many new icons. +- Add an `active` top-level parameter to the shell component to highlight one of the top bar menu items. Thanks to @andrewsinnovations ! +- Make the [Content-Security-Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP) customization more flexible, allowing you to harden the default security rules. Thanks to @guspower ! +- Fix vertically truncated text in the list component on empty descriptions. + - ![screenshot](https://github.com/user-attachments/assets/df258e31-6698-4398-8ce5-4d7f396c03ef) +- Updated sqlparser to [v0.56](https://github.com/apache/datafusion-sqlparser-rs/blob/main/changelog/0.56.0.md), with many improvements including: +- Add support for the xmltable(...) function in postgres +- Add support for MSSQL IF/ELSE statements. +- Added four optional properties to the `big_number` component: + - title_link (string): the URL or path that the Big Number’s title should link to, if any + - title_link_new_tab (bool): how the title link is opened + - value_link (string): the URL or path that the Big Number’s value should link to, if any + - value_link_new_tab (bool): open the link in a new tab +- Add support for nice "switch" checkboxes in the form component using `'switch' as type` +- Add support for headers in the form component using +- Release binaries are statically linked on linux + +## v0.34 (2025-03-23) + +### ✨ Top Features at a Glance + +- **Safer deletion flows** in lists +- **Better table styling control** with CSS updates +- **Right-to-Left language support** +- **HTML-enhanced Markdown** in text components +- **Sticky table footers** for better data presentation + +### 🔒 Security First + +#### **POST-based Deletions** + +List component's `delete_link` now uses secure POST requests: + +```sql +SELECT 'list' AS component; +SELECT 'Delete me' AS title, 'delete_item.sql?id=77' AS delete_link; +``` + +_Prevents accidental deletions by web crawlers and follows REST best practices_ + +#### **Protected Internal Files** + +- Files/folders starting with `.` (e.g., `.utils/`) are now inaccessible +- Perfect for internal scripts used with `sqlpage.run_sql()` + +### 🎨 UI & Component Upgrades + +#### **Table Styling Revolution** + +```css +/* Before: .price | After: */ +._col_price { + background: #f8f9fa; + border-right: 2px solid #dee2e6; +} +``` + +- New CSS class pattern: `._col_{column_name}` +- Fixes [#830](https://github.com/sqlpage/SQLPage/issues/830) + +#### **Column component** + +```sql +SELECT 'columns' AS component; +SELECT 'View details' AS title; -- No button shown +``` + +- Columns without button text now hide empty buttons +- Cleaner interfaces by default + +#### **Sticky Table Footers** + +```sql +SELECT + 'table' AS component, + true AS freeze_footers; +SELECT + 'Total' AS label, + SUM(price) AS value, + true AS _sqlpage_footer; +``` + +- Keep summary rows visible during scroll +- Use `_sqlpage_footer` on your final data row + +### 🌍 Internationalization + +#### **Right-to-Left Support** + +```sql +SELECT 'shell' AS component, true AS rtl; +``` + +- Enable RTL mode per page via shell component +- Perfect for Arabic, Hebrew, and Persian content + +### 📝 Content Handling + +#### **Rich Text Power** + +```sql +SELECT 'text' AS component, + '
+ **Important!** + + New *HTML-enhanced* content. +
' + AS unsafe_contents_md; +``` + +- New `unsafe_contents_md` allows HTML+Markdown mixing + +#### **Base64 Image Support** + +```markdown +![Alt text](data:image/png;base64,iVBORw0KGg...) +``` + +- Embed images directly in Markdown fields + +### ⚙️ Configuration Tweaks + +```json +{ + "markdown_allow_dangerous_html": false, + "markdown_allow_dangerous_protocol": false +} +``` + +- **Markdown safety controls** to change markdown rendering settings + +### 🐛 Notable Fixes + +- **SQL Server** + Fixed TINYINT handling crashes +- **Anchor Links** + Corrected display in tables with fixed headers +- **Form Inputs** + Proper handling of `0` values in number fields + +### 💡 Upgrade Guide + +1. **CSS Updates** + Search/replace `.your_column` → `._col_your_column` if you have custom css targetting tables. +2. **Deletion Flows** + Test list components using `delete_link`. + You can now add a check that the request method is POST if you want to forbid deletions by simply loading pages. + +[View full configuration options →](./configuration.md) + +## 0.33.1 (2025-02-25) + +- Fix a bug where the table component would not format numbers if sorting was not enabled. +- Fix a bug with date sorting in the table component. +- Center table descriptions. +- Fix a rare crash on startup in some restricted linux environments. +- Fix a rare but serious issue when on SQLite and MySQL, some variable values were assigned incorrectly + - `CASE WHEN $a THEN $x WHEN $b THEN $y` would be executed as `CASE WHEN $a THEN $b WHEN $x THEN $y` on these databases. + - the issue only occured when using in case expressions where variables were used both in conditions and results. +- Implement parameter deduplication. + Now, when you write `select $x where $x is not null`, the value of `$x` is sent to the database only once. It used to be sent as many times as `$x` appeared in the statement. +- Improve error messages on invalid sqlpage function calls. The messages now contain actionable advice. +- Fix top navigation bar links color. They appeared "muted", with low contrast, since v0.33 +- update to apex charts v4.5.0. This fixes a bug where tick positions in scatter plots would be incorrect. +- New function: `sqlpage.fetch_with_meta` + - This function is similar to `sqlpage.fetch`, but it returns a json object with the following properties: + - `status`: the http status code of the response. + - `headers`: a json object with the response headers. + - `body`: the response body. + - `error`: an error message if the request failed. + - This is useful when interacting with complex or unreliable external APIs. + +## 0.33.0 (2025-02-15) + +### 1. Routing & URL Enhancements 🔀 + +#### **Clean URLs:** + +Access your pages without the extra “.sql” suffix. For instance, if your file is `page.sql`, you can now use either: + +| Old URL | New URL | +| ------------------------------ | ------------------------------------------------------ | +| `https://example.com/page.sql` | `https://example.com/page` (or `page.sql` still works) | + +Big thanks to [@guspower](https://github.com/guspower) for their contributions! + +#### **Complete Routing Rewrite:** + +We overhauled our request routing system for smoother, more predictable routing across every request. + +--- + +### 2. SQLPage Functions ⚙️ + +#### **sqlpage.fetch (Calling External Services)** + +- **HTTP Basic Authentication:** + SQLPage’s `sqlpage.fetch(request)` now supports HTTP Basic Auth. Easily call APIs requiring a username/password. For example: + + ```sql + SET result = sqlpage.fetch(json_object( + 'url', 'https://api.example.com/data', + 'username', 'user', + 'password', 'pass' + )); + ``` + + Check out the [[fetch documentation](https://sql-page.com/documentation.sql?component=fetch#component)](https://sql-page.com/documentation.sql?component=fetch#component) for more. + +- **Smarter Fetch Errors & Headers Defaults:** + Get clearer error messages if your HTTP request definition is off (unknown fields, etc.). Plus, if you omit the `headers` parameter, SQLPage now sends a default User‑Agent header that includes the SQLPage version. + +- New Functions: [`sqlpage.request_body`](https://sql-page.com/functions.sql?function=request_body) and [`sqlpage.request_body_base64`](https://sql-page.com/functions.sql?function=request_body_base64) + - Return the raw request body as a string or base64 encoded string. + - Useful to build REST JSON APIs in SQL easily. + - Example: + ```sql + INSERT INTO users (name, email) + VALUES ( + json(sqlpage.request_body())->>'name', + json(sqlpage.request_body())->>'email' + ); + ``` + +- **New Function: [sqlpage.headers](https://sql-page.com/functions.sql?function=headers):** + Easily manage and inspect HTTP headers with the brand‑new [`sqlpage.headers`](https://sql-page.com/functions.sql?function=headers) function. + +### 3. UI Component Enhancements 🎨 + +#### **Table & Card Components** + +- **Table CSS Fixes:** + We fixed a bug where table cells weren’t getting the right CSS classes—your tables now align perfectly. + +- **Native Number Formatting:** + Numeric values in tables are now automatically formatted to your visitor’s locale with proper thousands separators and decimal points, and sorted numerically. + _Example:_ + ![Number Formatting Example](https://github.com/user-attachments/assets/ba51a63f-b9ce-4ab2-a6dd-dfa8e22396de) + +- **Enhanced Card Layouts:** + Customizing your `card` components is now easier: + - The `embed` property auto‑appends the `_sqlpage_embed` parameter for embeddable fragments. + - When rendering an embedded page, the `shell` component is replaced by `shell-empty` to avoid duplicate headers and metadata. + ![Card Layout Example](https://github.com/user-attachments/assets/c5b58402-178a-441e-8966-fd8e341b02bc) + +#### **Form Component Boosts** + +- **Auto‑Submit Forms:** + Set `auto_submit` to true and your form will instantly submit on any field change—ideal for dashboard filters. + _Example:_ + ```sql + SELECT 'form' AS component, 'Filter Results' AS title, true AS auto_submit; + SELECT 'date' AS name; + ``` +- **Dynamic Options for Dropdowns:** + Use `options_source` to load dropdown options dynamically from another SQL file. Perfect for autocomplete with large option sets. + _Example:_ + ```sql + SELECT 'form' AS component, 'Select Country' AS title, 'countries.sql' AS options_source; + SELECT 'country' AS name; + ``` +- **Markdown in Field Descriptions:** + With the new `description_md` property, render markdown in form field descriptions for improved guidance. +- **Improved Header Error Messages:** + Now you’ll get more helpful errors if header components (e.g., `json`, `cookie`) are used incorrectly. + +--- + +### 4. Chart, Icons & CSS Updates 📊 + +- **ApexCharts Upgrade:** + We updated ApexCharts to [[v4.4.0](https://github.com/apexcharts/apexcharts.js/releases/tag/v4.4.0)](https://github.com/apexcharts/apexcharts.js/releases/tag/v4.4.0) for smoother charts and minor bug fixes. + +- **Tabler Icons & CSS:** + Enjoy a refreshed look: + - Tabler Icons are now [[v3.30.0](https://tabler.io/changelog#/changelog/tabler-icons-3.30)](https://tabler.io/changelog#/changelog/tabler-icons-3.30) with many new icons. + - The CSS framework has been upgraded to [[Tabler 1.0.0](https://github.com/tabler/tabler/releases/tag/v1.0.0)](https://github.com/tabler/tabler/releases/tag/v1.0.0) for improved consistency and a sleeker interface. + +--- + +### 5. CSV Import & Error Handling 📥 + +- **Enhanced CSV Error Messages:** + More descriptive error messages when a CSV import fails (via `copy` and file upload). + +- **Postgres CSV Bug Fix:** + A bug that caused subsequent requests to fail after a CSV import error on PostgreSQL is now fixed. + (See [Issue #788](https://github.com/sqlpage/SQLPage/issues/788) for details.) + +--- + +### 6. SQL Parser & Advanced SQL Support 🔍 + +**Upgraded SQL Parser ([v0.54](https://github.com/apache/datafusion-sqlparser-rs/blob/main/changelog/0.54.0.md)):** +Our sqlparser is now at [v0.54](https://github.com/apache/datafusion-sqlparser-rs/blob/main/changelog/0.54.0.md), with support for advanced SQL syntax: + +- **INSERT...SELECT...RETURNING:** + ```sql + INSERT INTO users (name, email) + SELECT :name, :email + WHERE :name IS NOT NULL + RETURNING 'redirect' AS component, 'user.sql?id=' || id AS link; + ``` +- **PostgreSQL’s overlaps operator:** + ```sql + SELECT 'card' AS component, + event_name AS title, + start_time::text || ' - ' || end_time::text AS description + FROM events + WHERE + (start_time, end_time) + OVERLAPS + ($start_filter::timestamp, $end_filter::timestamp); + ``` +- **MySQL’s INSERT...SET syntax:** + ```sql + INSERT INTO users + SET name = :name, email = :email; + ``` + +--- + +## 0.32.1 (2025-01-03) + +This is a bugfix release. + +- Fix a bug where the form component would not display the right checked state in radio buttons and checkboxes. +- https://github.com/sqlpage/SQLPage/issues/751 +- Fix a bug in the [link](https://sql-page.com/component.sql?component=link) component where the properties `view_link`, `edit_link`, and `delete_link` had become incompatible with the main `link` property. +- Updated sqlparser to [v0.53](https://github.com/apache/datafusion-sqlparser-rs/blob/main/changelog/0.53.0.md) which fixes parse errors when using some advanced SQL syntax + - adds support for SQLite's `UPDATE OR REPLACE` syntax + - adds support for MSSQL's `JSON_ARRAY` and `JSON_OBJECT` functions + - adds support for PostgreSQL's `JSON_OBJECT(key : value)` and `JSON_OBJECT(key VALUE value)` syntax + - fixes the parsing of `true` and `false` in Microsoft SQL Server (mssql): they are now correctly parsed as column names, not as boolean values, since mssql does not support boolean literals. This means you may have to replace `TRUE as some_property` with `1 as some_property` in your SQL code when working with mssql. +- When your SQL contains errors, the error message now displays the precise line(s) number(s) of your file that contain the error. + +## 0.32.0 (2024-12-29) + +- Rollback any open transactions when an error occurs in a SQL file. + - Previously, if an error occurred in the middle of a transaction, the transaction would be left open, and the connection would be returned to the pool. The next request could get a connection with an open half-completed transaction, which could lead to hard to debug issues. + - This allows safely using features that require a transaction, like + - ```sql + BEGIN; + CREATE TEMPORARY TABLE t (x int) ON COMMIT DROP; -- postgres syntax + -- do something with t + -- previously, if an error occurred, the transaction would be left open, and the connection returned to the pool. + -- the next request could get a connection where the table `t` still exists, leading to a new hard to debug error. + COMMIT; + ``` + - This will now automatically rollback the transaction, even if an error occurs in the middle of it. +- Fix a bug where one additional SQL statement was executed after an error occurred in a SQL file. This could cause surprising unexpected behavior. + - ```sql + insert into t values ($invalid_value); -- if this statement fails, ... + insert into t values (42); -- this next statement should not be executed + ``` +- Fix `error returned from database: 1295 (HY000): This command is not supported in the prepared statement protocol yet` when trying to use transactions with MySQL. `START TRANSACTION` now works as expected in MySQL. +- Fix a bug where a multi-select dropdown would unexpectedly open when the form was reset. +- Add a new optional `sqlpage/on_reset.sql` file that can be used to execute some SQL code after the end of each page execution. + - Useful to reset a connection to the database after each request. +- Fix a bug where the `sqlpage.header` function would not work with headers containing uppercase letters. +- Fix a bug where the table component would not sort columns that contained a space in their name. +- Fix a bug where stacked bar charts would not stack the bars correctly in some cases. +- Update ApexCharts to [v4.1.0](https://github.com/apexcharts/apexcharts.js/releases/tag/v4.1.0). +- Temporarily disable automatic tick amount calculation in the chart component. This was causing issues with mislabeled x-axis data, because of a bug in ApexCharts. +- Add a new `max_recursion_depth` configuration option to limit the depth of recursion allowed in the `run_sql` function. +- Fix a bug where the results of the `JSON` function in sqlite would be interpreted as a string instead of a json object. +- Fix a bug where the `sqlpage.environment_variable` function would return an error if the environment variable was not set. Now it returns `null` instead. +- Update ApexCharts to [v4.3.0](https://github.com/apexcharts/apexcharts.js/releases/tag/v4.3.0). +- New `article` property in the text component to display text in a more readable, article-like format. +- Add support for evaluating calls to `coalesce` inside sqlpage functions. This means you can now use `coalesce` inside arguments of sqlpage functions, and it will be evaluated inside sqlpage. For instance, this lets you call `sqlpage.link(coalesce($url, 'https://sql-page.com'))` to create a link that will use the value of `$url` if it is not null, or fallback to `https://sql-page.com` if it is null. +- In the form component, allow the usage of the `value` property in checkboxes and radio buttons. The custom `checked` property still works, but it is now optional. +- Updated the welcome message displayed on the terminal when starting the server to be friendlier and more helpful. +- Display the page footer (by default: `Built with SQLPage`) at the bottom of the page instead of immediately after the main content. +- Improve links in the list component: The entire list item is now clickable, when a `link` property is provided. +- When using the map component without a basemap, use a light background color that respects the theme color. + +## 0.31.0 (2024-11-24) + +### 🚀 **New Features** + +#### **Improved Components** + +- [**Columns Component**](https://sql-page.com/component.sql?component=columns) + - Markdown-supported descriptions (`description_md`) allow richer formatting. + - Add simple text items without needing JSON handling. + - Optionally skip displaying items (`null as item`). + - ![columns component screenshot](https://github.com/user-attachments/assets/dd5e1ba7-e12f-4119-a201-0583cf765000) + +- [**Table Component**](https://sql-page.com/component.sql?component=table) + - New **freeze headers and columns** feature improves usability with large tables. + - Enhanced search logic ensures more precise matches (e.g., `"xy"` no longer matches separate `x` and `y` cells in adjacent columns). + - Search box visibility is retained during horizontal scrolling. + _Technical:_ Adds `freeze_headers`, `freeze_columns`, and improves the internal search algorithm. + - ![scroll table](https://github.com/user-attachments/assets/546f36fb-b590-487d-8817-47eeed8f1835) + +- [**Form Component**](https://sql-page.com/component.sql?component=form) + - Added an empty option (`empty_option`) to dropdowns, enabling placeholder-like behavior. + - ![form](https://github.com/user-attachments/assets/40a230da-9b1b-49ed-9759-5e21fe812957) + - Improved handling of large form submissions with configurable size limits (`max_uploaded_file_size`, default 5MB). + _Technical:_ There used to be a hardcoded limit to 16kB for all forms. + +--- + +#### **Database Enhancements** + +- **Support for New Data Types**: + - Microsoft SQL Server now supports `BIT` columns. + - Improved handling of `DATETIMEOFFSET` in MSSQL and `TIMESTAMPTZ` in PostgreSQL, preserving their timezones instead of converting them to UTC. + +- **Better JSON Handling**: + - Accept nested JSON objects and arrays as function parameters. + Useful for advanced usage like calling external APIs using `sqlpage.fetch` with complex data structures. + +- **SQL Parser Update**: + - Upgraded to [v0.52.0](https://github.com/apache/datafusion-sqlparser-rs/blob/main/changelog/0.52.0.md) with new features: + - Added support for: + - advanced `JSON_TABLE` usage in MySQL for working with JSON arrays. + - `EXECUTE` statements with parameters in MSSQL for running stored procedures. + - MSSQL's `TRY_CONVERT` function for type conversion. + - `ANY`, `ALL`, and `SOME` subqueries (e.g., `SELECT * FROM t WHERE a = ANY (SELECT b FROM t2)`). + - `LIMIT max_rows, offset` syntax in SQLite. + - Assigning column names aliases using `=` in MSSQL (e.g., `SELECT col_name = value`). + - Fixes a bug where the parser would fail parse a `SET` clause for a variable named `role`. + +--- + +#### **Security and Performance** + +- **Encrypted Login Support for MSSQL**: + - Ensures secure connections with flexible encryption modes: + - No encryption (`?encrypt=not_supported`): For legacy systems and environments where SSL is blocked + - Partial encryption (`?encrypt=off`): Protects login credentials but not data packets. + - Full encryption (`?encrypt=on`): Secures both login and data. + _Technical:_ Controlled using the `encrypt` parameter (`not_supported`, `off`, or `strict`) in mssql connection strings. + +- **Chart Library Optimization**: + - Updated ApexCharts to v4.0.0. + - Fixed duplicate library loads, speeding up pages with multiple charts. + - Fixed a bug where [timeline chart tooltips displayed the wrong labels](https://github.com/sqlpage/SQLPage/issues/659). + +--- + +### 🛠 **Bug Fixes** + +#### Database and Compatibility Fixes + +- **Microsoft SQL Server**: + - Fixed decoding issues for less common data types. + - Resolved bugs in reading `VARCHAR` columns from non-European collations. + - Correctly handles `REAL` values. + +- **SQLite**: + - Eliminated spurious warnings when using SQLPage functions with JSON arguments. + _Technical:_ Avoids warnings like `The column _sqlpage_f0_a1 is missing`. + +#### Component Fixes + +- **Card Component**: + - Fixed layout issues with embedded content (e.g., removed double borders). + - ![Example Screenshot](https://github.com/user-attachments/assets/ea85438d-5fcb-4eed-b90b-a4385675355d) + - Corrected misaligned loading spinners. + +- **Form Dropdowns**: + - Resolved state retention after form resets, ensuring dropdowns reset correctly. + +#### Usability Enhancements + +- Removed unnecessary padding around tables for cleaner layouts. +- Increased spacing between items in the columns component for improved readability. +- Database errors are now consistently logged and displayed with more actionable details. + - ![better errors](https://github.com/user-attachments/assets/f0d2f9ef-9a30-4ff2-af3c-b33a375f2e9b) + _Technical:_ Ensures warnings in the browser and console for faster debugging. + +--- + +## 0.30.1 (2024-10-31) + +- fix a bug where table sorting would break if table search was not also enabled. + +## 0.30.0 (2024-10-30) + +### 🤖 Easy APIs + +- **Enhanced CSV Support**: The [CSV component](https://sql-page.com/component.sql?component=csv) can now create URLs that trigger a CSV download directly on page load. + - This finally makes it possible to allow the download of large datasets as CSV + - This makes it possible to create an API that returns data as CSV and can be easily exposed to other software for interoperabily. +- **Easy [json](https://sql-page.com/component.sql?component=json) APIs** + - The json component now accepts a second sql query, and will return the results as a json array in a very resource-efficient manner. This makes it easier and faster than ever to build REST APIs entirely in SQL. + - ```sql + select 'json' as component; + select * from users; + ``` + - ```json + [ + { "id": 0, "name": "Jon Snow" }, + { "id": 1, "name": "Tyrion Lannister" } + ] + ``` + - **Ease of use** : the component can now be used to automatically format any query result as a json array, without manually using your database''s json functions. + - **server-sent events** : the component can now be used to stream query results to the client in real-time using server-sent events. + +### 🔒 Database Connectivity + +- **Encrypted Microsoft SQL Server Connections**: SQLPage now supports encrypted connections to SQL Server databases, enabling connections to secure databases (e.g., those hosted on Azure). +- **Separate Database Password Setting**: Added `database_password` [configuration option](https://github.com/sqlpage/SQLPage/blob/main/configuration.md) to store passwords securely outside the connection string. This is useful for security purposes, to avoid accidentally leaking the password in logs. This also allows setting the database password as an environment variable directly, without having to URL-encode it inside the connection string. + +### 😎 Developer experience improvements + +- **Improved JSON Handling**: SQLPage now automatically converts JSON strings to JSON objects in databases like SQLite and MariaDB, making it easier to use JSON-based components. + - ```sql + -- Now works out of the box in SQLite + select 'big_number' as component; + select 'Daily performance' as title, perf as value; + json_object( + 'label', 'Monthly', + 'link', 'monthly.sql' + ) as dropdown_item + from performance; + ``` + +### 📈 Table & Search Improvements + +- **Initial Search Value**: Pre-fill the search bar with a default value in tables with `initial_search_value`, making it easier to set starting filters. +- **Faster Sorting and Searching**: Table filtering and sorting has been entirely rewritten. + - filtering is much faster for large datasets + - sorting columns that contain images and links now works as expected + - Since the new code is smaller, initial page loads should be slightly faster, even on pages that do not use tables + +### 🖼️ UI & UX Improvements + +- **[Carousel](https://sql-page.com/component.sql?component=carousel) Updates**: + - Autoplay works as expected when embedded in a card. + - Set image width and height to prevent layout shifts due to varying image sizes. +- **Improved Site SEO**: The site title in the shell component is no longer in `

` tags, which should aid search engines in understanding content better, and avoid confusing between the site name and the page's title. + +### 🛠️ Fixes and improvements + +- **Shell Component Search**: Fixed search feature when no menu item is defined. +- **Updated Icons**: The Tabler icon set has been refreshed from 3.10 to 3.21, making many new icons available: https://tabler.io/changelog + +## 0.29.0 (2024-09-25) + +- New columns component: `columns`. Useful to display a comparison between items, or large key figures to an user. + - ![screenshot](https://github.com/user-attachments/assets/89e4ac34-864c-4427-a926-c38e9bed3f86) +- New foldable component: `foldable`. Useful to display a list of items that can be expanded individually. + - ![screenshot](https://github.com/user-attachments/assets/2274ef5d-7426-46bd-b12c-865c0308a712) +- CLI arguments parsing: SQLPage now processes command-line arguments to set the web root and configuration directory. It also allows getting the currently installed version of SQLPage with `sqlpage --version` without starting the server. + - ``` + $ sqlpage --help + Build data user interfaces entirely in SQL. A web server that takes .sql files and formats the query result using pre-made configurable professional-looking components. + + Usage: sqlpage [OPTIONS] + + Options: + -w, --web-root The directory where the .sql files are located + -d, --config-dir The directory where the sqlpage.json configuration, the templates, and the migrations are located + -c, --config-file The path to the configuration file + -h, --help Print help + -V, --version Print version + ``` + +- Configuration checks: SQLPage now checks if the configuration file is valid when starting the server. This allows to display a helpful error message when the configuration is invalid, instead of crashing or behaving unexpectedly. Notable, we now ensure critical configuration values like directories, timeouts, and connection pool settings are valid. + - ``` + ./sqlpage --web-root /xyz + [ERROR sqlpage] The provided configuration is invalid + Caused by: + Web root is not a valid directory: "/xyz" + ``` +- The configuration directory is now created if it does not exist. This allows to start the server without having to manually create the directory. +- The default database URL is now computed from the configuration directory, instead of being hardcoded to `sqlite://./sqlpage/sqlpage.db`. So when using a custom configuration directory, the default SQLite database will be created inside it. When using the default `./sqlpage` configuration directory, or when using a custom database URL, the default behavior is unchanged. +- New `navbar_title` property in the [shell](https://sql-page.com/documentation.sql?component=shell#component) component to set the title of the top navigation bar. This allows to display a different title in the top menu than the one that appears in the tab of the browser. This can also be set to the empty string to hide the title in the top menu, in case you want to display only a logo for instance. +- Fixed: The `font` property in the [shell](https://sql-page.com/documentation.sql?component=shell#component) component was mistakingly not applied since v0.28.0. It works again. +- Updated SQL parser to [v0.51.0](https://github.com/sqlparser-rs/sqlparser-rs/blob/main/CHANGELOG.md#0510-2024-09-11). Improved `INTERVAL` parsing. +- **Important note**: this version removes support for the `SET $variable = ...` syntax in SQLite. This worked only with some databases. You should replace all occurrences of this syntax with `SET variable = ...` (without the `$` prefix). +- slightly reduce the margin at the top of pages to make the content appear higher on the screen. +- fix the display of the page title when it is long and the sidebar display is enabled. +- Fix an issue where the color name `blue` could not be used in the chart component. +- **divider component**: Add new properties to the divider component: `link`, `bold`, `italics`, `underline`, `size`. + - ![image](https://github.com/user-attachments/assets/1aced068-7650-42d6-b9bf-2b4631a63c70) +- **form component**: fix slight misalignment and sizing issues of checkboxes and radio buttons. + - ![image](https://github.com/user-attachments/assets/2caf6c28-b1ef-4743-8ffa-351e88c82070) +- **table component**: fixed a bug where markdown contents of table cells would not be rendered as markdown if the column name contained uppercase letters on Postgres. Column name matching is now case-insensitive, so `'title' as markdown` will work the same as `'Title' as markdown`. In postgres, non-double-quoted identifiers are always folded to lowercase. +- **shell component**: fixed a bug where the mobile menu would display even when no menu items were provided. + +## 0.28.0 (2024-08-31) + +- Chart component: fix the labels of pie charts displaying too many decimal places. + - ![pie chart](https://github.com/user-attachments/assets/6cc4a522-b9dd-4005-92bc-dc92b16c7293) +- You can now create a `404.sql` file anywhere in your SQLPage project to handle requests to non-existing pages. This allows you to create custom 404 pages, or create [nice URLs](https://sql-page.com/your-first-sql-website/custom_urls.sql) that don't end with `.sql`. + - Create if `/folder/404.sql` exists, then it will be called for all URLs that start with `folder` and do not match an existing file. +- Updated SQL parser to [v0.50.0](https://github.com/sqlparser-rs/sqlparser-rs/blob/main/CHANGELOG.md#0500-2024-08-15) + - Support postgres String Constants with Unicode Escapes, like `U&'\2713'`. Fixes https://github.com/sqlpage/SQLPage/discussions/511 +- New [big_number](https://sql-page.com/documentation.sql?component=big_number#component) component to display key statistics and indicators in a large, easy-to-read format. Useful for displaying KPIs, metrics, and other important numbers in dashboards and reports. + - ![big_number](https://github.com/user-attachments/assets/9b5bc091-afd1-4872-be55-0b2a47aff15c) +- Fixed small display inconsistencies in the shell component with the new sidebar feature ([#556](https://github.com/sqlpage/SQLPage/issues/556)). +- Cleanly close all open database connections when shutting down sqlpage. Previously, when shutting down SQLPage, database connections that were opened during the session were not explicitly closed. These connections could remain open until the database closes it. Now, SQLPage ensures that all opened database connections are cleanly closed during shutdown. This guarantees that resources are freed immediately, ensuring more reliable operation, particularly in environments with limited database connections. + +## 0.27.0 (2024-08-17) + +- updated Apex Charts to v3.52.0 + - see https://github.com/apexcharts/apexcharts.js/releases +- Fixed a bug where in very specific conditions, sqlpage functions could mess up the order of the arguments passed to a sql query. This would happen when a sqlpage function was called with both a column from the database and a sqlpage variable in its arguments, and the query also contained references to other sqlpage variables **after** the sqlpage function call. An example would be `select sqlpage.exec('xxx', some_column = $a) as a, $b as b from t`. A test was added for this case. +- added a new `url_encode` helper for [custom components](https://sql-page.com/custom_components.sql) to encode a string for use in a URL. +- fixed a bug where the CSV component would break when the data contained a `#` character. +- properly escape fields in the CSV component to avoid generating invalid CSV files. +- Nicer inline code style in markdown. +- Fixed `width` attribute in the card component not being respected when the specified width was < 6. +- Fixed small inaccuracies in decimal numbers leading to unexpectedly long numbers in the output, such as `0.47000000000000003` instead of `0.47`. +- [chart component](https://sql-page.com/documentation.sql?component=chart#component) +- TreeMap charts in the chart component allow you to visualize hierarchical data structures. +- Timeline charts allow you to visualize time intervals. +- Fixed multiple small display issues in the chart component. +- When no series name nor top-level `title` is provided, display the series anyway (with no name) instead of throwing an error in the javascript console. +- Better error handling: Stop processing the SQL file after the first error is encountered. +- The previous behavior was to try paresing a new statement after a syntax error, leading to a cascade of irrelevant error messages after a syntax error. +- Allow giving an id to HTML rows in the table component. This allows making links to specific rows in the table using anchor links. (`my-table.sql#myid`) +- Fixed a bug where long menu items in the shell component's menu would wrap on multiple lines. +- Much better error messages when a call to sqlpage.fetch fails. + +## 0.26.0 (2024-08-06) + +### Components + +#### Card + +New `width` attribute in the [card](https://sql-page.com/documentation.sql?component=card#component) component to set the width of the card. This finally allows you to create custom layouts, by combining the `embed` and `width` attributes of the card component! This also updates the default layout of the card component: when `columns` is not set, there is now a default of 4 columns instead of 5. + +![image](https://github.com/user-attachments/assets/98425bd8-c576-4628-9ae2-db3ba4650019) + +#### Datagrid + +fix [datagrid](https://sql-page.com/documentation.sql?component=datagrid#component) color pills display when they contain long text. + +![image](https://github.com/user-attachments/assets/3b7dba27-8812-410c-a383-2b62d6a286ac) + +#### Table + +Fixed a bug that could cause issues with other components when a table was empty. +Improved handling of empty tables. Added a new `empty_description` attribute, which defaults to `No data`. This allows you to display a custom message when a table is empty. + +![image](https://github.com/user-attachments/assets/c370f841-20c5-4cbf-8c9e-7318dce9b87c) + +#### Form + +- Fixed a bug where a form input with a value of `0` would diplay as empty instead of showing the `0`. +- Reduced the margin at the botton of forms to fix the appearance of forms that are validated by a `button` component declared separately from the form. + +#### Shell + +Fixed ugly wrapping of items in the header when the page title is long. We now have a nice text ellipsis (...) when the title is too long. +![image](https://github.com/user-attachments/assets/3ac22d98-dde5-49c2-8f72-45ee7595fe82) + +Fixed the link to the website title in the shell component. + +Allow loading javascript ESM modules in the shell component with the new `javascript_module` property. + +#### html + +Added `text` and `post_html` properties to the [html](https://sql-page.com/documentation.sql?component=html#component) component. This allows to include sanitized user-generated content in the middle of custom HTML. + +```sql +select + 'html' as component; +select + 'Username: ' as html, + 'username that will be safely escaped: <"& ' as text, + '' as post_html; +``` + +### Other + +- allow customizing the [Content-Security-Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy) in the configuration. +- the new default _content security policy_ is both more secure and easier to use. You can now include inline javascript in your custom components with ``. +- update to [sqlparser v0.49.0](https://github.com/sqlparser-rs/sqlparser-rs/blob/main/CHANGELOG.md#0490-2024-07-23) + - support [`WITH ORDINALITY`](https://www.postgresql.org/docs/current/queries-table-expressions.html#QUERIES-TABLEFUNCTIONS) in postgres `FROM` clauses +- update to [handlebars-rs v6](https://github.com/sunng87/handlebars-rust/blob/master/CHANGELOG.md#600---2024-07-20) +- fix the "started successfully" message being displayed before the error message when the server failed to start. +- add support for using the system's native SSL Certificate Authority (CA) store in `sqlpage.fetch`. See the new `system_root_ca_certificates` configuration option. + +## 0.25.0 (2024-07-13) + +- hero component: allow reversing the order of text and images. Allows hero components with the text on the right and the image on the left. +- Reduce the max item width in the datagrid component for a better and more compact display on small screens. This makes the datagrid component more mobile-friendly. If you have a datagrid with long text items, this may impact the layout of your page. You can override this behavior by manually changing the `--tblr-datagrid-item-width` CSS variable in your custom CSS. +- Apply migrations before initializing the on-database file system. This allows migrations to create files in the database file system. +- Added a [new example](https://github.com/sqlpage/SQLPage/tree/main/examples/CRUD%20-%20Authentication) to the documentation +- Bug fix: points with a latitude of 0 are now displayed correctly on the map component. +- Bug fix: in sqlite, lower(NULL) now returns NULL instead of an empty string. This is consistent with the standard behavior of lower() in other databases. SQLPage has its own implementation of lower() that supports unicode characters, and our implementation now matches the standard behavior of lower() in mainstream SQLite. +- Allow passing data from the database to sqlpage functions. + - SQLPage functions are special, because they are not executed inside your database, but by SQLPage itself before sending the query to your database. Thus, they used to require all the parameters to be known at the time the query is sent to your database. + - This limitation is now relaxed, and you can pass data from your database to SQLPage functions, at one condition: the function must be called at the top level of a `SELECT` statement. In this case, SQLPage will get the value of the function arguments from the database, and then execute the function after the query has been executed. + - This fixes most errors like: `Arbitrary SQL expressions as function arguments are not supported.`. +- Better error messages in the dynamic component when properties are missing. +- Bug fix: the top bar was shown only when page title was defined. Now icon, image, and menu_item are also considered. +- [54 new icons](https://tabler.io/icons/changelog) (tabler icons updated from 3.4 to 3.7) +- updated the SQL parser to [v0.48](https://github.com/sqlparser-rs/sqlparser-rs/blob/main/CHANGELOG.md#0480-2024-07-09) + - upport UPDATE statements that contain tuple assignments , like `UPDATE table SET (a, b) = (SELECT 1, 2)` + - support custom operators in postgres. Usefull when using extensions like PostGIS, PGroonga, pgtrgm, or pg_similarity, which define custom operators like `&&&`, `@>`, `<->`, `~>`, `~>=`, `~<=`, `<@`... +- New `html` component to display raw HTML content. This component is meant to be used by advanced users who want to display HTML content that cannot be expressed with the other components. Make sure you understand the security implications before using this component, as using untrusted HTML content can expose your users to [cross-site scripting (XSS)](https://en.wikipedia.org/wiki/Cross-site_scripting) attacks. +- New parameter in the [`run_sql`](https://sql-page.com/functions.sql?function=run_sql#function) function to pass variables to the included SQL file, instead of using the global variables. Together with the new ability to pass data from the database to SQLPage functions, this allows you to create more modular and reusable SQL files. For instance, the following is finally possible: + ```sql + select 'dynamic' as component, sqlpage.run_sql('display_product.sql', json_object('product_id', product_id)) as properties from products; + ``` +- New icons (see [tabler icons 3.10](https://tabler.io/changelog)) +- Updated apexcharts.js to [v3.50.0](https://github.com/apexcharts/apexcharts.js/releases/tag/v3.50.0) +- Improve truncation of long page titles + - ![screenshot long title](https://github.com/sqlpage/SQLPage/assets/552629/9859023e-c706-47b3-aa9e-1c613046fdfa) +- new function: [`sqlpage.link`](https://sql-page.com/functions.sql?function=link#function) to easily create links with parameters between pages. For instance, you can now use + + ```sql + select 'list' as component; + select + product_name as title, + sqlpage.link('product.sql', json_object('product', product_name)) as link + from products; + ``` + + - Before, you would usually build the link manually with `CONCAT('/product.sql?product=', product_name)`, which would fail if the product name contained special characters like '&'. The new `sqlpage.link` function takes care of encoding the parameters correctly. + +- Calls to `json_object` are now accepted as arguments to SQLPage functions. This allows you to pass complex data structures to functions such as `sqlpage.fetch`, `sqlpage.run_sql`, and `sqlpage.link`. +- Better syntax error messages, with a short quotation of the part of the SQL file that caused the error: +- ![syntax error](https://github.com/user-attachments/assets/86ab5628-87bd-4dea-b6fe-64ea19afcdc3) + +## 0.24.0 (2024-06-23) + +- in the form component, searchable `select` fields now support more than 50 options. They used to display only the first 50 options. + - ![screenshot](https://github.com/sqlpage/SQLPage/assets/552629/40571d08-d058-45a8-83ef-91fa134f7ce2) +- map component + - automatically center the map on the contents when no top-level latitude and longitude properties are provided even when the map contains geojson data. + - allow using `FALSE as tile_source` to completely remove the base map. This makes the map component useful to display even non-geographical geometric data. +- Fix a bug that occured when no `database_url` was provided in the configuration file. SQLPage would generate an incorrect default SQLite database URL. +- Add a new `background_color` attribute to the [card](https://sql-page.com/documentation.sql?component=card#component) component to set the background color of the card. + - ![cards with color backgrounds](https://github.com/sqlpage/SQLPage/assets/552629/d925d77c-e1f6-490f-8fb4-cdcc4418233f) +- new handlebars helper for [custom components](https://sql-page.com/custom_components.sql): `{{app_config 'property'}}` to access the configuration object from the handlebars template. +- Prevent form validation and give a helpful error message when an user tries to submit a form with a file upload field that is above the maximum file size. + - ![file upload too large](https://github.com/sqlpage/SQLPage/assets/552629/1c684d33-49bd-4e49-9ee0-ed3f0d454ced) +- Fix a bug in [`sqlpage.read_file_as_data_url`](https://sql-page.com/functions.sql?function=read_file_as_data_url#function) where it would truncate the mime subtype of the file. This would cause the browser to refuse to display SVG files, for instance. +- Avoid vertical scrolling caused by the footer even when the page content is short. +- Add a new `compact` attribute to the [list](https://sql-page.com/documentation.sql?component=list#component), allowing to display more items in a list without taking up too much space. Great for displaying long lists of items. + - ![compact list screenshot](https://github.com/sqlpage/SQLPage/assets/552629/41302807-c6e4-40a0-9486-bfd0ceae1537) +- Add property `narrow` to the [button](https://sql-page.com/documentation.sql?component=button#component) component to make the button narrower. Ideal for buttons with icons. + - ![icon buttons](https://github.com/sqlpage/SQLPage/assets/552629/7fcc049e-6012-40c1-a8ee-714ce70a8763) +- new `tooltip` property in the datagrid component. + - ![datagrid tooltip](https://github.com/sqlpage/SQLPage/assets/552629/81b94d92-1bca-4ffe-9056-c30d6845dcc6) +- datagrids are now slightly more compact, with less padding and less space taken by each item. +- fix a bug in the [card](https://sql-page.com/documentation.sql?component=card#component) component where the icon would sometimes overflow the card's text content. +- new `image` property in the [button](https://sql-page.com/documentation.sql?component=button#component) component to display a small image inside a button. + - ![image button](https://github.com/sqlpage/SQLPage/assets/552629/cdfa0709-1b00-4779-92cb-dc6f3e78c1a8) +- In the `shell` component + - allow easily creating complex menus even in SQLite: + ```sql + select 'shell' as component, 'My Website' as title, '{"title":"About","submenu":[{"link":"/x.sql","title":"X"},{"link":"/y.sql","title":"Y"}]}' as menu_item; + ``` + - allow easily creating optional menu items that are only displayed in some conditions: + ```sql + select 'shell' as component, 'My Website' as title, CASE WHEN $role = 'admin' THEN 'Admin' END as menu_item; + ``` + - Add the ability to use local Woff2 fonts in the [shell](https://sql-page.com/documentation.sql?component=shell#component) component. This is useful to use custom fonts in your website, without depending on google fonts (and disclosing your users' IP addresses to google). + - Add a `fixed_top_menu` attribute to make the top menu sticky. This is useful to keep the menu visible even when the user scrolls down the page. + - ![a fixed top menu](https://github.com/sqlpage/SQLPage/assets/552629/65fe3a41-faee-45e6-9dfc-d81eca043f45) +- Add a `wrap` attribute to the `list` component to wrap items on multiple lines when they are too long. +- New `max_pending_rows` [configuration option](https://sql-page.com/configuration.md) to limit the number of messages that can be sent to the client before they are read. Usefule when sending large amounts of data to slow clients. +- New `compress_responses` configuration option. Compression is still on by default, but can now be disabled to allow starting sending the page sooner. It's sometimes better to start displaying the shell immediateley and render components as soon as they are ready, even if that means transmitting more data over the wire. +- Update sqlite to v3.46: https://www.sqlite.org/releaselog/3_46_0.html + - major upgrades to PRAGMA optimize, making it smarter and more efficient on large databases + - enhancements to [date and time functions](https://www.sqlite.org/lang_datefunc.html), including easy week-of-year calculations + - support for underscores in numeric literals. Write `1_234_567` instead of `1234567` + - new [`json_pretty()`](https://www.sqlite.org/json1.html) function +- Faster initial page load. SQLPage used to wait for the first component to be rendered before sending the shell to the client. We now send the shell immediately, and the first component as soon as it is ready. This can make the initial page load faster, especially when the first component requires a long computation on the database side. +- Include a default favicon when none is specified in the shell component. This fixes the `Unable to read file "favicon.ico"` error message that would appear in the logs by default. + - ![favicon](https://github.com/sqlpage/SQLPage/assets/552629/cf48e271-2fe4-42da-b825-893cff3f95fb) + +## 0.23.0 (2024-06-09) + +- fix a bug in the [csv](https://sql-page.com/documentation.sql?component=csv#component) component. The `separator` parameter now works as expected. This facilitates creating excel-compatible CSVs in european countries where excel expects the separator to be `;` instead of `,`. +- new `tooltip` property in the button component. +- New `search_value` property in the shell component. +- Fixed a display issue in the hero component when the button text is long and the viewport is narrow. +- reuse the existing opened database connection for the current query in `sqlpage.run_sql` instead of opening a new one. This makes it possible to create a temporary table in a file, and reuse it in an included script, create a SQL transaction that spans over multiple run_sql calls, and should generally make run_sql more performant. +- Fixed a bug in the cookie component where removing a cookie from a subdirectory would not work. +- [Updated SQL parser](https://github.com/sqlparser-rs/sqlparser-rs/blob/main/CHANGELOG.md#0470-2024-06-01). Fixes support for `AT TIME ZONE` in postgres. Fixes `GROUP_CONCAT()` in MySQL. +- Add a new warning message in the logs when trying to use `set x = ` when there is already a form field named `x`. +- **Empty Uploaded files**: when a form contains an optional file upload field, and the user does not upload a file, the field used to still be accessible to SQLPage file-related functions such as `sqlpage.uploaded_file_path` and `sqlpage.uploaded_file_mime_type`. This is now fixed, and these functions will return `NULL` when the user does not upload a file. `sqlpage.persist_uploaded_file` will not create an empty file in the target directory when the user does not upload a file, instead it will do nothing and return `NULL`. +- In the [map](https://sql-page.com/documentation.sql?component=map#component) component, when top-level latitude and longitude properties are omitted, the map will now center on its markers. This makes it easier to create zoomed maps with a single marker. +- In the [button](https://sql-page.com/documentation.sql?component=button#component) component, add a `download` property to make the button download a file when clicked, a `target` property to open the link in a new tab, and a `rel` property to prevent search engines from following the link. +- New `timeout` option in the [sqlpage.fetch](https://sql-page.com/functions.sql?function=fetch#function) function to set a timeout for the request. This is useful when working with slow or unreliable APIs, large payloads, or when you want to avoid waiting too long for a response. +- In the [hero](https://sql-page.com/documentation.sql?component=hero#component) component, add a `poster` property to display a video poster image, a `loop` property to loop the video (useful for short animations), a `muted` property to mute the video, and a `nocontrols` property to hide video controls. +- Fix a bug where icons would disappear when serving a SQLPage website from a subdirectory and not the root of the (sub)domain using the `site_prefix` configuration option. + +## 0.22.0 (2024-05-29) + +- **Important Security Fix:** The behavior of `set x` has been modified to match `SELECT $x`. + - **Security Risk:** Previously, `set x` could be overwritten by a POST parameter named `x`. + - **Solution:** Upgrade to SQLPage v0.22. If not possible, then update your application to use `SET :x` instead of `set x`. + - For more information, see [GitHub Issue #342](https://github.com/sqlpage/SQLPage/issues/342). +- **Deprecation Notice:** Reading POST variables using `$x`. + - **New Standard:** Use `:x` for POST variables and `$x` for GET variables. + - **Current Release Warning:** Using `$x` for POST variables will display a console warning: + ``` + Deprecation warning! $x was used to reference a form field value (a POST variable) instead of a URL parameter. This will stop working soon. Please use :x instead. + ``` + - **Future Change:** `$x` will evaluate to `NULL` if no GET variable named `x` is present, regardless of any POST variables. + - **Detection and Update:** Use provided warnings to find and update deprecated usages in your code. + - **Reminder about GET and POST Variables:** + - **GET Variables:** Parameters included in the URL of an HTTP GET request, used to retrieve data. Example: `https://example.com/page?x=value`, where `x` is a GET variable. + - **POST Variables:** Parameters included in the body of an HTTP POST request, used for form submissions. Example: the value entered by the user in a form field named `x`. +- Two **backward-incompatible changes** in the [chart](https://sql-page.com/documentation.sql?component=chart#component) component's timeseries plotting feature (actioned with `TRUE as time`): + - when providing a number for the x value (time), it is now interpreted as a unix timestamp, in seconds (number of seconds since 1970-01-01 00:00:00 UTC). It used to be interpreted as milliseconds. If you were using the `TRUE as time` syntax with integer values, you will need to divide your time values by 1000 to get the same result as before. + - This change makes it easier to work with time series plots, as most databases return timestamps in seconds. For instance, in SQLite, you can store timestamps as integers with the [`unixepoch()`](https://www.sqlite.org/lang_datefunc.html) function, and plot them directly in SQLPage. + - when providing an ISO datetime string for the x value (time), without an explicit timezone, it is now interpreted and displayed in the local timezone of the user. It used to be interpreted as a local time, but displayed in UTC, which [was confusing](https://github.com/sqlpage/SQLPage/issues/324). If you were using the `TRUE as time` syntax with naive datetime strings (without timezone information), you will need to convert your datetime strings to UTC on the database side if you want to keep the same behavior as before. As a side note, it is always recommended to store and query datetime strings with timezone information in the database, to avoid ambiguity. + - This change is particularly useful in SQLite, which generates naive datetime strings by default. You should still store and query datetimes as unix timestamps when possible, to avoid ambiguity and reduce storage size. +- When calling a file with [`sqlpage.run_sql`](https://sql-page.com/functions.sql?function=run_sql#function), the target file now has access to uploaded files. +- New article by [Matthew Larkin](https://github.com/matthewlarkin) about [migrations](https://sql-page.com/your-first-sql-website/migrations.sql). +- Add a row-level `id` attribute to the button component. +- Static assets (js, css, svg) needed to build SQLPage are now cached individually, and can be downloaded separately from the build process. This makes it easier to build SQLPage without internet access. If you use pre-built SQLPage binaries, this change does not affect you. +- New `icon_after` row-level property in the button component to display an icon on the right of a button (after the text). Contributed by @amrutadotorg. +- New demo example: [dark theme](./examples/light-dark-toggle/). Contributed by @lyderic. +- Add the ability to [bind to a unix socket instead of a TCP port](https://sql-page.com/your-first-sql-website/nginx.sql) for better performance on linux. Contributed by @vlasky. + +## 0.21.0 (2024-05-19) + +- `sqlpage.hash_password(NULL)` now returns `NULL` instead of throwing an error. This behavior was changed unintentionally in 0.20.5 and could have broken existing SQLPage websites. +- The [dynamic](https://sql-page.com/documentation.sql?component=dynamic#component) component now supports multiple `properties` attributes. The following is now possible: + ```sql + select 'dynamic' as component, + '{ "component": "card", "title": "Hello" }' as properties, + '{ "title": "World" }' as properties; + ``` +- Casting values from one type to another using the `::` operator is only supported by PostgreSQL. SQLPage versions before 0.20.5 would silently convert all casts to the `CAST(... AS ...)` syntax, which is supported by all databases. Since 0.20.5, SQLPage started to respect the original `::` syntax, and pass it as-is to the database. This broke existing SQLPage websites that used the `::` syntax with databases other than PostgreSQL. For backward compatibility, this version of SQLPage re-establishes the previous behavior, converts `::` casts on non-PostgreSQL databases to the `CAST(... AS ...)` syntax, but will display a warning in the logs. + - In short, if you saw an error like `Error: unrecognized token ":"` after upgrading to 0.20.5, this version should fix it. +- The `dynamic` component now properly displays error messages when its properties are invalid. There used to be a bug where errors would be silently ignored, making it hard to debug invalid dynamic components. +- New [`sqlpage.request_method`](https://sql-page.com/functions.sql?function=request_method#function) function to get the HTTP method used to access the current page. This is useful to create pages that behave differently depending on whether they are accessed with a GET request (to display a form, for instance) or a POST request (to process the form). +- include the trailing semicolon as a part of the SQL statement sent to the database. This doesn't change anything in most databases, but Microsoft SQL Server requires a trailing semicolon after certain statements, such as `MERGE`. Fixes [issue #318](https://github.com/sqlpage/SQLPage/issues/318) +- New `readonly` and `disabled` attributes in the [form](https://sql-page.com/documentation.sql?component=form#component) component to make form fields read-only or disabled. This is useful to prevent the user from changing some fields. +- 36 new icons [(tabler icons 3.4)](https://tabler.io/icons/changelog) +- Bug fixes in charts [(apexcharts.js v3.49.1)](https://github.com/apexcharts/apexcharts.js/releases) + +## 0.20.5 (2024-05-07) + +- Searchable multi-valued selects in the form component + - Fix missing visual indication of selected item in form dropdown fields. + - ![screenshot](https://github.com/tabler/tabler/assets/552629/a575db2f-e210-4984-a786-5727687ac037) + - fix autofocus on select fields with dropdown + - add _searchable_ as an alias for _dropdown_ in the form component +- Added support for SSL client certificates in MySQL and Postgres + - SSL client certificates are commonly used to secure connections to databases in cloud environments. To connect to a database that requires a client certificate, you can now use the ssl_cert and ssl_key connection options in the connection string. For example: postgres://user@host/db?ssl_cert=/path/to/client-cert.pem&ssl_key=/path/to/client-key.pem +- The SQLPage function system was greatly improved + - All the functions can now be freely combined and nested, without any limitation. No more `Expected a literal single quoted string.` errors when trying to nest functions. + - The error messages when a function call is invalid were rewritten, to include more context, and provide suggestions on how to fix the error. This should make it easier get started with SQLPage functions. + Error messages should always be clear and actionnable. If you encounter an error message you don't understand, please [open an issue](https://github.com/sqlpage/SQLPage/issues) on the SQLPage repository. + - Adding new functions is now easier, and the code is more maintainable. This should make it easier to contribute new functions to SQLPage. If you have an idea for a new function, feel free to open an issue or a pull request on the SQLPage repository. All sqlpage functions are defined in [`functions.rs`](./src/webserver/database/sqlpage_functions/functions.rs). +- The `shell-empty` component (used to create pages without a shell) now supports the `html` attribute, to directly set the raw contents of the page. This is useful to advanced users who want to generate the page content directly in SQL, without using the SQLPage components. +- Updated sqlparser to [v0.46](https://github.com/sqlparser-rs/sqlparser-rs/blob/main/CHANGELOG.md#0460-2024-05-03) + - The changes include support for DECLARE parsing and CONVERT styles in MSSQL, improved JSON access parsing and ?-based jsonb operators in Postgres, and `ALTER TABLE ... MODIFY` support for MySQL. + +## 0.20.4 (2024-04-23) + +- Improvements to the fetch function + - Set a default [user-agent header](https://en.wikipedia.org/wiki/User-Agent_header) when none is specified (`User-Agent: sqlpage`). + - bundle root certificates with sqlpage so that we can always access HTTPS URLs even on outdated or stripped-down systems. + - update our https library to the latest version everywhere, to avoid having to bundle two distinct versions of it. + +## 0.20.3 (2024-04-22) + +- New `dropdown` row-level property in the [`form` component](https://sql-page.com/documentation.sql?component=form#component) + - ![select dropdown in form](https://github.com/sqlpage/SQLPage/assets/552629/5a2268d3-4996-49c9-9fb5-d310e753f844) + - ![multiselect input](https://github.com/sqlpage/SQLPage/assets/552629/e8d62d1a-c851-4fef-8c5c-a22991ffadcf) +- Adds a new [`sqlpage.fetch`](https://sql-page.com/functions.sql?function=fetch#function) function that allows sending http requests from SQLPage. This is useful to query external APIs. This avoids having to resort to `sqlpage.exec`. +- Fixed a bug that occured when using both HTTP and HTTPS in the same SQLPage instance. SQLPage tried to bind to the same (HTTP) + port twice instead of binding to the HTTPS port. This is now fixed, and SQLPage can now be used with both a non-443 `port` and + an `https_domain` set in the configuration file. +- [Updated sqlparser](https://github.com/sqlparser-rs/sqlparser-rs/blob/main/CHANGELOG.md) + - adds support for named windows in window functions +- New icons with tabler icons 3.2: https://tabler.io/icons/changelog +- Optimize queries like `select 'xxx' as component, sqlpage.some_function(...) as parameter` + to avoid making an unneeded database query. + This is especially important for the performance of `sqlpage.run_sql` and the `dynamic` component. + +## 0.20.2 (2024-04-01) + +- the **default component**, used when no `select '...' as component` is present, is now [table](https://sql-page.com/documentation.sql?component=table#component). It used to be the `debug` component instead. `table` makes it extremely easy to display the results of any SQL query in a readable manner. Just write any query in a `.sql` file open it in your browser, and you will see the results displayed in a table, without having to use any SQLPage-specific column names or attributes. +- Better error messages when a [custom component](https://sql-page.com/custom_components.sql) contains a syntax error. [Fix contributed upstream](https://github.com/sunng87/handlebars-rust/pull/638) +- Lift a limitation on **sqlpage function nesting**. In previous versions, some sqlpage functions could not be used inside other sqlpage functions. For instance, `sqlpage.url_encode(sqlpage.exec('my_program'))` used to throw an error saying `Nested exec() function not allowed`. This limitation is now lifted, and you can nest any sqlpage function inside any other sqlpage function. +- Allow **string concatenation in inside sqlpage function parameters**. For instance, `sqlpage.exec('echo', 'Hello ' || 'world')` is now supported, whereas it used to throw an error saying `exec('echo', 'Hello ' || 'world') is not a valid call. Only variables (such as $my_variable) and sqlpage function calls (such as sqlpage.header('my_header')) are supported as arguments to sqlpage functions.`. +- Bump the minimal supported rust version to 1.77 (this is what allows us to easily handle nested sqlpage functions) + +## 0.20.1 (2024-03-23) + +- More than 200 new icons, with [tabler icons v3](https://tabler.io/icons/changelog#3.0) +- New [`sqlpage.persist_uploaded_file`](https://sql-page.com/functions.sql?function=persist_uploaded_file#function) function to save uploaded files to a permanent location on the local filesystem (where SQLPage is running). This is useful to store files uploaded by users in a safe location, and to serve them back to users later. +- Correct error handling for file uploads. SQLPage used to silently ignore file uploads that failed (because they exceeded [max_uploaded_file_size](./configuration.md), for instance), but now it displays a clear error message to the user. + +## 0.20.0 (2024-03-12) + +- **file inclusion**. This is a long awaited feature that allows you to include the contents of one file in another. This is useful to factorize common parts of your website, such as the header, or the authentication logic. There is a new [`sqlpage.run_sql`](https://sql-page.com/functions.sql?function=run_sql#function) function that runs a given SQL file and returns its result as a JSON array. Combined with the existing [`dynamic`](https://sql-page.com/documentation.sql?component=dynamic#component) component, this allows you to include the content of a file in another, like this: + +```sql +select 'dynamic' as component, sqlpage.run_sql('header.sql') as properties; +``` + +- **more powerful _dynamic_ component**: the [`dynamic`](https://sql-page.com/documentation.sql?component=dynamic#component) component can now be used to generate the special _header_ components too, such as the `redirect`, `cookie`, `authentication`, `http_header` and `json` components. The _shell_ component used to be allowed in dynamic components, but only if they were not nested (a dynamic component inside another one). This limitation is now lifted. This is particularly useful in combination with the new file inclusion feature, to factorize common parts of your website. There used to be a limited to how deeply nested dynamic components could be, but this limitation is now lifted too. +- Add an `id` attribute to form fields in the [form](https://sql-page.com/documentation.sql?component=form#component) component. This allows you to easily reference form fields in custom javascript code. +- New [`rss`](https://sql-page.com/documentation.sql?component=rss#component) component to create RSS feeds, including **podcast feeds**. You can now create and manage your podcast feed entirely in SQL, and distribute it to all podcast directories such as Apple Podcasts, Spotify, and Google Podcasts. +- Better error handling in template rendering. Many template helpers now display a more precise error message when they fail to execute. This makes it easier to debug errors when you [develop your own custom components](https://sql-page.com/custom_components.sql). +- better error messages when an error occurs when defining a variable with `SET`. SQLPage now displays the query that caused the error, and the name of the variable that was being defined. +- Updated SQL parser to [v0.44](https://github.com/sqlparser-rs/sqlparser-rs/blob/main/CHANGELOG.md#0440-2024-03-02) + - support [EXECUTE ... USING](https://www.postgresql.org/docs/current/plpgsql-statements.html#PLPGSQL-STATEMENTS-EXECUTING-DYN) in PostgreSQL + - support `INSERT INTO ... SELECT ... RETURNING`, which allows you to insert data into a table, and easily pass values from the inserted row to a SQLPage component. [postgres docs](https://www.postgresql.org/docs/current/dml-returning.html), [mysql docs](https://mariadb.com/kb/en/insertreturning/), [sqlite docs](https://sqlite.org/lang_returning.html) + - support [`UPDATE ... FROM`](https://www.sqlite.org/lang_update.html#update_from) in SQLite +- Bug fixes in charts. See [apexcharts.js v3.47.0](https://github.com/apexcharts/apexcharts.js/releases/tag/v3.47.0) + +## 0.19.1 (2024-02-28) + +- **SECURITY**: fixes users being able to re-run migrations by visiting `/sqlpage/migrations/NNNN_name.sql` pages. If you are using sqlpage migrations, your migrations are not idempotent, and you use the default SQLPAGE_WEB_ROOT (`./`) and `SQLPAGE_CONFIGURATION_DIRECTORY` (`./sqlpage/`), you should upgrade to this version as soon as possible. If you are using a custom `SQLPAGE_WEB_ROOT` or `SQLPAGE_CONFIGURATION_DIRECTORY` or your migrations are idempotent, you can upgrade at your convenience. +- Better error messages on invalid database connection strings. SQLPage now displays a more precise and useful message when an error occurs instead of a "panic" message. + +## 0.19.0 (2024-02-25) + +- New `SQLPAGE_CONFIGURATION_DIRECTORY` environment variable to set the configuration directory from the environment. + The configuration directory is where SQLPage looks for the `sqlpage.json` configuration file, for the `migrations` and `templates` directories, and the `on_connect.sql` file. It used to be hardcoded to `./sqlpage/`, which made each SQLPage invokation dependent on the [current working directory](https://en.wikipedia.org/wiki/Working_directory). + Now you can, for instance, set `SQLPAGE_CONFIGURATION_DIRECTORY=/etc/sqlpage/` in your environment, and SQLPage will look for its configuration files in `/etc/sqlpage`, which is a more standard location for configuration files in a Unix environment. + - The official docker image now sets `SQLPAGE_CONFIGURATION_DIRECTORY=/etc/sqlpage/` by default, and changes the working directory to `/var/www/` by default. + - **⚠️ WARNING**: This change can break your docker image if you relied on setting the working directory to `/var/www` and putting the configuration in `/var/www/sqlpage`. In this case, the recommended setup is to store your sqlpage configuration directory and sql files in different directory. For more information see [this issue](https://github.com/sqlpage/SQLPage/issues/246). +- Updated the chart component to use the latest version of the charting library + - https://github.com/apexcharts/apexcharts.js/releases/tag/v3.45.2 + - https://github.com/apexcharts/apexcharts.js/releases/tag/v3.46.0 +- Updated Tabler Icon library to v2.47 with new icons + - see: https://tabler.io/icons/changelog ![](https://pbs.twimg.com/media/GFUiJa_WsAAd0Td?format=jpg&name=medium) +- Added `prefix`, `prefix_icon` and `suffix` attributes to the `form` component to create input groups. Useful to add a currency symbol or a unit to a form input, or to visually illustrate the type of input expected. +- Added `striped_rows`, `striped_columns`, `hover`,`border`, and `small` attributes to the [table component](https://sql-page.com/documentation.sql?component=table#component). +- In the cookie component, set cookies for the entire website by default. The old behavior was to set the cookie + only for files inside the current folder by default, which did not match the documentation, that says "If not specified, the cookie will be sent for all paths". +- Dynamic components at the top of sql files. + - If you have seen _Dynamic components at the top level are not supported, except for setting the shell component properties_ in the past, you can now forget about it. You can now use dynamic components at the top level of your sql files, and they will be interpreted as expected. +- [Custom shells](https://sql-page.com/custom_components.sql): + - It has always been possible to change the default shell of a SQLPage website by writing a `sqlpage/shell.handlebars` file. But that forced you to have a single shell for the whole website. It is now possible to have multiple shells, just by creating multiple `shell-*.handlebars` files in the `sqlpage` directory. A `shell-empty` file is also provided by default, to create pages without a shell (useful for returning non-html content, such as an RSS feed). +- New `edit_link`, `delete_link`, and `view_link` row-level attributes in the list component to add icons and links to each row. + - ![screenshot](https://github.com/sqlpage/SQLPage/assets/552629/df085592-8359-4fed-9aeb-27a2416ab6b8) +- **Multiple page layouts** : The page layout is now configurable from the [shell component](https://sql-page.com/documentation.sql?component=shell#component). 3 layouts are available: `boxed` (the default), `fluid` (full width), and `horizontal` (with boxed contents but a full-width header). + - ![horizontal layout screenshot](https://github.com/sqlpage/SQLPage/assets/552629/3c0fde36-7bf6-414e-b96f-c8880a2fc786) + +## 0.18.3 (2024-02-03) + +- Updated dependencies + - Updated sql parser, to add [support for new syntax](https://github.com/sqlparser-rs/sqlparser-rs/blob/main/CHANGELOG.md), including: + - MySQL's [`JSON_TABLE`](https://dev.mysql.com/doc/refman/8.0/en/json-table-functions.html) table-valued function, that allows easily iterating over json structures + - MySQL's [`CALL`](https://dev.mysql.com/doc/refman/8.0/en/call.html) statements, to call stored procedures. + - PostgreSQL `^@` starts-with operator +- New [carousel](https://sql-page.com/documentation.sql?component=carousel#component) component to display a carousel of images. +- For those who write [custom components](https://sql-page.com/custom_components.sql), a new `@component_index` variable is available in templates to get the index of the current component in the page. This makes it easy to generate unique ids for components. + +## 0.18.2 (2024-01-29) + +- Completes the 0.18.1 fix for the `chart` component: fix missing chart title. + +## 0.18.1 (2024-01-28) + +- Fixes a bug introduced in 0.18.0 where the `chart` component would not respect its `height` attribute. + +## 0.18.0 (2024-01-28) + +- Fix small display issue on cards without a title. +- New component: [`tracking`](https://sql-page.com/documentation.sql?component=tracking#component) for beautiful and compact status reports. +- New component: [`divider`](https://sql-page.com/documentation.sql?component=divider#component) to add a horizontal line between other components. +- New component: [`breadcrumb`](https://sql-page.com/documentation.sql?component=breadcrumb#component) to display a breadcrumb navigation bar. +- fixed a small visual bug in the `card` component, where the margin below footer text was too large. +- new `ystep` top-level attribute in the `chart` component to customize the y-axis step size. +- Updated default graph colors so that all series are easily distinguishable even when a large number of series are displayed. +- New `embed` attribute in the `card` component that lets you build multi-column layouts of various components with cards. +- ![](./examples/cards-with-remote-content/screenshot.png) +- Added `id` and `class` attributes to all components, to make it easier to style them with custom CSS and to reference them in intra-page links and custom javascript code. +- Implemented [uploaded_file_mime_type](https://sql-page.com/functions.sql?function=uploaded_file_mime_type#function) +- Update the built-in SQLite database to version 3.45.0: https://www.sqlite.org/releaselog/3_45_0.html +- Add support for unicode in the built-in SQLite database. This includes the `lower` and `upper` functions, and the `NOCASE` collation. + +## 0.17.1 (2023-12-10) + +- The previous version reduced log verbosity, but also removed the ability to see the HTTP requests in the logs. + This is now fixed, and you can see the HTTP requests again. Logging is still less verbose than before, but you can enable debug logs by setting the `RUST_LOG` environment variable to `debug`, or to `sqlpage=debug` to only see SQLPage debug logs. +- Better error message when failing to bind to a low port (<1024) on Linux. SQLPage now displays a message explaining how to allow SQLPage to bind to a low port. +- When https_domain is set, but a port number different from 443 is set, SQLPage now starts both an HTTP and an HTTPS server. +- Better error message when component order is invalid. SQLPage has "header" components, such as [redirect](https://sql-page.com/documentation.sql?component=redirect#component) and [cookie](https://sql-page.com/documentation.sql?component=cookie#component), that must be executed before the rest of the page. SQLPage now displays a clear error message when you try to use them after other components. +- Fix 404 error not displaying. 404 responses were missing a content-type header, which made them invisible in the browser. +- Add an `image_url` row-level attribute to the [datagrid](https://sql-page.com/documentation.sql?component=datagrid#component) component to display tiny avatar images in data grids. +- change breakpoints in the [hero](https://sql-page.com/documentation.sql?component=hero#component) component to make it more responsive on middle-sized screens such as tablets or small laptops. This avoids the hero image taking up the whole screen on these devices. +- add an `image_url` row-level attribute to the [list](https://sql-page.com/documentation.sql?component=list#component) component to display small images in lists. +- Fix bad contrast in links in custom page footers. +- Add a new [configuration option](./configuration.md): `environment`. This allows you to set the environment in which SQLPage is running. It can be either `development` or `production`. In `production` mode, SQLPage will hide error messages and stack traces from the user, and will cache sql files in memory to avoid reloading them from disk when under heavy load. +- Add support for `selected` in multi-select inputs in the [form](https://sql-page.com/documentation.sql?component=form#component) component. This allows you to pre-select some options in a multi-select input. +- New function: [`sqlpage.protocol`](https://sql-page.com/functions.sql?function=protocol#function) to get the protocol used to access the current page. This is useful to build links that point to your own site, and work both in http and https. +- Add an example to the documentation showing how to create heatmaps with the [chart](https://sql-page.com/documentation.sql?component=chart#component) component. +- 18 new icons available: https://tabler.io/icons/changelog#2.43 +- New top-level attributes for the [`datagrid`](https://sql-page.com/documentation.sql?component=datagrid#component) component: `description`, `description_md` , `icon` , `image_url`. + +## 0.17.0 (2023-11-28) + +### Uploads + +This release is all about a long awaited feature: file uploads. +Your SQLPage website can now accept file uploads from users, store them either in a directory or directly in a database table. + +You can add a file upload button to a form with a simple + +```sql +select 'form' as component; +select 'user_file' as name, 'file' as type; +``` + +when received by the server, the file will be saved in a temporary directory (customizable with `TMPDIR` on linux). You can access the temporary file path with the new [`sqlpage.uploaded_file_path`](https://sql-page.com/functions.sql?function=uploaded_file_path#function) function. + +You can then persist the upload as a permanent file on the server with the [`sqlpage.exec`](https://sql-page.com/functions.sql?function=exec#function) function: + +```sql +set file_path = sqlpage.uploaded_file_path('user_file'); +select sqlpage.exec('mv', $file_path, '/path/to/my/file'); +``` + +or you can store it directly in a database table with the new [`sqlpage.read_file_as_data_url`](https://sql-page.com/functions.sql?function=read_file#function) and [`sqlpage.read_file_as_text`](https://sql-page.com/functions.sql?function=read_file#function) functions: + +```sql +insert into files (content) values (sqlpage.read_file_as_data_url(sqlpage.uploaded_file_path('user_file'))) +returning 'text' as component, 'Uploaded new file with id: ' || id as contents; +``` + +The maximum size of uploaded files is configurable with the [`max_uploaded_file_size`](./configuration.md) configuration parameter. By default, it is set to 5 MiB. + +#### Parsing CSV files + +SQLPage can also parse uploaded CSV files and insert them directly into a database table. +SQLPage re-uses PostgreSQL's [`COPY` syntax](https://www.postgresql.org/docs/current/sql-copy.html) +to import the CSV file into the database. +When connected to a PostgreSQL database, SQLPage will use the native `COPY` statement, +for super fast and efficient on-database CSV parsing. +But it will also work with any other database as well, by +parsing the CSV locally and emulating the same behavior with simple `INSERT` statements. + +`user_file_upload.sql` : + +```sql +select 'form' as component, 'bulk_user_import.sql' as action; +select 'user_file' as name, 'file' as type, 'text/csv' as accept; +``` + +`bulk_user_import.sql` : + +```sql +-- create a temporary table to preprocess the data +create temporary table if not exists csv_import(name text, age text); +delete from csv_import; -- empty the table +-- If you don't have any preprocessing to do, you can skip the temporary table and use the target table directly + +copy csv_import(name, age) from 'user_file' +with (header true, delimiter ',', quote '"', null 'NaN'); -- all the options are optional +-- since header is true, the first line of the file will be used to find the "name" and "age" columns +-- if you don't have a header line, the first column in the CSV will be interpreted as the first column of the table, etc + +-- run any preprocessing you want on the data here + +-- insert the data into the users table +insert into users (name, email) +select upper(name), cast(email as int) from csv_import; +``` + +#### New functions + +##### Handle uploaded files + +- [`sqlpage.uploaded_file_path`](https://sql-page.com/functions.sql?function=uploaded_file_path#function) to get the temprary local path of a file uploaded by the user. This path will be valid until the end of the current request, and will be located in a temporary directory (customizable with `TMPDIR`). You can use [`sqlpage.exec`](https://sql-page.com/functions.sql?function=exec#function) to operate on the file, for instance to move it to a permanent location. +- [`sqlpage.uploaded_file_mime_type`](https://sql-page.com/functions.sql?function=uploaded_file_name#function) to get the type of file uploaded by the user. This is the MIME type of the file, such as `image/png` or `text/csv`. You can use this to easily check that the file is of the expected type before storing it. + +The new _Image gallery_ example in the official repository shows how to use these functions to create a simple image gallery with user uploads. + +##### Read files + +These new functions are useful to read the content of a file uploaded by the user, +but can also be used to read any file on the server. + +- [`sqlpage.read_file_as_text`](https://sql-page.com/functions.sql?function=read_file#function) reads the contents of a file on the server and returns a text string. +- [`sqlpage.read_file_as_data_url`](https://sql-page.com/functions.sql?function=read_file#function) reads the contents of a file on the server and returns a [data URL](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs). This is useful to embed images directly in web pages, or make link + +### HTTPS + +This is the other big feature of this release: SQLPage now supports HTTPS ! + +And it does not require you to do a lot of manual configuration +that will compromise your security if you get it wrong, +like most other web servers do. You just give SQLPage your domain name, +and it will take care of the rest. + +And while we're at it, SQLPage also supports HTTP/2, for even faster page loads. + +To enable HTTPS, you need to buy a [domain name](https://en.wikipedia.org/wiki/Domain_name) +and make it point to the server where SQLPage is running. +Then set the `https_domain` configuration parameter to `yourdomain.com` in your [`sqlpage.json` configuration file](./configuration.md). + +```json +{ + "https_domain": "my-cool-website.com" +} +``` + +That's it. No external tool to install, no certificate to generate, no configuration to tweak. +No need to restart SQLPage either, or to worry about renewing your certificate when it expires. +SQLPage will automatically request a certificate from [Let's Encrypt](https://letsencrypt.org/) by default, +and does not even need to listen on port 80 to do so. + +### SQL parser improvements + +SQLPage needs to parse SQL queries to be able to bind the right parameters to them, +and to inject the results of built-in sqlpage functions in them. +The parser we user is very powerful and supports most SQL features, +but there are some edge cases where it fails to parse a query. +That's why we contribute to it a lot, and bring the latest version of the parser to SQLPage as soon as it is released. + +#### JSON functions in MS SQL Server + +SQLPage now supports the [`FOR JSON` syntax](https://learn.microsoft.com/en-us/sql/relational-databases/json/format-query-results-as-json-with-for-json-sql-server?view=sql-server-ver16&tabs=json-path) in MS SQL Server. + +This unlocks a lot of new possibilities, that were previously only available in other databases. + +This is particularly interesting to build complex menus with the `shell` component, +to build multiple-answer select inputs with the `form` component, +and to create JSON APIs. + +#### Other sql syntax enhancements + +- SQLPage now supports the custom `CONVERT` expression syntax for MS SQL Server, and the one for MySQL. +- SQLPage now supports the `VARCHAR(MAX)` type in MS SQL Server and uses it for all variables bound as parameters to your SQL queries (we used to use `VARCHAR(8000)` before). +- `INSERT INTO ... DEFAULT VALUES ...` is now supported + +### Other news + +- Dates and timestamps returned from the database are now always formatted in ISO 8601 format, which is the standard format for dates in JSON. This makes it easier to use dates in SQLPage. +- The `cookie` component now supports setting an explicit expiration date for cookies. +- The `cookie` component now supports setting the `SameSite` attribute of cookies, and defaults to `SameSite=Strict` for all cookies. What this means in practice is that cookies set by SQLPage will not be sent to your website if the user is coming from another website. This prevents someone from tricking your users into executing SQLPage queries on your website by sending them a malicious link. +- Bugfix: setting `min` or `max` to `0` in a number field in the `form` component now works as expected. +- Added support for `.env` files to set SQLPage's [environment variables](./configuration.md#environment-variables). +- Better responsive design in the card component. Up to 5 cards per line on large screens. The number of cards per line is still customizable with the `columns` attribute. +- New icons: + - ![new icons in tabler 42](https://github.com/tabler/tabler-icons/assets/1282324/00856af9-841d-4aa9-995d-121c7ddcc005) + +## 0.16.1 (2023-11-22) + +- fix a bug where setting a variable to a non-string value would always set it to null +- clearer debug logs (https://github.com/wooorm/markdown-rs/pull/92) +- update compiler to rust 1.74 +- use user id and group id 1000 in docker image (this is the default user id in most linux distributions) + +## 0.16.0 (2023-11-19) + +- Add special handling of hidden inputs in [forms](https://sql-page.com/documentation.sql?component=form#component). Hidden inputs are now completely invisible to the end user, facilitating the implementation of multi-step forms, csrf protaction, and other complex forms. +- 36 new icons available + - https://github.com/tabler/tabler-icons/releases/tag/v2.40.0 + - https://github.com/tabler/tabler-icons/releases/tag/v2.41.0 +- Support multiple statements in [`on_connect.sql`](./configuration.md) in MySQL. +- Randomize postgres prepared statement names to avoid name collisions. This should fix a bug where SQLPage would report errors like `prepared statement "sqlx_s_1" already exists` when using a connection pooler in front of a PostgreSQL database. It is still not recommended to use SQLPage with an external connection pooler (such as pgbouncer), because SQLPage already implements its own connection pool. If you really want to use a connection pooler, you should set the [`max_connections`](./configuration.md) configuration parameter to `1` to disable the connection pooling logic in SQLPage. +- SQL statements are now prepared lazily right before their first execution, instead of all at once when a file is first loaded, which allows **referencing a temporary table created at the start of a file in a later statement** in the same file. This works by delegating statement preparation to the database interface library we use (sqlx). The logic of preparing statements and caching them for later reuse is now entirely delegated to sqlx. This also nicely simplifies the code and logic inside sqlpage itself, and should slightly improve performance and memory usage. + - Creating temporary tables at the start of a file is a nice way to keep state between multiple statements in a single file, without having to use variables, which can contain only a single string value: + + ```sql + DROP VIEW IF EXISTS current_user; + + CREATE TEMPORARY VIEW current_user AS + SELECT * FROM users + INNER JOIN sessions ON sessions.user_id = users.id + WHERE sessions.session_id = sqlpage.cookie('session_id'); + + SELECT 'card' as component, + 'Welcome, ' || username as title + FROM current_user; + ``` + +- Add support for resetting variables to a `NULL` value using `SET`. Previously, storing `NULL` in a variable would store the string `'null'` instead of the `NULL` value. This is now fixed. + ```sql + SET myvar = NULL; + SELECT 'card' as component; + SELECT $myvar IS NULL as title; -- this used to display false, it now displays true + ``` + +## 0.15.2 (2023-11-12) + +- Several improvements were made to the **map** component +- Fix a bug where the new geojson support in the map component would not work when the geojson was passed as a string. This impacted databases that do not support native json objects, such as SQLite. +- Improve support for geojson points (in addition to polygons and lines) in the map component. +- Add a new `size` parameter to the map component to set the size of markers. +- Document the `height` parameter to customize the size of the map. +- `tile_source` parameter to customize the map tiles, giving completely free control over the map appearance. +- `attribution` parameter to customize or remove the small copyright information text box at the bottom of the map. +- Add the ability to customize top navigation links and to create submenus in the `shell` component. + - Postgres example: + + ```sql + select + 'shell' as component, + 'SQLPage' as title, + JSON('{ "link":"/", "title":"Home" }') as menu_item, + JSON('{ "title":"Options", "submenu":[ + {"link":"1.sql","title":"Page 1"}, + {"link":"2.sql","title":"Page 2"} + ]}') as menu_item; + ``` + + - _note_: this requires a database that supports json objects natively. If you are using SQLite, you can work around this limitation by using the `dynamic` component. + +- Updated the embedded database to [SQLite 3.44](https://antonz.org/sqlite-3-44/), which improves performance, compatibility with other databases, and brings new date formatting functions. The new `ORDER BY` clause in aggregate functions is not supported yet in SQLPage. + +## 0.15.1 (2023-11-07) + +- Many improvements in the [`form`](https://sql-page.com/documentation.sql?component=form#component) component + - Multiple form fields can now be aligned on the same line using the `width` attribute. + - A _reset_ button can now be added to the form using the `reset` top-level attribute. + - The _submit_ button can now be customized, and can be removed completely, which is useful to create multiple submit buttons that submit the form to different targets. +- Support non-string values in markdown fields. `NULL` values are now displayed as empty strings, numeric values are displayed as strings, booleans as `true` or `false`, and arrays as lines of text. This avoids the need to cast values to strings in SQL queries. +- Revert a change introduced in v0.15.0: + - Re-add the systematic `CAST(? AS TEXT)` around variables, which helps the database know which type it is dealing with in advance. This fixes a regression in 0.15 where some SQLite websites were broken because of missing affinity information. In SQLite `SELECT '1' = 1` returns `false` but `SELECT CAST('1' AS TEXT) = 1` returns `true`. This also fixes error messages like `could not determine data type of parameter $1` in PostgreSQL. +- Fix a bug where [cookie](https://sql-page.com/documentation.sql?component=cookie#component) removal set the cookie value to the empty string instead of removing the cookie completely. +- Support form submission using the [button](https://sql-page.com/documentation.sql?component=button#component) component using its new `form` property. This allows you to create a form with multiple submit buttons that submit the form to different targets. +- Custom icons and colors for markers in the [map](https://sql-page.com/documentation.sql?component=map#component) component. +- Add support for GeoJSON in the [map](https://sql-page.com/documentation.sql?component=map#component) component. This makes it much more generic and allows you to display any kind of geographic data, including areas, on a map very easily. This plays nicely with PostGIS and Spatialite which can return GeoJSON directly from SQL queries. + +## 0.15.0 (2023-10-29) + +- New function: [`sqlpage.path`](https://sql-page.com/functions.sql?function=path#function) to get the path of the current page. +- Add a new `align_right` attribute to the [table](https://sql-page.com/documentation.sql?component=table#component) component to align a column to the right. +- Fix display of long titles in the shell component. +- New [`sqlpage.variables`](https://sql-page.com/functions.sql?function=variables#function) function for easy handling of complex forms + - `sqlpage.variables('get')` returns a json object containing all url parameters. Inside `/my_page.sql?x=1&y=2`, it returns the string `'{"x":"1","y":"2"}'` + - `sqlpage.variables('post')` returns a json object containg all variables passed through a form. This makes it much easier to handle a form with a variable number of fields. +- Remove systematic casting in SQL of all parameters to `TEXT`. The supported databases understand the type of the parameters natively. +- Some advanced or database-specific SQL syntax that previously failed to parse inside SQLPage is now supported. See [updates in SQLParser](https://github.com/sqlparser-rs/sqlparser-rs/blob/main/CHANGELOG.md#added) + +## 0.14.0 (2023-10-19) + +- Better support for time series in the [chart](https://sql-page.com/documentation.sql?component=chart#component) component. You can now use the `time` top-attribute to display a time series chart + with smart x-axis labels. +- **New component**: [button](https://sql-page.com/documentation.sql?component=button#component). This allows you to create rows of buttons that allow navigation between pages. +- Better error messages for Microsoft SQL Server. SQLPage now displays the line number of the error, which is especially useful for debugging long migration scripts. +- Many improvements in the official website and the documentation. + - Most notably, the documentation now has syntax highlighting on code blocks (using [prism](https://prismjs.com/) with a custom theme made for tabler). This also illustrates the usage of external javascript and css libraries in SQLPage. See [the shell component documentation](https://sql-page.com/documentation.sql?component=shell#component). + - Better display of example queries in the documentation, with smart indentation that makes it easier to read. +- Clarify some ambiguous error messages: + - make it clearer whether the error comes from SQLPage or from the database + - specific tokenization errors are now displayed as such + +## 0.13.0 (2023-10-16) + +- New [timeline](https://sql-page.com/documentation.sql?component=timeline#component) component to display a timeline of events. +- Add support for scatter and bubble plots in the chart component. See [the chart documentation](https://sql-page.com/documentation.sql?component=chart#component). +- further improve debuggability with more precise error messages. In particular, it usd to be hard to debug errors in long migration scripts, because the line number and position was not displayed. This is now fixed. +- Better logs on 404 errors. SQLPage used to log a message without the path of the file that was not found. This made it hard to debug 404 errors. This is now fixed. +- Add a new `top_image` attribute to the [card](https://sql-page.com/documentation.sql?component=card#component) component to display an image at the top of the card. This makes it possible to create beautiful image galleries with SQLPage. +- Updated dependencies, for bug fixes and performance improvements. +- New icons (see https://tabler-icons.io/changelog) +- When `NULL` is passed as an icon name, display no icon instead of raising an error. +- Official docker image folder structure changed. The docker image now expects + - the SQLPage website (`.sql` files) to be in `/var/www/`, and + - the SQLPage configuration folder to be in `/etc/sqlpage/` + - the configuration file should be in `/etc/sqlpage/sqlpage.json` + - the database file should be in `/etc/sqlpage/sqlpage.db` + - custom templates should be in `/etc/sqlpage/templates/` + - This configuration change concerns only the docker image. If you are using the sqlpage binary directly, nothing changes. + +## 0.12.0 (2023-10-04) + +- **variables** . SQLPage now support setting and reusing variables between statements. This allows you to write more complex SQL queries, and to reuse the result of a query in multiple places. + ```sql + -- Set a variable + SET person = (select username from users where id = $id); + -- Use it in a query + SELECT 'text' AS component, 'Hello ' || $person AS contents; + ``` +- _asynchronous password hashing_ . SQLPage used to block a request processing thread while hashing passwords. This could cause a denial of service if an attacker sent many requests to a page that used `sqlpage.hash_password()` + (typically, the account creation page of your website). + SQLPage now launches password hashing operations on a separate thread pool, and can continue processing other requests while waiting for passwords to be hashed. +- Easier configuration for multiple menu items. Syntax like `SELECT 'shell' as component, '["page 1", "page 2"]' as menu_item'` now works as expected. See the new `sqlpage_shell` definition in [the small sql game example](./examples/corporate-conundrum/) and [this discussion](https://github.com/sqlpage/SQLPage/discussions/91). +- New `sqlpage.exec` function to execute a command on the server. This allows you to run arbitrary code on the server, and use the result in your SQL queries. This can be used to make external API calls, send emails, or run any other code on the server. + +```sql +select 'card' as component; +select value->>'name' as title, value->>'email' as description +from json_each(sqlpage.exec('curl', 'https://jsonplaceholder.typicode.com/users')); +``` + +This function is disabled by default for security reasons. To enable it, set the `allow_exec` configuration parameter to `true` in the [configuration](./configuration.md). Enabling it gives full access to the server to anyone who can write SQL queries on your website (this includes users with access to the local filesystem and users with write access to the `sqlpage_files` table on your database), so be careful ! + +- New `sqlpage.url_encode` function to percent-encode URL parameters. + ```sql + select 'card' as component; + select 'More...' as title, 'advanced_search.sql?query=' || sqlpage.url_encode($query) + ``` +- Add the ability to run a sql script on each database connection before it is used, + by simply creating `sqlpage/on_connect.sql` file. This has many interesting use cases: + - allows you to set up your database connection with custom settings, such as `PRAGMA` in SQLite + - set a custom `search_path`, `application_name` or other variables in PostgreSQL + - create temporary tables that will be available to all SQLPage queries but will not be persisted in the database + - [`ATTACH`](https://www.sqlite.org/lang_attach.html) a database in SQLite to query multiple database files at once +- Better error messages. SQLPage displays a more precise and useful message when an error occurs, and displays the position in the SQL statement where the error occured. Incorrect error messages on invalid migrations are also fixed. +- We now distribute docker images from ARM too. Say hello to SQLPage on your Raspberry Pi and your Mac M1 ! +- Create the default SQLite database file in the "sqlpage" config directory instead of at the root of the web server by default. This makes it inaccessible from the web, which is a more secure default. If you want to keep the old behavior, set the `database_url` configuration parameter to `sqlite://sqlpage.db` in your [configuration](./configuration.md). +- New `empty_title`, `empty_description`, and `empty_link` top-level attributes on the [`list`](https://sql-page.com/documentation.sql?component=list#component) component to customize the text displayed when the list is empty. + +## 0.11.0 (2023-09-17) + +- Support for **environment variables** ! You can now read environment variables from sql code using `sqlpage.environment_variable('VAR_NAME')`. +- Better support for connection options in mssql. +- New icons (see https://tabler-icons.io/changelog) +- New version of the CSS library (see https://preview.tabler.io/changelog.html) +- configurable web root (see [configuration.md](./configuration.md)) +- new welcome message + - ``` + SQLPage is now running on http://127.0.0.1:8080/ + You can write your code in .sql files in /path/to/your/website/directory. + ``` +- New `sqlpage.current_working_directory` function to get the [current working directory](https://en.wikipedia.org/wiki/Working_directory) of the SQLPage process. +- New `sqlpage.version` function to get the version of SQLPage. + +## 0.10.3 (2023-09-14) + +- Update database drivers to the latest version. + - Adds new connection string options for mssql, including `app_name` and `instance`. + Set them with `DATABASE_URL=mssql://user:password@host:port/database?app_name=My%20App&instance=My%20Instance` + +## 0.10.2 (2023-09-04) + +- Fix a bug where the `map` component followed by another component would break the page layout. + +## 0.10.1 (2023-08-27) + +- Update the SQL parser, with multiple fixes. See https://github.com/sqlparser-rs/sqlparser-rs/blob/main/CHANGELOG.md#0370-2023-08-22 +- Display all parameters in the debug component (instead of only row-level parameters). +- Update dashmap for better file lookup performance. +- Fix table sorting. +- Fix a bug with Basic Authentication. + See [#72](https://github.com/sqlpage/SQLPage/pull/72). Thanks to @edgrip for the contribution ! + +## 0.10.0 (2023-08-20) + +- `.sql` files are now parsed in the dialect of the database they are executed against, + instead of always being parsed as a "Generic" dialect. + This allows using more database-specific features in SQLPage and avoids confusion. + This should not change anything in most cases, but could break your web application + if you were relying on an SQL dialect syntax that is not directly supported by your database, + hence the major version change. +- Added the ability to download chart data as SVG, PNG, and **CSV** using the new `toolbar` attribute of the `chart` component. + This makes it easy to provide a large data set and allow users to download it as a CSV file from a nice UI. + ```sql + SELECT 'chart' as component, 1 as toolbar; + SELECT quarter as label, sum(sales) as value FROM sales GROUP BY quarter; + ``` +- Added a dark theme ! You can now choose between a light and a dark theme for your SQLPage website. + Select the dark theme using the `theme` parameter of the `shell` component: + ```sql + SELECT 'shell' AS component, 'dark' AS theme; + ``` + See https://github.com/sqlpage/SQLPage/issues/50 +- Fixed a bug where the default index page would be displayed when `index.sql` could not be loaded, + instead of displaying an error page explaining the issue. +- Improved the appearance of scrollbars. (Workaround for https://github.com/tabler/tabler/issues/1648). + See https://github.com/sqlpage/SQLPage/discussions/17 +- Create a single database connection by default when using `sqlite://:memory:` as the database URL. + This makes it easier to use temporary tables and other connection-specific features. +- When no component is selected, display data with the `debug` component by default. + This makes any simple `SELECT` statement a valid SQLPage file. + Before, data returned outside of a component would be ignored. +- Improved error handling. SQLPage now displays a nice error page when an error occurs, even if it's at the top of the page. + This makes it easier to debug SQLPage websites. Before, errors that occured before SQLPage had started to render the page would be displayed as a raw text error message without any styling. +- Added the ability to retry database connections when they fail on startup. + This makes it easier to start SQLPage concurrently with the database, and have it wait for the database to become available. + See the [`database_connection_retries` and `database_connection_acquire_timeout_seconds` configuration parameter](./configuration.md). + +## 0.9.5 (2023-08-12) + +- New `tab` component to create tabbed interfaces. See [the documentation](https://sql-page.com/documentation.sql?component=tab#component). +- Many improvements in database drivers. + - performance and numeric precision improvements, + - multiple fixes around passing NUMERIC, DECIMAL, and JSON values to SQLPage. + +## 0.9.4 (2023-08-04) + +Small bugfix release + +- Fix a bug with simple queries (ones with only static values) that contained multiple repeated columns + (such as `SELECT 'hello' AS menu_item, 'world' AS menu_item`). Only the last column would be taken into account. + This could manifest as a bug where + - only the last menu item in the shell component would be displayed, + - only the last markdown column in a table would be interpreted as markdown, + - only the last icon column in a table would be displayed as an icon. + +## 0.9.3 (2023-08-03) + +- Icons are now loaded directly from the sqlpage binary instead of loading them from a CDN. + This allows pages to load faster, and to get a better score on google's performance audits, potentially improving your position in search results. + - This also makes it possible to host a SQLPage website on an intranet without access to the internet. + - Fixes https://github.com/sqlpage/SQLPage/issues/37 +- store compressed frontend assets in the SQLPage binary: + - smaller SQLPage binary + - Faster page loads, less work on the server +- Fix a bug where table search would fail to find a row if the search term contained some special characters. + - Fixes https://github.com/sqlpage/SQLPage/issues/46 +- Split the charts javascript code from the rest of the frontend code, and load it only when necessary. + This greatly diminishes the amount of js loaded by default, and achieves very good performance scores by default. + SQLPage websites now load even faster, een on slow mobile connections. + +## 0.9.2 (2023-08-01) + +- Added support for more SQL data types. This notably fixes an issue with the display of datetime columns in tables. + - See: https://github.com/sqlpage/SQLPage/issues/41 +- Updated dependencies, better SQL drivers + +## 0.9.1 (2023-07-30) + +- Fix issues with the new experimental mssql driver. + +## 0.9.0 (2023-07-30) + +- Added a new `json` component, which allows building a JSON API entirely in SQL with SQLPage ! + Now creating an api over your database is as simple as `SELECT 'json' AS component, JSON_OBJECT('hello', 'world') AS contents`. +- `SELECT` statements that contain only static values are now interpreted directly by SQLPage, and do not result in a database query. This greatly improves the performance of pages that contain many static elements. +- Redirect index pages without a trailing slash to the same page with the trailing slash. This ensures that relative links work correctly, and gives each page a unique canonical URL. (For instance, if you have a file in `myfolder/index.sql`, then it will be accessible at `mysite.com/myfolder/` and `mysite.com/myfolder` will redirect to `mysite.com/myfolder/`). +- Update the database drivers to the latest version, and switch to a fork of `sqlx`. This also updates the embedded version of SQLite to 3.41.2, with [many cool new features](https://www.sqlite.org/changes.html) such as: + - better json support + - better performance +- Add experimental support for _Microsoft SQL Server_. If you have a SQL Server database lying around, please test it and report any issue you might encounter ! + +## 0.8.0 (2023-07-17) + +- Added a new [`sqlite_extensions` configuration parameter](./configuration.md) to load SQLite extensions. This allows many interesting use cases, such as + - [using spatialite to build a geographic data application](./examples/make%20a%20geographic%20data%20application%20using%20sqlite%20extensions/), + - querying CSV data from SQLPage with [vsv](https://github.com/nalgeon/sqlean/blob/main/docs/vsv.md), + - or building a search engine for your data with [FTS5](https://www.sqlite.org/fts5.html). +- Breaking: change the order of priority for loading configuration parameters: the environment variables have priority over the configuration file. This makes it easier to tweak the configuration of a SQLPage website when deploying it. +- Fix the default index page in MySQL. Fixes [#23](https://github.com/sqlpage/SQLPage/issues/23). +- Add a new [map](https://sql-page.com/documentation.sql?component=map#component) component to display a map with markers on it. Useful to display geographic data from PostGIS or Spatialite. +- Add a new `icon` attribute to the [table](https://sql-page.com/documentation.sql?component=table#component) component to display icons in the table. +- Fix `textarea` fields in the [form](https://sql-page.com/documentation.sql?component=table#component) component to display the provided `value` attribute. Thanks Frank for the contribution ! +- SQLPage now guarantees that a single web request will be handled by a single database connection. Previously, connections were repeatedly taken and put back to the connection pool between each statement, preventing the use of temporary tables, transactions, and other connection-specific features such as [`last_insert_rowid`](https://www.sqlite.org/lang_corefunc.html#last_insert_rowid). This makes it much easier to keep state between SQL statements in a single `.sql` file. Please report any performance regression you might encounter. See [the many-to-many relationship example](./examples/modeling%20a%20many%20to%20many%20relationship%20with%20a%20form/). +- The [table](https://sql-page.com/documentation.sql?component=table#component) component now supports setting a custom background color, and a custom CSS class on a given table line. +- New `checked` attribute for checkboxes and radio buttons. + +## 0.7.2 (2023-07-10) + +### [SQL components](https://sql-page.com/documentation.sql) + +- New [authentication](https://sql-page.com/documentation.sql?component=authentication#component) component to handle user authentication, and password checking +- New [redirect](https://sql-page.com/documentation.sql?component=redirect#component) component to stop rendering the current page and redirect the user to another page. +- The [debug](https://sql-page.com/documentation.sql?component=debug#component) component is now documented +- Added properties to the [shell](https://sql-page.com/documentation.sql?component=shell#component) component: + - `css` to add custom CSS to the page + - `javascript` to add custom Javascript to the page. An example of [how to use it to integrate a react component](https://github.com/sqlpage/SQLPage/tree/main/examples/using%20react%20and%20other%20custom%20scripts%20and%20styles) is available. + - `footer` to set a message in the footer of the page + +### [SQLPage functions](https://sql-page.com/functions.sql) + +- New [`sqlpage.basic_auth_username`](https://sql-page.com/functions.sql?function=basic_auth_username#function) function to get the name of the user logged in with HTTP basic authentication +- New [`sqlpage.basic_auth_password`](https://sql-page.com/functions.sql?function=basic_auth_password#function) function to get the password of the user logged in with HTTP basic authentication. +- New [`sqlpage.hash_password`](https://sql-page.com/functions.sql?function=hash_password#function) function to hash a password with the same algorithm as the [authentication](https://sql-page.com/documentation.sql?component=authentication#component) component uses. +- New [`sqlpage.header`](https://sql-page.com/functions.sql?function=header#function) function to read an HTTP header from the request. +- New [`sqlpage.random_string`](https://sql-page.com/functions.sql?function=random_string#function) function to generate a random string. Useful to generate session ids. + +### Bug fixes + +- Fix a bug where the page style would not load in pages that were not in the root directory: https://github.com/sqlpage/SQLPage/issues/19 +- Fix resources being served with the wrong content type +- Fix compilation of SQLPage as an AWS lambda function +- Fixed logging and display of errors, to make them more useful diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..47f9ff2 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,233 @@ +# Contributing to SQLPage + +Thank you for your interest in contributing to SQLPage! This document will guide you through the contribution process. + +## Development Setup + +1. Install Rust and Cargo (latest stable version): https://www.rust-lang.org/tools/install +2. If you contribute to the frontend, install Node.js too for frontend tooling: https://nodejs.org/en/download/ +3. Clone the repository + +```bash +git clone https://github.com/sqlpage/sqlpage +cd sqlpage +``` + +## Building the project + +The first time you build the project, +dependencies will be downloaded, so you will need internet access, +and the build may take a while. + +Run the following command from the root of the repository to build the project in development mode: + +```bash +cargo build +``` + +The resulting executable will be in `target/debug/sqlpage`. + +### Release mode + +To build the project in release mode: + +```bash +cargo build --release +``` + +The resulting executable will be in `target/release/sqlpage`. + +### ODBC build modes + +SQLPage can either be built with an integrated odbc driver manager (static linking), +or depend on having one already installed on the system where it is running (dynamic linking). + +- Dynamic ODBC (default): `cargo build` +- Static ODBC (Linux and MacOS only): `cargo build --features odbc-static` + +Windows comes with ODBC pre-installed; SQLPage cannot statically link to the unixODBC driver manager on windows. + +## Code Style and Linting + +### Rust + +- Use `cargo fmt` to format your Rust code +- Run `cargo clippy` to catch common mistakes and improve code quality +- All code must pass the following checks: + +```bash +cargo fmt --all -- --check +cargo clippy +``` + +### Frontend + +We use Biome for linting and formatting of the frontend code. + +```bash +npx @biomejs/biome check . +``` + +This will check the entire codebase (html, css, js). + +## Testing + +### Rust Tests + +Run the backend tests: + +```bash +cargo test +``` + +By default, the tests are run against an SQLite in-memory database. + +If you want to run them against another database, +start a database server with `docker compose up database_name` (mssql, mysql, mariadb, or postgres) +and run the tests with the `DATABASE_URL` environment variable pointing to the database: + +```bash +docker compose up mssql # or mysql, mariadb, postgres +export DATABASE_URL=mssql://root:Password123!@localhost/sqlpage +cargo test +``` + +### End-to-End Tests + +We use Playwright for end-to-end testing of dynamic frontend features. +Tests are located in [`tests/end-to-end/`](./tests/end-to-end/). Key areas covered include: + +#### Start a sqlpage instance pointed to the official site source code + +```bash +cd examples/official-site +cargo run +``` + +#### Run the tests + +In a separate terminal, run the tests: + +```bash +cd tests/end-to-end +npm install +npx playwright install chromium +npm run test +``` + +## Documentation + +### Component Documentation + +When adding new components, comprehensive documentation is required. Example from a component documentation: + +```sql +INSERT INTO component(name, icon, description, introduced_in_version) VALUES + ('component_name', 'icon_name', 'Description of the component', 'version'); + +-- Document all parameters +INSERT INTO parameter(component, name, description, type, top_level, optional) +VALUES ('component_name', 'param_name', 'param_description', 'TEXT|BOOLEAN|NUMBER|JSON|ICON|COLOR', false, true); + +-- Include usage examples +INSERT INTO example(component, description, properties) VALUES + ('component_name', 'Example description in markdown', JSON('[ +{"component": "new_component_name", "top_level_property_1": "value1", "top_level_property_2": "value2"}, +{"row_level_property_1": "value1", "row_level_property_2": "value2"} +]')); +``` + +Component documentation is stored in [`./examples/official-site/sqlpage/migrations/`](./examples/official-site/sqlpage/migrations/). + +If you are editing an existing component, edit the existing sql documentation file directly. +If you are adding a new component, add a new sql file in the folder, and add the appropriate insert statements above. + +### SQLPage Function Documentation + +When adding new SQLPage functions, document them using a SQL migrations. Example structure: + +```sql +-- Function Definition +INSERT INTO sqlpage_functions ( + "name", + "introduced_in_version", + "icon", + "description_md" +) +VALUES ( + 'your_function_name', + '1.0.0', + 'function-icon-name', + 'Description of what the function does. + +### Example + + select ''text'' as component, sqlpage.your_function_name(''parameter'') as result; + +Additional markdown documentation, usage notes, and examples go here. +'); + +-- Function Parameters +INSERT INTO sqlpage_function_parameters ( + "function", + "index", + "name", + "description_md", + "type" +) +VALUES ( + 'your_function_name', + 1, + 'parameter_name', + 'Description of what this parameter does and how to use it.', + 'TEXT|BOOLEAN|NUMBER|JSON' +); +``` + +Key elements to include in function documentation: + +- Clear description of the function's purpose +- Version number where the function was introduced +- Appropriate icon +- Markdown-formatted documentation with examples +- All parameters documented with clear descriptions and types +- Security considerations if applicable +- Example usage scenarios + +## Pull Request Process + +1. Create a new branch for your feature/fix: + +```bash +git checkout -b feature/your-feature-name +``` + +2. Make your changes, ensuring: + +- All tests pass +- Code is properly formatted +- New features are documented +- tests cover new functionality + +3. Push your changes and create a Pull Request + +4. CI Checks + Our CI pipeline will automatically: + - Run Rust formatting and clippy checks + - Execute all tests across multiple platforms (Linux, Windows) + - Build Docker images for multiple architectures + - Run frontend linting with Biome + - Test against multiple databases (PostgreSQL, MySQL, MSSQL) + +## Release Process + +Releases are automated when pushing tags that match the pattern `v*` (e.g., `v1.0.0`). The CI pipeline will: + +- Build and test the code +- Create Docker images for multiple architectures +- Push images to Docker Hub +- Create GitHub releases + +## Questions? + +If you have any questions, feel free to open an issue or discussion on GitHub. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..b852248 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,5760 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "actix-codec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" +dependencies = [ + "bitflags 2.13.0", + "bytes", + "futures-core", + "futures-sink", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "actix-http" +version = "3.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e2faa3e7418ed780cca54829d32782a4008a077230f67457caa063415e99c2" +dependencies = [ + "actix-codec", + "actix-rt", + "actix-service", + "actix-tls", + "actix-utils", + "base64 0.22.1", + "bitflags 2.13.0", + "brotli 8.0.4", + "bytes", + "bytestring", + "derive_more", + "encoding_rs", + "flate2", + "foldhash", + "futures-core", + "h2", + "http 0.2.12", + "httparse", + "httpdate", + "itoa", + "language-tags", + "local-channel", + "mime", + "percent-encoding", + "pin-project-lite", + "rand 0.10.2", + "sha1 0.11.0", + "smallvec", + "tokio", + "tokio-util", + "tracing", + "zstd", +] + +[[package]] +name = "actix-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "actix-multipart" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "560e3dd4eae03837f86d1b6bf6222c508568eff36845ef5ebb3a0dff480e3f64" +dependencies = [ + "actix-multipart-derive", + "actix-utils", + "actix-web", + "derive_more", + "futures-core", + "futures-util", + "httparse", + "local-waker", + "log", + "memchr", + "mime", + "rand 0.10.2", + "serde", + "serde_json", + "serde_plain", + "tempfile", + "tokio", +] + +[[package]] +name = "actix-multipart-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8720bceaa6797fd8b2deab968d52e1120b2a8c30950939f6c8cdb42a910bc885" +dependencies = [ + "bytesize", + "darling 0.23.0", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "actix-router" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14f8c75c51892f18d9c46150c5ac7beb81c95f78c8b83a634d49f4ca32551fe7" +dependencies = [ + "bytestring", + "cfg-if", + "http 0.2.12", + "regex", + "regex-lite", + "serde", + "tracing", +] + +[[package]] +name = "actix-rt" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92589714878ca59a7626ea19734f0e07a6a875197eec751bb5d3f99e64998c63" +dependencies = [ + "actix-macros", + "futures-core", + "tokio", +] + +[[package]] +name = "actix-server" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a65064ea4a457eaf07f2fba30b4c695bf43b721790e9530d26cb6f9019ff7502" +dependencies = [ + "actix-rt", + "actix-service", + "actix-utils", + "futures-core", + "futures-util", + "mio", + "socket2 0.5.10", + "tokio", + "tracing", +] + +[[package]] +name = "actix-service" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e46f36bf0e5af44bdc4bdb36fbbd421aa98c79a9bce724e1edeb3894e10dc7f" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "actix-tls" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6176099de3f58fbddac916a7f8c6db297e021d706e7a6b99947785fee14abe9f" +dependencies = [ + "actix-rt", + "actix-service", + "actix-utils", + "futures-core", + "http 0.2.12", + "http 1.4.2", + "impl-more 0.1.9", + "pin-project-lite", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tokio-util", + "tracing", + "webpki-roots 0.26.11", +] + +[[package]] +name = "actix-utils" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a1dcdff1466e3c2488e1cb5c36a71822750ad43839937f85d2f4d9f8b705d8" +dependencies = [ + "local-waker", + "pin-project-lite", +] + +[[package]] +name = "actix-web" +version = "4.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df09e2d9239703dd64056359c920c7f3fba6535ec61a0059e0f44e095ffe02b4" +dependencies = [ + "actix-codec", + "actix-http", + "actix-macros", + "actix-router", + "actix-rt", + "actix-server", + "actix-service", + "actix-tls", + "actix-utils", + "actix-web-codegen", + "bytes", + "bytestring", + "cfg-if", + "cookie", + "derive_more", + "encoding_rs", + "foldhash", + "futures-core", + "futures-util", + "impl-more 0.3.1", + "itoa", + "language-tags", + "log", + "mime", + "once_cell", + "pin-project-lite", + "regex", + "regex-lite", + "serde", + "serde_json", + "serde_urlencoded", + "smallvec", + "socket2 0.6.4", + "time", + "tracing", + "url", +] + +[[package]] +name = "actix-web-codegen" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f591380e2e68490b5dfaf1dd1aa0ebe78d84ba7067078512b4ea6e4492d622b8" +dependencies = [ + "actix-router", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "actix-web-httpauth" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456348ed9dcd72a13a1f4a660449fafdecee9ac8205552e286809eb5b0b29bd3" +dependencies = [ + "actix-utils", + "actix-web", + "base64 0.22.1", + "futures-core", + "futures-util", + "log", + "pin-project-lite", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "adler32" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.13.0", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.18", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +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 = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[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 = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-http-codec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "096146020b08dbc4587685b0730a7ba905625af13c65f8028035cdfd69573c91" +dependencies = [ + "anyhow", + "futures", + "http 1.4.2", + "httparse", + "log", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-net" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" +dependencies = [ + "async-io", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-web-client" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8caf502b44d6d4be6154ac33af012cbb5fef11e6066edcfb42834217fbaf501b" +dependencies = [ + "async-http-codec", + "async-net", + "futures", + "futures-rustls", + "http 1.4.2", + "lazy_static", + "log", + "rustls-pki-types", + "serde", + "thiserror 1.0.69", + "webpki-roots 0.26.11", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atoi" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a8bbe9949e43a1edaa043038c68703b04774156afdfb62ba2cef5bf93d67be" +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 = "awc" +version = "3.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7dc0207013c5059ddce268fe12045bd12b2e919318ee660c891bfe297a54f1f" +dependencies = [ + "actix-codec", + "actix-http", + "actix-rt", + "actix-service", + "actix-tls", + "actix-utils", + "base64 0.22.1", + "bytes", + "cfg-if", + "cookie", + "derive_more", + "futures-core", + "futures-util", + "h2", + "http 0.2.12", + "itoa", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "rand 0.9.4", + "rustls", + "serde", + "serde_json", + "serde_urlencoded", + "tokio", +] + +[[package]] +name = "aws-lc-rs" +version = "1.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[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 = "bigdecimal" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d6867f1565b3aad85681f1015055b087fcfd840d6aeee6eee7f2da317603695" +dependencies = [ + "autocfg", + "libm", + "num-bigint", + "num-integer", + "num-traits", + "serde", + "serde_json", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +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 = "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 = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "brotli" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640d25bc63c50fb1f0b545ffd80207d2e10a4c965530809b40ba3386825c391" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor 2.5.1", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor 5.0.3", +] + +[[package]] +name = "brotli-decompressor" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e2e4afe60d7dd600fdd3de8d0f08c2b7ec039712e3b6137ff98b7004e82de4f" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "bytesize" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7c8918969267b2932ffd5655509bbbea0833823058c378876953217f5fc50e" + +[[package]] +name = "bytestring" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86566c496f2f47d9b8147a4c8b02ffdb69c919fe0c2b2e7195d22cbba0e635c9" +dependencies = [ + "bytes", +] + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.13.0", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "cc" +version = "1.2.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "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 = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "config" +version = "0.15.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b85f248a4de22d204ceabc6299d89d2c70fbd7f09fea53c06c852369652d8139" +dependencies = [ + "async-trait", + "convert_case 0.6.0", + "json5", + "pathdiff", + "ron", + "rust-ini", + "serde-untagged", + "serde_core", + "serde_json", + "toml", + "winnow", + "yaml-rust2", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[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.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + +[[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +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 = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +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-async" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "888dbb0f640d2c4c04e50f933885c7e9c95995d93cec90aba8735b4c610f26f1" +dependencies = [ + "cfg-if", + "csv-core", + "futures", + "itoa", + "ryu", + "serde", + "tokio", + "tokio-stream", +] + +[[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 = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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", +] + +[[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", +] + +[[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", +] + +[[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", +] + +[[package]] +name = "dary_heap" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe" + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[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", +] + +[[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", +] + +[[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 0.10.0", + "proc-macro2", + "quote", + "rustc_version", + "syn", + "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", + "const-oid 0.9.6", + "crypto-common 0.1.6", + "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", + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "hkdf 0.12.4", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[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 = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[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 = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[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 = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +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.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-rustls" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f2f12607f92c69b12ed746fabf9ca4f5c482cba46679c1a75b874ed7c26adb" +dependencies = [ + "futures-io", + "rustls", + "rustls-pki-types", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[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 0.11.1+wasi-snapshot-preview1", + "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", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "handlebars" +version = "6.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26569a2763497b7bd3fbd19374b774ea6038c5293678771259cd534d49740ff" +dependencies = [ + "derive_builder", + "log", + "num-order", + "pest", + "pest_derive", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[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 = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac 0.13.0", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[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 = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "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 = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http 0.2.12", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[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.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +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 = "impl-more" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" + +[[package]] +name = "impl-more" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a84fd5aa25fae5c0f4a33d9cac2ca017fc622cbd089be2229993514990f870" + +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json5" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" +dependencies = [ + "pest", + "pest_derive", + "serde", +] + +[[package]] +name = "lambda-web" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6277b60649250d681654162b7e8e875c938295ea5f883eb9a8da7e27d2c051" +dependencies = [ + "actix-http", + "actix-service", + "actix-web", + "base64 0.13.1", + "brotli 3.5.0", + "lambda_runtime", + "percent-encoding", + "serde", + "serde_json", +] + +[[package]] +name = "lambda_runtime" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd32d5799db2155ae4d47116bb3e169b59f531ced4d5762a10c2125bdd2bf134" +dependencies = [ + "async-stream", + "bytes", + "futures", + "http 0.2.12", + "hyper", + "lambda_runtime_api_client", + "serde", + "serde_json", + "tokio", + "tokio-stream", + "tower", + "tracing", +] + +[[package]] +name = "lambda_runtime_api_client" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7210012be904051520f0dc502140ba599bae3042b65b3737b87727f1aa88a7d6" +dependencies = [ + "http 0.2.12", + "hyper", + "tokio", + "tower-service", +] + +[[package]] +name = "language-tags" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libflate" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd96e993e5f3368b0cb8497dae6c860c22af8ff18388c61c6c0b86c58d86b5df" +dependencies = [ + "adler32", + "crc32fast", + "dary_heap", + "libflate_lz77", + "no_std_io2", +] + +[[package]] +name = "libflate_lz77" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff7a10e427698aef6eef269482776debfef63384d30f13aad39a1a95e0e098fd" +dependencies = [ + "hashbrown 0.16.1", + "no_std_io2", + "rle-decode-fast", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "bitflags 2.13.0", + "libc", + "plain", + "redox_syscall 0.9.0", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.38.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6c19a05435c21ac299d71b6a9c13db3e3f47c520517d58990a462a1397a61db" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "local-channel" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6cbc85e69b8df4b8bb8b89ec634e7189099cea8927a276b7384ce5488e53ec8" +dependencies = [ + "futures-core", + "futures-sink", + "local-waker", +] + +[[package]] +name = "local-waker" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d873d7c67ce09b42110d801813efbc9364414e356be9935700d368351657487" + +[[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.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "markdown" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5cab8f2cadc416a82d2e783a1946388b31654d391d1c7d92cc1f03e295b1deb" +dependencies = [ + "log", + "unicode-id", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[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 = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[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 = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "log", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.2", +] + +[[package]] +name = "mutually_exclusive_features" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e94e1e6445d314f972ff7395df2de295fe51b71821694f0b0e1e79c4f12c8577" + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.0", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[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-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.6", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-modular" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc41a1374056e9672221567958a66c16be12d0e2c1b408761e14d901c237d5e0" + +[[package]] +name = "num-order" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537b596b97c40fcf8056d153049eb22f481c17ebce72a513ec9286e4986d1bb6" +dependencies = [ + "num-modular", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "oauth2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" +dependencies = [ + "base64 0.22.1", + "chrono", + "getrandom 0.2.17", + "http 1.4.2", + "rand 0.8.6", + "serde", + "serde_json", + "serde_path_to_error", + "sha2 0.10.9", + "thiserror 1.0.69", + "url", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.0", + "block2", + "libc", + "objc2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2", + "objc2-core-location", + "objc2-foundation", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2", + "objc2", + "objc2-foundation", + "objc2-metal", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2", + "objc2", + "objc2-contacts", + "objc2-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.0", + "block2", + "dispatch", + "libc", + "objc2", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2", + "objc2-foundation", + "objc2-metal", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "objc2-core-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation", + "objc2-link-presentation", + "objc2-quartz-core", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2", + "objc2-core-location", + "objc2-foundation", +] + +[[package]] +name = "odbc-api" +version = "28.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c81fc7467324b7a4dfcce514d18439f95ec6acec1adcae113f7b72c34b1dc1d8" +dependencies = [ + "atoi 3.1.0", + "log", + "odbc-sys", + "thiserror 2.0.18", + "widestring", + "winit", +] + +[[package]] +name = "odbc-sys" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "245cb4fe8236df4fd352ba96075d754233c6509d654d9f1c1482158b7d6c083d" +dependencies = [ + "unix-odbc", +] + +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs", +] + +[[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 = "openidconnect" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c6709ba2ea764bbed26bce1adf3c10517113ddea6f2d4196e4851757ef2b2" +dependencies = [ + "base64 0.21.7", + "chrono", + "dyn-clone", + "ed25519-dalek", + "hmac 0.12.1", + "http 1.4.2", + "itertools 0.10.5", + "log", + "oauth2", + "p256", + "p384", + "rand 0.8.6", + "rsa", + "serde", + "serde-value", + "serde_json", + "serde_path_to_error", + "serde_plain", + "serde_with", + "sha2 0.10.9", + "subtle", + "thiserror 1.0.69", + "url", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "opentelemetry" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0142c63252a9e054e68a4c61a5778f7b14f576274d593f8ce883d191a099682" +dependencies = [ + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror 2.0.18", +] + +[[package]] +name = "opentelemetry-http" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5683015d09e2df236ef005b17f6f196f0d5f6313c4fa43a7b6a53b52776e4331" +dependencies = [ + "async-trait", + "bytes", + "http 1.4.2", + "opentelemetry", +] + +[[package]] +name = "opentelemetry-otlp" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9966929966d17620d7c316c643ba62631826e10021409357772d5eea84f62c35" +dependencies = [ + "http 1.4.2", + "opentelemetry", + "opentelemetry-http", + "opentelemetry-proto", + "opentelemetry_sdk", + "prost", + "thiserror 2.0.18", +] + +[[package]] +name = "opentelemetry-proto" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56d658ba1faf63f7b9c492cfbe6e0ec365440a16132d3270c1065f7b33f1b638" +dependencies = [ + "opentelemetry", + "opentelemetry_sdk", + "prost", +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c913ac17a6c451661ee255f4625d143e51647ae78ebd969b75e41c4442f4fe47" + +[[package]] +name = "opentelemetry_sdk" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b59f80e1ac4d5ff7a2db8fb6c80badb7f0f3f858211fba08dd9aaec750894f9" +dependencies = [ + "futures-channel", + "futures-executor", + "futures-util", + "opentelemetry", + "percent-encoding", + "portable-atomic", + "rand 0.9.4", + "thiserror 2.0.18", + "tokio", + "tokio-stream", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[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 0.5.18", + "smallvec", + "windows-link", +] + +[[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 = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47627dd7305c6a2d6c8c6bcd24c5a4c17dbbf425f4f9c5313e724b38fc9782e9" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b4254325ecad416ab689e27ba51da03ba01a9632bc6e108f5fe7c3c4ad29d58" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c4c0e91ead7a8f7acecbca6f003fc2e8282b1dbe2dd9c9d2f16aba42995e0a7" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9744bc48116fee06334924bb5f2bad41eed5e89bd26e29b0b799f9a3f82c210" +dependencies = [ + "pest", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +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 = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[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.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[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 = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "aws-lc-rs", + "pem", + "rustls-pki-types", + "time", + "yasna", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "redox_syscall" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5102a6aaa05aa011a238e178e6bca86d2cb56fc9f586d37cb80f5bca6e07759" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + +[[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 = "rle-decode-fast" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422" + +[[package]] +name = "ron" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3" +dependencies = [ + "bitflags 2.13.0", + "once_cell", + "serde", + "serde_derive", + "typeid", + "unicode-ident", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-acme" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9c70a17ecb067d5067565a16a2e0f26a4a2ea0924f49739d558c45186facc75" +dependencies = [ + "async-io", + "async-trait", + "async-web-client", + "aws-lc-rs", + "base64 0.22.1", + "blocking", + "chrono", + "futures", + "futures-rustls", + "http 1.4.2", + "log", + "pem", + "rcgen", + "serde", + "serde_json", + "thiserror 2.0.18", + "webpki-roots 1.0.8", + "x509-parser", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[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.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "indexmap 2.14.0", + "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_plain" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" +dependencies = [ + "serde", +] + +[[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 = "serde_with" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[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.0", + "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.0", + "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 = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlpage" +version = "0.44.1" +dependencies = [ + "actix-http", + "actix-multipart", + "actix-rt", + "actix-web", + "actix-web-httpauth", + "anyhow", + "argon2", + "async-recursion", + "async-stream", + "async-trait", + "awc", + "base64 0.22.1", + "bigdecimal", + "chrono", + "clap", + "config", + "csv-async", + "dotenvy", + "encoding_rs", + "futures-util", + "handlebars", + "hmac 0.13.0", + "include_dir", + "lambda-web", + "libflate", + "log", + "markdown", + "mime_guess", + "odbc-sys", + "openidconnect", + "opentelemetry", + "opentelemetry-http", + "opentelemetry-otlp", + "opentelemetry-semantic-conventions", + "opentelemetry_sdk", + "percent-encoding", + "rand 0.10.2", + "regex", + "rustls", + "rustls-acme", + "rustls-native-certs", + "serde", + "serde_json", + "sha2 0.11.0", + "sqlparser", + "sqlx-oldapi", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "tracing-actix-web", + "tracing-log", + "tracing-opentelemetry", + "tracing-subscriber", +] + +[[package]] +name = "sqlparser" +version = "0.62.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c6d1b651dc4edf07eead2a0c6c78016ce971bc2c10da5266861b13f25e7cec" +dependencies = [ + "log", + "sqlparser_derive", +] + +[[package]] +name = "sqlparser_derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6dd45d8fc1c79299bfbb7190e42ccbbdf6a5f52e4a6ad98d92357ea965bd289" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sqlx-core-oldapi" +version = "0.6.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e33eb18d1e750df8aef99361ee02e170562dff119108680bc9035e796cd2a84" +dependencies = [ + "ahash", + "atoi 2.0.0", + "base64 0.22.1", + "bigdecimal", + "bitflags 2.13.0", + "byteorder", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "dirs", + "dotenvy", + "either", + "encoding_rs", + "event-listener", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "hashlink", + "hex", + "hkdf 0.13.0", + "hmac 0.13.0", + "indexmap 2.14.0", + "itoa", + "libc", + "libsqlite3-sys", + "log", + "md-5", + "memchr", + "num-bigint", + "odbc-api", + "once_cell", + "percent-encoding", + "rand 0.10.2", + "regex", + "rsa", + "rustls", + "serde", + "serde_json", + "sha1 0.10.6", + "sha1 0.11.0", + "sha2 0.11.0", + "smallvec", + "sqlx-rt-oldapi", + "stringprep", + "thiserror 2.0.18", + "tokio-stream", + "tokio-util", + "url", + "uuid", + "webpki-roots 1.0.8", + "whoami", +] + +[[package]] +name = "sqlx-macros-oldapi" +version = "0.6.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adbf4ebc08c19991fa51993f471e572930c4dec146d3dc915a8e54db91d624c6" +dependencies = [ + "dotenvy", + "either", + "heck", + "once_cell", + "proc-macro2", + "quote", + "serde_json", + "sha2 0.11.0", + "sqlx-core-oldapi", + "sqlx-rt-oldapi", + "syn", + "url", +] + +[[package]] +name = "sqlx-oldapi" +version = "0.6.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ba4d352504ee1a0a76eb052879d68ba63536c738da5062026ac0d3dc724c7" +dependencies = [ + "sqlx-core-oldapi", + "sqlx-macros-oldapi", +] + +[[package]] +name = "sqlx-rt-oldapi" +version = "0.6.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b8d629fed8792460ff39bb58cb154bfe181893ab9a51d0a3634950b35672a57" +dependencies = [ + "once_cell", + "tokio", + "tokio-rustls", +] + +[[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.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +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.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.4", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-io", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +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.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "pin-project", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[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 = "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-actix-web" +version = "0.7.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36bb7a33ce7f0807d44124b5119ea3581fd59028db56477a7aa01741c869ca6a" +dependencies = [ + "actix-web", + "mutually_exclusive_features", + "pin-project", + "tracing", + "uuid", +] + +[[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", +] + +[[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-opentelemetry" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adbc64cba7137545b8044cb1fe9814f7aacf3c6b5f9b45be8bb5db538befdb26" +dependencies = [ + "js-sys", + "opentelemetry", + "tracing", + "tracing-core", + "tracing-subscriber", + "web-time", +] + +[[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 = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[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-id" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ba288e709927c043cbe476718d37be306be53fb1fafecd0dbe36d072be2580" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[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 = "unix-odbc" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26bdaf2156eebadc0dbabec5b2c2a6f92bff5cface28f3f0a367d2ee9aeca0e2" +dependencies = [ + "cc", +] + +[[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.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "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 = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[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 = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + +[[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 = "wasite" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" +dependencies = [ + "wasi 0.14.7+wasi-0.2.4", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +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 = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.8", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" +dependencies = [ + "libc", + "libredox", + "objc2-system-configuration", + "wasite", + "web-sys", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[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", +] + +[[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", +] + +[[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.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +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 = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "android-activity", + "atomic-waker", + "bitflags 2.13.0", + "block2", + "calloop", + "cfg_aliases", + "concurrent-queue", + "core-foundation 0.9.4", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "orbclient", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.0", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "yaml-rust2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "631a50d867fafb7093e709d75aaee9e0e0d5deb934021fcea25ac2fe09edc51e" +dependencies = [ + "arraydeque", + "encoding_rs", + "hashlink", +] + +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..bbda6de --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,110 @@ +[package] +name = "sqlpage" +version = "0.44.1" +edition = "2024" +description = "Build data user interfaces entirely in SQL. A web server that takes .sql files and formats the query result using pre-made configurable professional-looking components." +keywords = ["web", "sql", "framework"] +license = "MIT" +homepage = "https://sql-page.com/" +repository = "https://github.com/sqlpage/SQLPage" +documentation = "https://docs.rs/sqlpage" +include = ["/src", "/README.md", "/build.rs", "/sqlpage"] + +[profile.superoptimized] +inherits = "release" +strip = "debuginfo" +lto = "fat" +panic = "abort" +codegen-units = 2 + +[dependencies] +sqlx = { package = "sqlx-oldapi", version = "0.6.56", default-features = false, features = [ + "any", + "runtime-tokio-rustls", + "migrate", + "sqlite", + "postgres", + "mysql", + "mssql", + "odbc", + "chrono", + "bigdecimal", + "json", + "uuid", +] } +chrono = "0.4.23" +actix-web = { version = "4", features = ["rustls-0_23", "cookies"] } +percent-encoding = "2.2.0" +handlebars = "6.2.0" +log = "0.4.17" +mime_guess = "2.0.4" +futures-util = "0.3.21" +tokio = { version = "1.24.1", features = ["macros", "rt", "process", "sync"] } +tokio-stream = "0.1.9" +anyhow = "1" +serde = "1" +serde_json = { version = "1.0.82", features = [ + "preserve_order", + "raw_value", + "arbitrary_precision", +] } +lambda-web = { version = "0.2.1", features = ["actix4"], optional = true } +sqlparser = { version = "0.62.0", default-features = false, features = [ + "std", + "visitor", +] } +async-stream = "0.3" +async-trait = "0.1.61" +async-recursion = "1.0.0" +bigdecimal = { version = "0.4.8", features = ["serde-json"] } +include_dir = "0.7.2" +config = { version = "0.15.4", features = ["json"] } +markdown = { version = "1", features = ["log"] } +argon2 = "0.5.3" +actix-web-httpauth = "0.8.0" +rand = "0.10.0" +actix-multipart = "0.8.0" +base64 = "0.22" +hmac = "0.13" +sha2 = "0.11" +rustls-acme = "0.15" +dotenvy = "0.15.7" +csv-async = { version = "1.2.6", features = ["tokio"] } +rustls = { version = "0.23" } # keep in sync with actix-web, awc, rustls-acme, and sqlx +rustls-native-certs = "0.8.1" +awc = { version = "3", features = ["rustls-0_23-webpki-roots"] } +clap = { version = "4.5.17", features = ["derive"] } +tokio-util = "0.7.12" +openidconnect = { version = "4.0.0", default-features = false, features = ["accept-rfc3339-timestamps"] } +encoding_rs = "0.8.35" +odbc-sys = { version = "0", optional = true } +regex = "1" + +# OpenTelemetry / tracing +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["registry", "env-filter"] } +tracing-opentelemetry = { version = "0.33", default-features = false } +tracing-actix-web = { version = "0.7", default-features = false } +tracing-log = "0.2" +opentelemetry = { version = "0.32", default-features = false, features = ["trace", "metrics"] } +opentelemetry_sdk = { version = "0.32", default-features = false, features = ["metrics", "rt-tokio", "spec_unstable_metrics_views", "experimental_trace_batch_span_processor_with_async_runtime", "experimental_metrics_periodicreader_with_async_runtime"] } +opentelemetry-otlp = { version = "0.32", default-features = false, features = ["http-proto", "metrics"] } +opentelemetry-http = { version = "0.32", default-features = false } +opentelemetry-semantic-conventions = { version = "0.32", features = ["semconv_experimental"] } + + +[features] +default = [] +odbc-static = ["odbc-sys", "odbc-sys/vendored-unix-odbc"] +lambda-web = ["dep:lambda-web", "odbc-static"] + +[dev-dependencies] +actix-http = "3" +tokio = { version = "1", features = ["rt", "time", "test-util"] } + +[build-dependencies] +awc = { version = "3", features = ["rustls-0_23-webpki-roots"] } +rustls = "0.23" +actix-rt = "2.8" +libflate = "2" +futures-util = "0.3.21" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7684eab --- /dev/null +++ b/Dockerfile @@ -0,0 +1,71 @@ +FROM --platform=$BUILDPLATFORM rust:1.95-slim AS builder + +WORKDIR /usr/src/sqlpage +ARG TARGETARCH +ARG BUILDARCH +ARG CARGO_PROFILE=superoptimized +ENV CARGO_PROFILE=$CARGO_PROFILE + +COPY scripts/ /usr/local/bin/ +RUN cargo init . + +RUN /usr/local/bin/setup-cross-compilation.sh "$TARGETARCH" "$BUILDARCH" + +COPY .cargo/ .cargo/ +COPY Cargo.toml Cargo.lock build.rs ./ +COPY sqlpage/ sqlpage/ +RUN /usr/local/bin/build-dependencies.sh + +COPY . . +RUN /usr/local/bin/build-project.sh + +# Default minimal image (busybox-based) +FROM busybox:glibc AS minimal +RUN addgroup --gid 1000 --system sqlpage && \ + adduser --uid 1000 --system --no-create-home --ingroup sqlpage sqlpage && \ + mkdir -p /etc/sqlpage && \ + touch /etc/sqlpage/sqlpage.db && \ + chown -R sqlpage:sqlpage /etc/sqlpage/sqlpage.db +ENV SQLPAGE_WEB_ROOT=/var/www +ENV SQLPAGE_CONFIGURATION_DIRECTORY=/etc/sqlpage +WORKDIR /var/www +COPY --from=builder /usr/src/sqlpage/sqlpage.bin /usr/local/bin/sqlpage +# Provide runtime helper libs in system lib directory for the glibc busybox base +COPY --from=builder /tmp/sqlpage-libs/* /lib/ +USER sqlpage +COPY --from=builder --chown=sqlpage:sqlpage /usr/src/sqlpage/sqlpage/sqlpage.db sqlpage/sqlpage.db +EXPOSE 8080 +CMD ["/usr/local/bin/sqlpage"] + +# DuckDB ODBC image (debian-based with DuckDB ODBC driver) +FROM debian:trixie-slim AS duckdb + +ARG TARGETARCH +ENV SQLPAGE_WEB_ROOT=/var/www +ENV SQLPAGE_CONFIGURATION_DIRECTORY=/etc/sqlpage +ENV DATABASE_URL="Driver=/opt/duckdb_odbc/libduckdb_odbc.so;Database=/var/lib/sqlpage/duckdb.db" + +COPY scripts/install-duckdb-odbc.sh scripts/setup-sqlpage-user.sh /usr/local/bin/ + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + unzip \ + adduser \ + odbcinst \ + unixodbc \ + && /usr/local/bin/install-duckdb-odbc.sh "$TARGETARCH" \ + && apt-get purge -y --auto-remove curl unzip \ + && rm -rf /var/lib/apt/lists/* + +RUN /usr/local/bin/setup-sqlpage-user.sh + +COPY --from=builder /usr/src/sqlpage/sqlpage.bin /usr/local/bin/sqlpage + +USER sqlpage +WORKDIR /var/www +EXPOSE 8080 +CMD ["/usr/local/bin/sqlpage"] + +# Default stage +FROM minimal diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..3a61b4c --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Ophir LOJKINE + +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/README.md b/README.md new file mode 100644 index 0000000..8cd2c95 --- /dev/null +++ b/README.md @@ -0,0 +1,369 @@ +

+SQLpage +

+ +[![A short video explaining the concept of sqlpage](./docs/sqlpage.gif)](./docs/sqlpage.mp4) + +[SQLPage](https://sql-page.com) is an **SQL**-only webapp builder. +It allows building powerful data-centric user interfaces quickly, +by tranforming simple database queries into interactive websites. + +With SQLPage, you write simple `.sql` files containing queries to your database +to select, group, update, insert, and delete your data, and you get good-looking clean webpages +displaying your data as text, lists, grids, plots, and forms. + +## Examples + + + + + + + + + + + + + + + +
CodeResult
+ +```sql +SELECT + 'list' as component, + 'Popular websites' as title; +SELECT + name as title, + url as link, + CASE type + WHEN 1 THEN 'blue' + ELSE 'red' + END as color, + description, icon, active +FROM website; +``` + + + +![SQLPage list component](./docs/demo-list.png) + +
+ +```sql +SELECT + 'chart' as component, + 'Quarterly Revenue' as title, + 'area' as type; + +SELECT + quarter AS x, + SUM(revenue) AS y +FROM finances +GROUP BY quarter +``` + + + +![SQLPage list component](./docs/demo-graph.png) + +
+ +```sql +SELECT + 'form' as component, + 'User' as title, + 'Create new user' as validate; + +SELECT + name, type, placeholder, + required, description +FROM user_form; + +INSERT INTO user +SELECT $first_name, $last_name, $birth_date +WHERE $first_name IS NOT NULL; +``` + + + +![SQLPage list component](./docs/demo-form.png) + +
+ +```sql +select 'tab' as component, true as center; +select 'Show all cards' as title, '?' as link, + $tab is null as active; +select + format('Show %s cards', color) as title, + format('?tab=%s', color) as link, + $tab=color as active +from tab_example_cards +group by color; + + +select 'card' as component; +select + title, description, color + image_url as top_image, link +from tab_example_cards +where $tab is null or $tab = color; + +select + 'text' as component, + sqlpage.read_file_as_text('footer.md') as contents_md +``` + + + +![card component sql example](./docs/cards.png) + +
+ +## Supported databases + +- [SQLite](https://www.sqlite.org/index.html), including the ability to [load extensions](./configuration.md) such as *Spatialite*. +- [PostgreSQL](https://www.postgresql.org/), and other compatible databases such as *YugabyteDB*, *CockroachDB* and *Aurora*. +- [MySQL](https://www.mysql.com/), and other compatible databases such as *MariaDB* and *TiDB*. +- [Microsoft SQL Server](https://www.microsoft.com/en-us/sql-server), and all compatible databases and providers such as *Azure SQL* and *Amazon RDS*. +- Any **ODBC-compatible database**, such as +[*ClickHouse*](https://github.com/ClickHouse/clickhouse-odbc), +[*MongoDB*](https://www.mongodb.com/docs/atlas/data-federation/query/sql/drivers/odbc/connect), +[*DuckDB*](https://duckdb.org/docs/stable/clients/odbc/overview.html), +[*Oracle*](https://www.oracle.com/database/technologies/releasenote-odbc-ic.html), +[*Snowflake*](https://docs.snowflake.com/en/developer-guide/odbc/odbc), +[*BigQuery*](https://cloud.google.com/bigquery/docs/reference/odbc-jdbc-drivers), +[*IBM DB2*](https://www.ibm.com/support/pages/db2-odbc-cli-driver-download-and-installation-information), and many others through their respective ODBC drivers. + +## Get started + +[Read the official *get started* guide on SQLPage's website](https://sql-page.com/get_started.sql). + +### Using executables + +The easiest way to get started is to download the latest release from the +[releases page](https://github.com/sqlpage/SQLPage/releases). + +- Download the binary that corresponds to your operating system (linux, macos, or windows). +- Uncompress it: `tar -xzf sqlpage-*.tgz` +- Run it: `./sqlpage.bin` + +### With docker + +To run on a server, you can use [the docker image](https://hub.docker.com/r/lovasoa/SQLPage): + +- [Install docker](https://docs.docker.com/get-docker/) +- In a terminal, run the following command: + - `docker run -it --name sqlpage -p 8080:8080 --volume "$(pwd):/var/www" --rm lovasoa/sqlpage` + - (`"$(pwd):/var/www"` allows sqlpage to run sql files from your current working directory) +- Create a file called index.sql with the contents from [this example](./index.sql) +- Open https://localhost:8080 in your browser +- Optionally, you can also mount a directory containing sqlpage's configuration file, + custom components, and migrations + (see [configuration.md](./configuration.md)) to `/etc/sqlpage` in the container. + - For instance, you can use: + - `docker run -it --name sqlpage -p 80:8080 --volume "$(pwd)/source:/var/www" --volume "$(pwd)/configuration:/etc/sqlpage:ro" --rm lovasoa/sqlpage` + - And place your website in a folder named `source` and your `sqlpage.json` in a folder named `configuration`. +- If you want to build your own docker image, taking the raw sqlpage image as a base is not recommended, since it is extremely stripped down and probably won't contain the dependencies you need. Instead, you can take debian as a base and simply copy the sqlpage binary from the official image to your own image: + - ```Dockerfile + FROM debian:stable-slim + COPY --from=lovasoa/sqlpage:main /usr/local/bin/sqlpage /usr/local/bin/sqlpage + ``` + +We provide compiled binaries only for the x86_64 architecture, but provide docker images for other architectures, including arm64 and armv7. If you want to run SQLPage on a Raspberry Pi or +a cheaper ARM cloud instance, using the docker image is the easiest way to do it. + +### On Mac OS, with homebrew + +An alternative for Mac OS users is to use [SQLPage's homebrew package](https://formulae.brew.sh/formula/sqlpage). + +- [Install homebrew](https://brew.sh/) +- In a terminal, run the following commands: + - `brew install sqlpage` + + +### ODBC Setup + +SQLPage supports ODBC connections to connect to databases that don't have native drivers. +You can skip this section if you want to use one of the built-in database drivers (SQLite, PostgreSQL, MySQL, Microsoft SQL Server). + +Linux and MacOS release binaries conatain a built-in statically linked ODBC driver manager (unixODBC). +You still need to install or provide the database-specific ODBC driver for the database you want to connect to. + +#### Install your ODBC database driver + - [DuckDB](https://duckdb.org/docs/stable/clients/odbc/overview.html) + - If you use docker, a DuckDB-enabled image variant is available with pre-installed DuckDB ODBC drivers + - Use the `-duckdb` suffix: `lovasoa/sqlpage:main-duckdb` or `lovasoa/sqlpage:latest-duckdb` + - Comes pre-configured to connect to DuckDB at `/var/lib/sqlpage/duckdb.db` inside the container + - To customize [connection options](https://duckdb.org/docs/stable/clients/odbc/configuration), set `DATABASE_URL`: + - `docker run -e DATABASE_URL="Driver=DuckDB;Database=/path/to/your.db" -p 8080:8080 lovasoa/sqlpage:main-duckdb` + - To persist your DuckDB database, mount a volume: + - `docker run -v ./data:/var/lib/sqlpage lovasoa/sqlpage:main-duckdb` + - [Snowflake](https://docs.snowflake.com/en/developer-guide/odbc/odbc) + - [BigQuery](https://cloud.google.com/bigquery/docs/reference/odbc-jdbc-drivers) + - For other databases, follow your database's official odbc install instructions. + +#### Connect to your database + + - Find your [connection string](https://www.connectionstrings.com/). + - It will look like this: `Driver=/opt/snowflake_odbc/lib/libSnowflake.so;Server=xyz.snowflakecomputing.com;Database=MY_DB;Schema=PUBLIC;UID=my_user;PWD=my_password` + - It must reference the path to the database driver you installed earlier, plus any connection parameter required by the driver itself. Follow the instructions from the driver's own documentation. + - Use it in the [DATABASE_URL configuration option](./configuration.md) + + +## How it works + +![architecture diagram](./docs/architecture-detailed.png) + +SQLPage is a [web server](https://en.wikipedia.org/wiki/Web_server) written in +[rust](https://en.wikipedia.org/wiki/Rust_(programming_language)) +and distributed as a single executable file. +When it receives a request to a URL ending in `.sql`, it finds the corresponding +SQL file, runs it on the database, +passing it information from the web request as SQL statement parameters. +When the database starts returning rows for the query, +SQLPage maps each piece of information in the row to a parameter +in one of its pre-defined components' templates, and streams the result back +to the user's browser. + +## Examples + +- [TODO list](./examples/todo%20application/): a simple todo list application, illustrating how to create a basic CRUD application with SQLPage. +- [Plots, Tables, forms, and interactivity](./examples/plots%20tables%20and%20forms/): a short well-commented demo showing how to use plots, tables, forms, and interactivity to filter data based on an URL parameter. +- [Tiny splitwise clone](./examples/splitwise): a shared expense tracker app +- [Corporate Conundrum](./examples/corporate-conundrum/): a board game implemented in SQL +- [Master-Detail Forms](./examples/master-detail-forms/): shows how to implement a simple set of forms to insert data into database tables that have a one-to-many relationship. +- [SQLPage's own official website and documentation](./examples/official-site/): The SQL source code for the project's official site, https://sql-page.com +- [Image gallery](./examples/image%20gallery%20with%20user%20uploads/): An image gallery where users can log in and upload images. Illustrates the implementation of a user authentication system using session cookies, and the handling of file uploads. +- [User Management](./examples/user-authentication/): An authentication demo with user registration, log in, log out, and confidential pages. Uses PostgreSQL. +- [Making a JSON API and integrating React components in the frontend](./examples/using%20react%20and%20other%20custom%20scripts%20and%20styles/): Shows how to integrate a react component in a SQLPage website, and how to easily build a REST API with SQLPage. +- [Handling file uploads](./examples/image%20gallery%20with%20user%20uploads): An image gallery where authenticated users can publish new images via an upload form. +- [Bulk data import from CSV files](./examples/official-site/examples/handle_csv_upload.sql) : A simple form letting users import CSV files to fill a database table. +- [Advanced authentication example using PostgreSQL stored procedures](https://github.com/mnesarco/sqlpage_auth_example) +- [Complex web application in SQLite with user management, file uploads, plots, maps, tables, menus, ...](https://github.com/DSMejantel/Ecole_inclusive) +- [Single sign-on](./examples/single%20sign%20on): An example of how to implement OAuth and OpenID Connect (OIDC) authentication in SQLPage. The demo also includes a CAS (Central Authentication Service) client. +- [Dark theme](./examples/light-dark-toggle/) : demonstrates how to let the user toggle between a light theme and a dark theme, and store the user's preference. + +You can try all the examples online without installing anything on your computer using [SQLPage's online demo on replit](https://replit.com/@pimaj62145/SQLPage). + +## Configuration + +SQLPage can be configured through either a configuration file placed in `sqlpage/sqlpage.json` +or environment variables such as `DATABASE_URL` or `LISTEN_ON`. + +For more information, read [`configuration.md`](./configuration.md). + +Additionally, custom components can be created by placing [`.handlebars`](https://handlebarsjs.com/guide/) +files in `sqlpage/templates`. [Example](./sqlpage/templates/card.handlebars). + +### HTTPS + +SQLPage supports HTTP/2 and HTTPS natively and transparently. +Just set `SQLPAGE_HTTPS_DOMAIN=example.com`, and SQLPage +will automatically request a trusted certificate and +start encrypting all your user's traffic with it. +No tedious manual configuration for you, +and no annoying "Connection is Not Secure" messages for your users ! + +## Serverless + +You can run SQLpage [serverless](https://en.wikipedia.org/wiki/Serverless_computing) +by compiling it to an [AWS Lambda function](https://aws.amazon.com/lambda/). +An easy way to do so is using the provided docker image: + +```bash + docker build -t sqlpage-lambda-builder . -f lambda.Dockerfile --target builder + docker run sqlpage-lambda-builder cat deploy.zip > sqlpage-aws-lambda.zip +``` + +You can then just add your own SQL files to `sqlpage-aws-lambda.zip`, +and [upload it to AWS Lambda](https://docs.aws.amazon.com/lambda/latest/dg/gettingstarted-package.html#gettingstarted-package-zip), +selecting *Custom runtime on Amazon Linux 2* as a runtime. + +### Hosting sql files directly inside the database + +When running serverless, you can include the SQL files directly in the image that you are deploying. +But if you want to be able to update your sql files on the fly without creating a new image, +you can store the files directly inside the database, in a table that has the following structure: + +```sql +CREATE TABLE sqlpage_files( + path VARCHAR(255) NOT NULL PRIMARY KEY, + contents BLOB, + last_modified TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +``` + +Make sure to update `last_modified` every time you update the contents of a file (or do it inside a TRIGGER). +SQLPage will re-parse a file from the database only when it has been modified. + +## Technologies and libraries used + +- [actix web](https://actix.rs/) handles HTTP requests at an incredible speed, +- [tabler](https://preview.tabler.io) handles the styling for professional-looking clean components, +- [tabler icons](https://tabler-icons.io) is a large set of icons you can select directly from your SQL, +- [handlebars](https://handlebarsjs.com/guide/) render HTML pages from readable templates for each component. + +## Frequently Asked Questions + +> **Why use SQL instead of a real programming language? SQL isn't even [Turing-complete](https://en.wikipedia.org/wiki/Turing_completeness)!** + +- You're focusing on the wrong issue. If you can express your application declaratively, you should—whether using SQL or another language. Declarative code is often more concise, readable, easier to reason about, and easier to debug than imperative code. +- SQL is simpler than traditional languages, often readable by non-programmers, yet very powerful. +- If complexity is your goal, note that [SQL is actually Turing-complete](https://stackoverflow.com/questions/900055/is-sql-or-even-tsql-turing-complete/7580013#7580013). +- Even without recursive queries, a sequence of SQL statements driven by user interactions (like SQLPage) would still be Turing-complete, enabling you to build a SQL-powered website that functions as a Turing machine. + +> **Just Because You Can Doesn’t Mean You Should...** +— [someone being mean on Reddit](https://www.reddit.com/r/rust/comments/14qjskz/comment/jr506nx) + +It's not about "should" — it's about "why not?" +Keep coloring inside the lines if you want, but we'll be over here having fun with our SQL websites. + +> **Is this the same as Microsoft Access?** + +The goals are similar — creating simple data-centric applications — but the tools differ significantly: +- SQLPage is a web server, not a desktop app. +- SQLPage connects to existing robust relational databases; Access tries to **be** a database. +- Access is expensive and proprietary; SQLPage is [open-source](./LICENSE.txt). +- SQLPage spares you from the torment of [Visual Basic for Applications](https://en.wikipedia.org/wiki/Visual_Basic_for_Applications). + +> **Is the name a reference to Microsoft FrontPage?** + +FrontPage was a visual static website builder popular in the late '90s. I hadn't heard of it until someone asked. + +> **I like CSS. I want to design websites, not write SQL.** + +If you want to write your own HTML and CSS, +you can [create custom components](https://sql-page.com/custom_components.sql) +by adding a [`.handlebars`](https://handlebarsjs.com/guide/) file in `sqlpage/templates` and writing your HTML and CSS there. ([Example](./sqlpage/templates/alert.handlebars)). +You can also use the `html` component to write raw HTML, or the `shell` component to include custom scripts and styles. + +But SQLPage believes you shouldn't worry about button border radii until you have a working prototype. +We provide good-looking components out of the box so you can focus on your data model, and iterate quickly. + +## Download + +SQLPage is available for download on the from multiple sources: + +[![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/sqlpage/SQLPage/total?label=direct%20download)](https://github.com/sqlpage/SQLPage/releases/latest) +[![Docker Pulls](https://img.shields.io/docker/pulls/sqlpage/SQLPage?label=docker%3A%20lovasoa%2Fsqlpage)](https://hub.docker.com/r/sqlpage/SQLPage) +[![homebrew downloads](https://img.shields.io/homebrew/installs/dq/sqlpage?label=homebrew%20downloads&labelColor=%232e2a24&color=%23f9d094)](https://formulae.brew.sh/formula/sqlpage#default) +[![Scoop Version](https://img.shields.io/scoop/v/sqlpage?labelColor=%23696573&color=%23d7d4db)](https://scoop.sh/#/apps?q=sqlpage&id=305b3437817cd197058954a2f76ac1cf0e444116) +[![Crates.io Total Downloads](https://img.shields.io/crates/d/sqlpage?label=crates.io%20download&labelColor=%23264323&color=%23f9f7ec)](https://crates.io/crates/sqlpage) +[![](https://img.shields.io/badge/Nix-pkg-rgb(126,%20185,%20227))](https://search.nixos.org/packages?channel=unstable&show=sqlpage&from=0&size=50&sort=relevance&type=packages&query=sqlpage) + +## Contributing + +We welcome contributions! SQLPage is built with Rust and uses +vanilla javascript for its frontend parts. + +Check out our [Contributing Guide](./CONTRIBUTING.md) for detailed instructions on development setup, testing, and pull request process. + +# Code signing policy + +Our windows binaries are digitally signed, so they should be recognized as safe by Windows. +Free code signing provided by [SignPath.io](https://about.signpath.io/), certificate by [SignPath Foundation](https://signpath.org/). [Contributors](https://github.com/sqlpage/SQLPage/graphs/contributors), [Owners](https://github.com/orgs/sqlpage/people?query=role%3Aowner). + +This program will not transfer any information to other networked systems unless specifically requested by the user or the person installing or operating it diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..bc32a0a --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`sqlpage/SQLPage` +- 原始仓库:https://github.com/sqlpage/SQLPage +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..5c25f98 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,190 @@ +# Security Policy + +## Reporting a Vulnerability + +Please report suspected SQLPage vulnerabilities privately to +`contact@ophir.dev`. + +Include the SQLPage version or commit, database backend, relevant +configuration, a minimal SQL file if possible, and the exact attacker-controlled +input. Do not open a public issue for a non-public vulnerability. + +## Threat Model + +SQLPage is a runtime for applications written in SQL. It maps HTTP requests to +SQL files, executes those files on the configured database, and renders the +result. SQLPage is not a sandbox for SQLPage application authors or operators. + +### Trusted Inputs + +SQLPage trusts the application and deployment: + +- application files under `web_root`; +- application files stored in the optional `sqlpage_files` table; +- configuration, command-line arguments, environment variables, and `.env` + files; +- templates, migrations, and connection-management SQL in the configuration + directory; +- database behavior and access: roles, permissions, schema, extensions, + triggers, stored procedures, views, and migrations; +- anyone or anything that can modify one of the above. + +Control of trusted inputs is control of the SQLPage application. If an attacker +can edit `sqlpage.json`, alter templates, change OIDC path rules, enable +dangerous Markdown options, enable `allow_exec`, or write SQL into +`sqlpage_files`, that is outside SQLPage's vulnerability boundary unless +SQLPage itself granted that access to an untrusted actor. + +### Untrusted Inputs + +SQLPage does not trust remote inputs: + +- HTTP paths, query strings, form fields, request bodies, and multipart uploads; +- uploaded filenames, MIME types, and file contents; +- HTTP headers, cookies, Basic Auth credentials, and unauthenticated OIDC + callback parameters; +- responses from remote servers contacted with `sqlpage.fetch` or + `sqlpage.fetch_with_meta`. + +Database row values are not classified globally as trusted or untrusted. +SQLPage cannot know whether a row came from an administrator-maintained table, +user-generated content, a trigger, or a third-party import. The security +boundary depends on where trusted SQL places the value. + +### Data Positions and Instruction Positions + +Trusted SQL chooses whether a value is ordinary data or an instruction to +SQLPage. + +Data positions are values SQLPage should render, serialize, encode, or pass as +bound database parameters without giving them extra authority. Examples include +ordinary table cells, text fields, JSON response data, CSV cells, and safe +Markdown. + +Instruction positions are values SQLPage intentionally treats as application +instructions or capability arguments. Examples include: + +- component names; +- `dynamic` component properties; +- response status codes, headers, redirects, cookies, and downloads; +- file paths passed to `run_sql`, `read_file_as_text`, or + `read_file_as_data_url`; +- URLs and request options passed to `fetch` or `fetch_with_meta`; +- raw HTML and unsafe Markdown; +- command names and arguments passed to `exec` when `allow_exec` is enabled. + +Placing a database value in an instruction position is an application decision. +For example, `select sqlpage.read_file_as_text(f) from trusted_table` is allowed +by design. If someone who can modify `trusted_table` can read arbitrary files, +that is a problem in the application or database permissions, not SQLPage. + +SQLPage's responsibility is to enforce the documented meaning and guardrails of +each position: data positions must not become instructions, instruction +positions must not bypass their documented checks, and malformed values must not +crash SQLPage or escape into unrelated capabilities. + +## In Scope + +Please report cases where SQLPage itself crosses that boundary. Examples: + +- An HTTP request can execute attacker-chosen SQL without trusted SQL explicitly + exposing that behavior. +- SQLPage parameter handling turns `$name`, `:name`, or `?name` into executable + SQL instead of a bound database value. +- A value in a data position causes SQL execution, command execution, host file + access, response-header injection, unsafe HTML execution, or another + instruction-position effect. +- A database value in any position reliably crashes or panics SQLPage instead + of producing a response or application-level error. +- HTTP routing, path decoding, static file serving, caching, `run_sql`, or file + functions expose host files that trusted SQL or configuration did not select. +- Reserved private files, including the `sqlpage/` prefix, dotfiles, + templates, and configuration, are reachable over HTTP. +- `allow_exec` is false, but an attacker can execute a local command through + SQLPage. +- Built-in OIDC handling accepts forged, expired, wrong-issuer, + wrong-audience, wrong-nonce, or wrong-signature tokens, or applies configured + public/protected path rules incorrectly. +- Default-safe rendering or safe Markdown executes browser script. +- SQLPage-generated production error responses expose source code, stack + traces, SQL text, parameters, environment values, or configuration values. +- Upload handling allows path traversal, overwrite of unintended files, or file + disclosure without trusted SQL selecting that behavior. +- Official SQLPage documentation or examples recommend placing untrusted remote + input into an instruction position without validation. + +## Out of Scope + +The following are usually application or deployment vulnerabilities, not +SQLPage vulnerabilities: + +- Trusted SQL omits authentication or authorization checks. +- Trusted SQL, a stored procedure, trigger, view, or extension builds and + executes SQL from untrusted data. +- Trusted SQL selects a value into an instruction position such as `component`, + `dynamic.properties`, a redirect target, header value, file path, `run_sql` + target, `fetch` URL, raw HTML, unsafe Markdown, or `exec` argument. +- Trusted SQL intentionally reads a host file, including an absolute path, and + returns it to a client. +- An operator intentionally changes configuration to expose files, trust a + different database, make an OIDC path public, weaken CSP, enable dangerous + Markdown options, load SQLite extensions, or enable `allow_exec`. +- A symlink placed under `web_root` exposes its target. SQLPage follows + symlinks during static file serving, so operators must not create symlinks + under `web_root` that point to reserved or private files, such as the + `sqlpage/` configuration directory or dotfiles, or to files outside + `web_root`, since those targets would then be publicly reachable over HTTP. +- An attacker can modify SQL files, templates, configuration, environment + variables, migrations, database code, or `sqlpage_files`. +- The configured database role has broader permissions than the application + needs. +- A SQLPage application is publicly reachable because no authentication was + configured. +- An attacker can plant or overwrite cookies for the SQLPage origin (for + example through a compromised subdomain, a sibling application on a shared + parent domain, or a man-in-the-middle on plain HTTP). Attacks that depend on + injecting attacker-chosen cookies into the victim's browser, such as OIDC + login CSRF or session fixation via a forged login-flow-state cookie, are out + of scope. SQLPage assumes its origin's cookie jar is writable only by the + user agent, not by attackers. +- Trusted SQL asks SQLPage or the database to perform expensive work. + +These may still be serious and should be fixed in the affected application, +deployment, or documentation. + +## Boundary Examples + +Report: `/x.sql?sort=...` causes SQLPage to execute attacker-chosen SQL because +SQLPage rewrote a parameter incorrectly. + +Do not report as SQLPage: `x.sql` passes `sort` to a stored procedure that +concatenates it into dynamic SQL. + +Report: a normal table cell containing ` + {{#each (to_array javascript)}} + {{#if this}} + + {{/if}} + {{/each}} + + + + {{#if norobot}} + + {{/if}} + + {{#if refresh}} + + {{/if}} + {{#if rss}} + + {{/if}} + + + {{#if social_image}} + + {{/if}} + + + +
+ {{#if title}} +
+ +
+ {{/if}} +
+ {{~#each_row~}}{{~/each_row~}} +
+
+
+ {{#if footer}} + {{{markdown footer}}} + {{else}} + + Built with SQLPage + {{/if}} +
+ + diff --git a/examples/CRUD - Authentication/test.hurl b/examples/CRUD - Authentication/test.hurl new file mode 100644 index 0000000..dd496aa --- /dev/null +++ b/examples/CRUD - Authentication/test.hurl @@ -0,0 +1,135 @@ +# Public homepage is reachable without a session. +GET http://localhost:8080 +HTTP 200 +[Asserts] +header "Content-Type" contains "text/html" +body contains "Demo/Template CRUD with Authentication" +body not contains "An error occurred" + +# The currencies CRUD page is protected. +GET http://localhost:8080/currencies_list.sql +HTTP 302 +[Asserts] +header "Location" == "/login.sql?path=/currencies_list.sql" + +GET http://localhost:8080/login.sql?path=/currencies_list.sql +HTTP 200 +[Asserts] +body contains "Login" +body contains "Username" +body contains "Password" +body not contains "An error occurred" + +POST http://localhost:8080/create_session.sql?path=/currencies_list.sql +[FormParams] +username: admin +password: wrong +HTTP 302 +[Asserts] +header "Location" contains "login.sql" +header "Location" contains "error=1" + +GET http://localhost:8080/currencies_list.sql +HTTP 302 +[Asserts] +header "Location" == "/login.sql?path=/currencies_list.sql" + +# Demo credentials from the example migration: admin/admin. +POST http://localhost:8080/create_session.sql?path=/currencies_list.sql +[FormParams] +username: admin +password: admin +HTTP 302 +[Asserts] +header "Set-Cookie" contains "session_token=" +header "Location" == "/currencies_list.sql" + +# Hurl's cookie store reuses the session_token set above. +GET http://localhost:8080/currencies_list.sql +HTTP 200 +[Asserts] +body contains "Currencies" +body contains "New Record" +body contains "RUR" +body contains "USD" +body not contains "An error occurred" + +GET http://localhost:8080/currencies_item_form.sql?id=1 +HTTP 200 +[Asserts] +body contains "Currency" +body contains "Exchange Rate to RUR" +body contains "RUR" +body not contains "An error occurred" + +GET http://localhost:8080/currencies_item_form.sql?action=INSERT +HTTP 200 +[Asserts] +body contains "Currency" +body contains "Exchange Rate to RUR" +body contains "BROWSE" +body not contains "An error occurred" + +POST http://localhost:8080/currencies_item_dml.sql?path=currencies_list.sql&action=UPDATE +[FormParams] +id: +name: HURLTEST +to_rub: 7.89 +HTTP 302 +[Captures] +currency_id: header "Location" regex "id=(\\d+)" +[Asserts] +header "Location" contains "currencies_list.sql" +header "Location" contains "info=INSERT" + +GET http://localhost:8080/currencies_list.sql +HTTP 200 +[Asserts] +body contains "HURLTEST" +body contains "7.89" +body htmlUnescape contains "currencies_item_form.sql?&path=/currencies_list.sql&id={{currency_id}}" +body not contains "An error occurred" + +POST http://localhost:8080/currencies_item_dml.sql?path=currencies_list.sql&action=UPDATE +[FormParams] +id: {{currency_id}} +name: HURLEDITED +to_rub: 8.91 +HTTP 302 +[Asserts] +header "Location" contains "currencies_list.sql" +header "Location" contains "id={{currency_id}}" +header "Location" contains "info=UPDATE" + +GET http://localhost:8080/currencies_item_form.sql?id={{currency_id}} +HTTP 200 +[Asserts] +body contains "HURLEDITED" +body contains "8.91" +body not contains "An error occurred" + +POST http://localhost:8080/currencies_item_dml.sql?path=currencies_list.sql&action=DELETE +[FormParams] +id: {{currency_id}} +name: HURLEDITED +to_rub: 8.91 +HTTP 302 +[Asserts] +header "Location" contains "currencies_list.sql" +header "Location" contains "info=DELETE" + +GET http://localhost:8080/currencies_list.sql +HTTP 200 +[Asserts] +body not contains "HURLEDITED" +body not contains "An error occurred" + +GET http://localhost:8080/logout.sql?path=currencies_list.sql +HTTP 302 +[Asserts] +header "Location" == "currencies_list.sql" + +GET http://localhost:8080/currencies_list.sql +HTTP 302 +[Asserts] +header "Location" == "/login.sql?path=/currencies_list.sql" diff --git a/examples/CRUD - Authentication/www/README.md b/examples/CRUD - Authentication/www/README.md new file mode 100644 index 0000000..81a59bb --- /dev/null +++ b/examples/CRUD - Authentication/www/README.md @@ -0,0 +1,292 @@ +This demo/template defines a basic CRUD application with authentication designed for use with SQLite. The primary goal of this demo is to explore some of the SQLPage features and figure out how to implement various aspects of a data-centric application. The template contains both public pages ([**SQLite Introspection**](intro.sql)) and pages requiring authentication. A user management GUI is not available (database migrations, included in the project, create a default login admin/admin). + +The website root is set to _./www_, and the database is in the _./db/_ directory. The database contains one data table, "currencies", and its content is listed on a login-protected [**page**](currencies_list.sql). After successful authentication, you can see the list of records and access the form for adding/editing/deleting records. + +## Authentication process + +Three files (login.sql, logout.sql, and create_session.sql) implement authentication mostly following the code provided in other examples. The login.sql defines the actual login form, and the two other files do not have any associated GUI but perform appropriate processing and redirect to designated targets. Given a protected page, the general authentication flow is as follows. + +1. The user attempts to open a protected page (e.g., currencies_list.sql) +2. Session checking code snippet at the top of the protected page checks if a valid session token (cookie) is set. In this example, the SET statement sets a local variable, `$_username`, for later use: +```sql +-- Checks if a valid session token cookie is available +set _username = ( + SELECT username + FROM sessions + WHERE sqlpage.cookie('session_token') = id + AND created_at > datetime('now', '-1 day') +); +``` +3. Redirect to login page (login.sql) if no session is available (`$_username IS NULL`) and the starting page requires authentication (by setting `set _session_required = 1;` before executing the session checking code; see, e.g., the top of currencies_item_form.sql and currencies_list.sql): +```sql +SELECT + 'redirect' AS component, + '/login.sql?path=' || $_curpath AS link +WHERE $_username IS NULL AND $_session_required; +``` +4. The login page renders the login form, accepts the user credentials, and redirects to create_session.sql, passing the login credentials as POST variables. +5. create_session.sql checks credentials. If this check fails, it redirects back to the login form. If the check succeeds, it generates a session token and performs the final redirect. + +## Header module + +### Controlling execution of parts in a loaded script + +Because the same code is used for session token check for all protected pages, it makes sense to place it in a separate module (header_shell_session.sql) and execute it via run_sql() at the top of protected files: + +```sql +set _curpath = sqlpage.path(); +set _session_required = 1; + +SELECT + 'dynamic' AS component, + sqlpage.run_sql('header_shell_session.sql') AS properties; +``` + +The second line above sets the local variable $\_session_required, which indicates whether authentication is required for a particular page. This variable, like the GET/POST variables, is then accessible to the loaded "header" module header_shell_session.sql. This way, if other common code is placed in the header module, it can be executed by a non-protected page while skipping the authentication part (by setting $\_session_required = 0, which prevents redirect to the login form even if no valid session token is available). + +Another "filter" variable (`$_shell_enabled`) controls the execution of another section in the header module, as discussed below. + +### Tracking the calling page + +The first line above sets another useful variable $\_curpath, which makes it possible to redirect back to the starting page after the authentication process is completed, rather than redirecting to the front page. The loaded header module has access to this variable as well, and if a login redirect is required, this variable is passed alone as a GET URL parameter (as a part of the "link" property): + +```sql +SELECT + 'redirect' AS component, + '/login.sql?path=' || $_curpath AS link +WHERE $_username IS NULL AND $_session_required; +``` + +The login form passes it further in a similar fashion to the create_session.sql script (as part of the "action" property): + +```sql +SELECT + 'form' AS component, + 'Login' AS title, + 'create_session.sql' || ifnull('?path=' || $path, '') AS action; +``` +If authentication fails, create_session.sql redirects back to login.sql and makes sure to pass the $path value alone (as a part of the "link" property): + +```sql +SELECT + 'authentication' AS component, + 'login.sql?' || ifnull('path=' || $path, '') || '&error=1' AS link, + :password AS password, + (SELECT password_hash + FROM "accounts" + WHERE username = :username) AS password_hash; +``` + +If authentication succeeds, create_session.sql redirects back to starting page using the $path value as the final redirect target: + +```sql +SELECT + 'redirect' AS component, + ifnull($path, '/') AS link; +``` + +### Adding User/Login/Logout buttons to the page menu + +It is customary to show the "User Profile" button in the top right corner when the user is authenticated. Also, it is customary to show the "Logout" button next to the "User Profile" button or the "Login" button when no active session is available. The associated code is common to all pages, and it makes sense to place it in the same header module. + +The "shell" component is responsible for constructing the top menu, but the standard component does not support menu buttons. The simplest solution to this "limitation" is to modify the standard shell.handlebars template found in the "sqlpage/templates" directory of the SQLPage source code repository and place it inside the project "sqlpage/templates" directory. + +To extend the "shell" component with button items in the menu, I have added a hybrid section of code mostly constructed from template code defining menu items and the "button" component. In the present implementation, menu buttons are defined as a JSON array value to the "menu_buttons" property. Each array member is a JSON object defining a single button and may include "shape", "color", "size", "outline", "link", "tooltip", and "title" properties (see description of these properties in the official "button" component docs. + +Note how the `$_curpath` variable, which is set in core page modules (such as currencies_list.sql) is used to define links for the Login/Logout buttons. These links are irrelevant for protected pages, but for non-protected pages, such as intro.sql, these links make sure that the user remains on the same page after he/she presses on Logout/Login buttons (and completes authentication in the latter case). + +The `$_username` variable set during the authentication process is then used to decide which buttons (Login or User/Logout) should be shown. + +The `$_shell_enabled` variable controls the execution of the custom shell component. This feature is necessary because the header module is also loaded by the currencies_item_dml.sql module, which should only be accessible to authenticated users. However, the currencies_item_dml.sql module is a no-GUI module, which performs database operations and uses redirects after the requested operations are completed. At the same time, if the loaded header module executes the custom shell component, generating GUI buttons, the redirection mechanism in currencies_item_dml.sql will fail. + +### Required variable guards + +The header modules expects that the calling module sets several variables. The SET statement makes it possible to check if the variables are set appropriately in one place at the beginning of the module, rather then placing guards every time theses variables are used. Hence, the top section of the header file includes + +```sql +set _curpath = ifnull($_curpath, '/'); +set _session_required = ifnull($_session_required, 1); +set _shell_enabled = ifnull($_shell_enabled, 1); +``` +In this case, if any required variable is not set, a suitable default value is defined, so that the following code would not have to check for NULL values. Alternatively, a redirect to an error page may be used, to inform the programmer about the potential issue. + +## Footer module - debug information + +POST/GET/SET variables may provide helpful information for debugging purposes. In earlier [post](https://reddit.com/r/SQLpage/comments/1dh1siw/structuring_code_showing_debug_info/), I described the code I use to output variables in a convenient way. Briefly, I use `sqlpage.variables('GET')` and `sqlpage.variables('POST')` to get all variables, and I distinguish between the GET variables and local SET variables by prefixing SET variable names with an underscore. Initially, I copy-pasted the code snippets at the bottom of pages, but later I moved it to a separate file, footer_debug_post-get-set.sql, which I load via + +```sql +SELECT + 'dynamic' AS component, + sqlpage.run_sql('footer_debug_post-get-set.sql') AS properties +WHERE $DEBUG OR $error IS NOT NULL; +``` +## Structuring code modules + +The "currencies" table is handled by three modules: + +- "table" view - __currencies_list.sql__ + Displays the entire table using the powerful "table" component. One way to extend this module is, possibly, to hide certain less important columns, especially for wide tables. +- "detail" view - __currencies_item_form.sql__ + This is the "detail" view. It shows all fields for a single record. In this case, it is an "editable" form, though the fields maybe made conditionally read-only. Another possible option for a read-only detail view is to use the "datagrid" component. +- database DML processor - __currencies_item_dml.sql__ + This is a no-GUI module, which only processes database modification operations using data submitted to the currencies_item_form.sql form. Presently, all DML statements (INSERT/UPDATE/DELETE) are processed by this module. If necessary, this module maybe split into more specialized modules. + +Let us briefly go over the code block in these modules. + +### Debug information (bottom section) + +All three module load the footer module discussed above that produces a conditional output of GET/POST/SET variables. + +### Authentication (top section) + +All three modules provide access to the database and are treated as protected: they are only accessible to authenticated users. Hence, they start with (mostly) the same code block: + +```sql +set _curpath = sqlpage.path(); +set _session_required = 1; + +SELECT + 'dynamic' AS component, + sqlpage.run_sql('header_shell_session.sql') AS properties; +``` + +This code discussed above sets the current path variable (necessary for correct redirects), authentication flag before loading the header module that takes care of authentication and common settings, such as top menu buttons. + +The "no-GUI" currencies_item_dml.sql module does not set `$_curpath`, since it cannot be a start/end point in a redirect chain, but it sets the `$_shell_enabled` flag to suppress top menu buttons generation, as discussed earlier. + +### Common variables + +The second section may generally be used to set additional common variables, such as the name of the "table" view inside the "detail" view and the other way around (to switch between the two). + +The "detail" view also uses the "&path" GET URL parameter, if provided (e.g., by the "table" view). This way, if a record is modified/deleted starting from, e.g., the "table" view, the same view is set as the final redirect target after the DML operation is completed. + +### Table view + +The rest of the table view module is fairly basic. It defines two alerts for displaying confirmation and error messages, a "new record" button, and the table itself. The last "actions" column is added to the table, designated as markdown, and includes shortcuts to edit/delete the corresponding record. + +![](https://raw.github.com/sqlpage/SQLPage/crud_auth/examples/CRUD%20-%20Authentication/www/img/table_view.png) + +### Detail view + +The detail view module is more elaborate. If "&id" GET URL parameter is provided, the form shows the corresponding record. Otherwise, the ID field is rendered as a dropdown list populated from the database, but is set to NULL. The remaining fields are either blank or contain dummy values. + +The first step (after the previously discussed common sections), therefore, is to filter invalid id values. + +```sql +SELECT + 'redirect' AS component, + $_curpath AS link +WHERE $id = '' OR CAST($id AS INT) = 0; + +set error_msg = sqlpage.url_encode('Bad {id = ' || $id || '} provided'); +SELECT + 'redirect' AS component, + $_curpath || '?error=' || $error_msg AS link +WHERE $id NOT IN (SELECT currencies.id FROM currencies); +``` + +The blank string and zero are considered the equivalents of NULL, so redirect to itself is activated, removing the id parameter. If no id is provided or id is set to an integer value, the first check does not trigger. The second check above triggers when there is no record with provided id. This check resets id and displays an error message. + +Another accepted GET URL parameter is $values, which may be set to a JSON representation of the record. This parameter is returned from the currencies_item_dml.sql script if the database operation fails. Then the detail view will display an error message, but the form will remain populated with the user-submitted data. If $values is set, it takes precedence. This check throws an error if $values is set, but does not represent a valid JSON. + +```sql +set _err_msg = + sqlpage.url_encode('Values is set to bad JSON: __ ') || $values || ' __'; + +SELECT + 'redirect' AS component, + $_curpath || '?error=' || $_err_msg AS link +WHERE NOT json_valid($values); +``` +The detail view maybe called with zero, one, or two (\$id/\$values) parameters. Invalid values are filtered out at this point, so the next step is to check provided parameters and determine the dataset that should go into the form. + +```sql +set _values = ( + WITH + fields AS ( + SELECT id, name, to_rub + FROM currencies + WHERE id = CAST($id AS INT) AND $values IS NULL + UNION ALL + SELECT NULL, '@', 1 + WHERE $id IS NULL AND $values IS NULL + UNION ALL + SELECT + $values ->> '$.id' AS id, + $values ->> '$.name' AS name, + $values ->> '$.to_rub' AS to_rub + WHERE json_valid($values) + ) + SELECT + json_object( + 'id', CAST(fields.id AS INT), + 'name', fields.name, + 'to_rub', CAST(CAST(fields.to_rub AS TEXT) AS NUMERIC) + ) + FROM fields +); +``` + +Each of the three united SELECTs in the "fields" CTE returns a single row and only one of them is selected for any given combination of \$id/\$values using the WHERE clauses. This query returns the "final" set of fields as a JSON object. + +![](https://raw.github.com/sqlpage/SQLPage/crud_auth/examples/CRUD%20-%20Authentication/www/img/detail_view.png) + +Now that the input parameters are validated and the "final" dataset is determined, it is the time to define the form GUI elements. First, I define the button to switch to the table view. Note that the same form is used to confirm record deletion, and when this happens, the "Browse" button is not shown. + +```sql +SELECT + 'button' AS component, + 'pill' AS shape, + 'lg' AS size, + 'end' AS justify; +SELECT + 'BROWSE' AS title, + 'browse_rec' AS id, + 'corner-down-left' AS icon, + 'corner-down-left' AS icon_after, + 'green' AS outline, + $_table_list AS link, + 'Browse full table' AS tooltip +WHERE NOT ifnull($action = 'DELETE', FALSE); +``` + +The following section defines the main form with record fields. First the $\_valid_ids variable is constructed as the source for the drop-down id field. The code also adds the NULL value used for defining a new record. Note that, when this form is opened from the table view via the "New Record" button, the $action variable is set to "INSERT" and the id field is set to the empty array in the first assignment via the alternative UINION and to the single NULL in the second assignment. The two queries can also be combined relatively straightforwardly using CTEs. + +```sql +set _valid_ids = ( + SELECT json_group_array( + json_object('label', CAST(id AS TEXT), 'value', id) ORDER BY id + ) + FROM currencies + WHERE ifnull($action, '') <> 'INSERT' + UNION ALL + SELECT '[]' + WHERE $action = 'INSERT' +); +set _valid_ids = ( + json_insert($_valid_ids, '$[#]', + json_object('label', 'NULL', 'value', json('null')) + ) +); +``` + +The next part defines form fields via the "dynamic" component (for some reason I am having issues with POST variables when the form is defined directly via the "form" component. Note how the $values variable prepared in previous blocks is used to populate the form. Without the SET statement, everything would need to be incorporated in a single query (which is feasible thanks to CTEs, but would still be significantly more difficult to develop and maintain). + +Also note that this single form definition actually combines two forms (the second being the record delete confirmation form). If the $action variable is set to "DELETE" (after the delete operation is initiated from either the table or detail view), buttons are adjusted appropriately and all fields are set to read-only. Whether this is a good design is a separate question. Perhaps, defining two separate forms is a better approach. + +![](https://raw.github.com/sqlpage/SQLPage/crud_auth/examples/CRUD%20-%20Authentication/www/img/delete_confirmation.png) + + +After the main form fields goes the delete confirmation alert, displayed after the delete operation is completed. + +The last big section defines the main form buttons, which are adjusted based on the type of operation (similarly to the form fields above). + +The final section includes a general confirmation alert (used after INSERT/UPDATE operations) and an error alert. + +### Coding style conventions + +Consistent code style is important for code readability. Because SQLPage module maybe a mix of SQL code and sizeable text fragments, which may contain plain text, Markdown, JSON, HTML, etc., it might be difficult to follow a fixed set of rules. In fact, dynamically generated webpages regardless of specific technologies used tend to get messy. At the very least I strive to + + - keep all SQL keywords always in the UPPER case, + - have reasonably sensible code alignment (though some alignment approaches may not be generally advisable) + - keep large static text pieces in separate appropriate dedicated files and load them via `sqlpage.read_file_as_text()` (e.g., the text of this file comes from Readme.md, where it can be properly edited by any Markdown editor and version-controlled; similarly, static JSON should go in \*.json files or in a dedicated database table with a designated JSON column). diff --git a/examples/CRUD - Authentication/www/create_session.sql b/examples/CRUD - Authentication/www/create_session.sql new file mode 100644 index 0000000..87f828b --- /dev/null +++ b/examples/CRUD - Authentication/www/create_session.sql @@ -0,0 +1,27 @@ +-- Redirect to the login page if the password is not correct + +SELECT + 'authentication' AS component, + 'login.sql?' || ifnull('path=' || sqlpage.url_encode($path), '') || '&error=1' AS link, + :password AS password, + (SELECT password_hash + FROM accounts + WHERE username = :username) AS password_hash; + +-- The code after this point is only executed if the user has sent the correct password +-- Generate a random session token and set via the "cookie" component in the RETURNING +-- clause. + +INSERT INTO sessions (id, username) +VALUES (sqlpage.random_string(32), :username) +RETURNING + 'cookie' AS component, + 'session_token' AS name, + id AS value; + +-- The user browser will now have a cookie named `session_token` that we can check later +-- to see if the user is logged in. + +SELECT + 'redirect' AS component, + ifnull($path, '/') AS link; diff --git a/examples/CRUD - Authentication/www/css/prism-tabler-theme.css b/examples/CRUD - Authentication/www/css/prism-tabler-theme.css new file mode 100644 index 0000000..930a5f0 --- /dev/null +++ b/examples/CRUD - Authentication/www/css/prism-tabler-theme.css @@ -0,0 +1,92 @@ +.token.comment, +.token.prolog, +.token.doctype, +.token.cdata { + color: var(--tblr-gray-300); +} + +.token.punctuation { + color: var(--tblr-gray-500); +} + +.namespace { + opacity: 0.7; +} + +.token.property, +.token.tag { + color: #f92672; + + /* We need to reset the 'tag' styles set by tabler */ + border: 0; + display: inherit; + height: inherit; + border-radius: inherit; + padding: 0; + background: inherit; + box-shadow: inherit; +} + +.token.number { + color: #ea9999; +} + +.token.boolean { + color: #ae81ff; +} + +.token.selector, +.token.attr-name, +.token.string { + color: #97e1a3; +} + +.token.operator, +.token.entity, +.token.url, +.language-css .token.string, +.style .token.string { + color: #f8f8f2; +} + +.token.atrule, +.token.attr-value { + color: #e6db74; +} + +.token.keyword { + color: #95d1ff; +} + +.token.regex, +.token.important { + color: var(--tblr-yellow); +} + +.token.important { + font-weight: bold; +} + +.token.entity { + cursor: help; +} + +.token { + transition: 0.3s; +} + +code::selection, +code ::selection { + background: var(--tblr-yellow); + color: var(--tblr-gray-900); + border-radius: 0.1em; +} + +code .token.keyword::selection, +code .token.punctuation::selection { + color: var(--tblr-gray-800); +} + +pre code { + padding: 0; +} diff --git a/examples/CRUD - Authentication/www/css/style.css b/examples/CRUD - Authentication/www/css/style.css new file mode 100644 index 0000000..a6bace3 --- /dev/null +++ b/examples/CRUD - Authentication/www/css/style.css @@ -0,0 +1,23 @@ +.menu_options_slim { + min-width: inherit !important; +} + +.menu_language_slim { + min-width: inherit !important; +} + +.menu_language { + min-width: 200%; +} + +div.dropdown-menu- { + min-width: inherit !important; +} + +a.dropdown-item- { + min-width: inherit !important; +} + +.slim_item { + min-width: inherit !important; +} diff --git a/examples/CRUD - Authentication/www/currencies_item_dml.sql b/examples/CRUD - Authentication/www/currencies_item_dml.sql new file mode 100644 index 0000000..9f6f189 --- /dev/null +++ b/examples/CRUD - Authentication/www/currencies_item_dml.sql @@ -0,0 +1,104 @@ +-- Procesess CREATE/UPDATE/DELETE operations. + +-- ============================================================================= +-- =========================== Module Setting ================================== +-- =========================== Login / Logout ================================== +-- ============================================================================= + +-- $_curpath and $_session_required are required for header_shell_session.sql. + +set _session_required = 1; +set _shell_enabled = 0; + +SELECT + 'dynamic' AS component, + sqlpage.run_sql('header_shell_session.sql') AS properties; + +-- ============================================================================= +-- Redirect target must be passed as $path +-- ============================================================================= + +set _err_msg = '&path URL GET parameter (redirect target) is not set!'; + +SELECT + 'alert' AS component, + 'red' AS color, + 'alert-triangle' AS icon, + 'Error' AS title, + $_err_msg AS description, + TRUE AS dismissible +WHERE + $path IS NULL AND $DEBUG IS NULL; + +-- ============================================================================= +-- Check new values for validity: +-- - UPDATE existing record: +-- :id IS NOT NULL +-- If :name is in the database, :id must match +-- If attempting to change :name, operation may fail due to FK constraint +-- - INSERT new record: +-- :id IS NULL +-- :name is not in the database +-- ============================================================================= + +-- Pass new values back as JSON object in $values GET variable for form population. +-- +-- For new records, the id (INTEGER PRIMARY KEY AUTOINCREMENT) should be set to NULL. +-- The id field is set as hidden in the record edit form and passed as the :id POST +-- variable. NULL, however, cannot be passed as such and is converted to blank string. +-- Check :id for '' and set id (:id will return the same value). + +set _id = iif(typeof(:id) = 'text' AND :id = '', NULL, :id); + +set _values = json_object( + 'id', CAST($_id AS INT), + 'name', :name, + 'to_rub', CAST(:to_rub AS NUMERIC) +); + +set _op = iif($_id IS NULL, 'INSERT', 'UPDATE'); +set _err_msg = sqlpage.url_encode('New currency already in the database'); + +SELECT + 'redirect' AS component, + $path || '?' || + '&op=' || $_op || + '&values=' || $_values || + '&error=' || $_err_msg AS link +FROM currencies +WHERE currencies.name = :name + AND ($_id IS NULL OR currencies.id <> $_id); + +-- ============================================================================= +-- UPSERT: If everything is OK and "UPDATE" is indicated, update the database +-- ============================================================================= + +INSERT INTO currencies(id, name, to_rub) + SELECT CAST($_id AS INT), :name, CAST(:to_rub AS NUMERIC) + WHERE $action = 'UPDATE' +ON CONFLICT(id) DO +UPDATE SET name = excluded.name, to_rub = excluded.to_rub +RETURNING + 'redirect' AS component, + $path || '?' || + '&id=' || id || + '&info=' || $_op || ' completed successfully' AS link; + +-- ============================================================================= +-- DELETE +-- ============================================================================= + +DELETE FROM currencies +WHERE $action = 'DELETE' AND id = $_id +RETURNING + 'redirect' AS component, + $path || '?' || + '&info=DELETE completed successfully' AS link; + +-- ============================================================================= +-- DEBUG +-- ============================================================================= + +SELECT + 'dynamic' AS component, + sqlpage.run_sql('footer_debug_post-get-set.sql') AS properties; diff --git a/examples/CRUD - Authentication/www/currencies_item_form.sql b/examples/CRUD - Authentication/www/currencies_item_form.sql new file mode 100644 index 0000000..435332c --- /dev/null +++ b/examples/CRUD - Authentication/www/currencies_item_form.sql @@ -0,0 +1,309 @@ +-- Reads an item from the database if valid id is provided and +-- populates the form. Otherwise, an empty form is presented. + +-- ============================================================================= +-- =========================== Module Setting ================================== +-- =========================== Login / Logout ================================== +-- ============================================================================= + +-- $_curpath and $_session_required are required for header_shell_session.sql. + +set _curpath = sqlpage.path(); +set _session_required = 1; + +SELECT + 'dynamic' AS component, + sqlpage.run_sql('header_shell_session.sql') AS properties; + +-- ============================================================================= +-- =============================== Module vars ================================= +-- ============================================================================= + +set _getpath = '?path=' || ifnull($path, $_curpath); +set _action_target = 'currencies_item_dml.sql' || $_getpath; +set _table_list = 'currencies_list.sql'; + +-- ============================================================================= +-- ========================== Filter invalid $id =============================== +-- ============================================================================= +-- +-- NULL is passed as 0 or '' via POST + +SELECT + 'redirect' AS component, + $_curpath AS link +WHERE $id = '' OR CAST($id AS INT) = 0; + +-- If $id is set, it must be a valid PKEY value. + +set error_msg = sqlpage.url_encode('Bad {id = ' || $id || '} provided'); + +SELECT + 'redirect' AS component, + $_curpath || '?error=' || $error_msg AS link + +-- If $id IS NULL, NOT IN returns NULL and redirect is NOT selected. + +WHERE $id NOT IN (SELECT currencies.id FROM currencies); + +-- ============================================================================= +-- ======================== Filter invalid $values ============================= +-- ============================================================================= +-- +-- If $values is provided, it must contain a valid JSON. + +set _err_msg = + sqlpage.url_encode('Values is set to bad JSON: __ ') || $values || ' __'; + +SELECT + 'redirect' AS component, + $_curpath || '?error=' || $_err_msg AS link + +-- Covers $values IS NULL.. + +WHERE NOT json_valid($values); + +-- ============================================================================= +-- ============================= Prepare data ================================== +-- ============================================================================= +-- +-- Field values may be provided via the $values GET variable formatted as JSON +-- object. If $values contains a valid JSON, use it to populate the form. +-- Otherwise, if $id is set to a valid value, retrieve the record from the +-- database and set values. If not, set values to all NULLs. + +set _values = ( + WITH + fields AS ( + -- If valid "id" is supplied as a GET variable, retrieve the record and + -- populate the form. + + SELECT id, name, to_rub + FROM currencies + WHERE id = CAST($id AS INT) AND $values IS NULL + + -- If no "id" is supplied, the first part does not return any records, + -- so add a dummy record. + + UNION ALL + SELECT NULL, '@', 1 + WHERE $id IS NULL AND $values IS NULL + + -- If $value contains a valid JSON, use it to populate the form + + UNION ALL + SELECT + $values ->> '$.id' AS id, + $values ->> '$.name' AS name, + $values ->> '$.to_rub' AS to_rub + WHERE json_valid($values) + ) + SELECT + json_object( + 'id', CAST(fields.id AS INT), + 'name', fields.name, + 'to_rub', CAST(CAST(fields.to_rub AS TEXT) AS NUMERIC) + ) + FROM fields +); + +-- ============================================================================= +-- ========================= Browse Records Button ============================= +-- ============================================================================= +-- +SELECT + 'button' AS component, + 'square' AS shape, + 'sm' AS size, + 'end' AS justify; +SELECT + 'BROWSE' AS title, + 'browse_rec' AS id, + 'corner-down-left' AS icon, + 'corner-down-left' AS icon_after, + 'green' AS outline, + TRUE AS narrow, + $_table_list AS link, + 'Browse full table' AS tooltip +WHERE NOT ifnull($action = 'DELETE', FALSE); + +-- ============================================================================= +-- ============================== Main Form ==================================== +-- ============================================================================= +-- +-- When confirming record deletion, set all fields to read-only and id type to +-- number. No need to worry about the field values: all fields. including id are +-- passed back as POST variables, and the code above sets the $_values variable +-- for proper initialization of the reloaded form. + +set _valid_ids = ( + SELECT json_group_array( + json_object('label', CAST(id AS TEXT), 'value', id) ORDER BY id + ) + FROM currencies + WHERE ifnull($action, '') <> 'INSERT' + UNION ALL + SELECT '[]' + WHERE $action = 'INSERT' +); +set _valid_ids = ( + json_insert($_valid_ids, '$[#]', + json_object('label', 'NULL', 'value', json('null')) + ) +); + +SELECT + 'dynamic' AS component, + json_array( + json_object( + 'component', 'form', + 'title', 'Currency', + 'class', 'form_class', + 'id', 'detail_view', + 'validate', '', + 'action', $_action_target + ), + json_object( + 'name', 'id', + 'label', 'ID', + 'type', iif(ifnull($action = 'DELETE', FALSE), 'number', 'select'), + 'name', 'id', + 'value', $_values ->> '$.id', + 'options', $_valid_ids, + 'width', 4, + 'readonly', ifnull($action = 'DELETE', FALSE), + 'required', json('false') + ), + json_object( + 'name', 'name', + 'label', 'Currency', + 'value', $_values ->> '$.name', + 'placeholder', 'RUR', + 'width', 4, + 'readonly', ifnull($action = 'DELETE', FALSE), + 'required', json('true') + ), + json_object( + 'type', 'number', + 'step', 0.01, + 'name', 'to_rub', + 'label', 'Exchange Rate to RUR', + 'value', $_values ->> '$.to_rub', + 'placeholder', 1, + 'width', 4, + 'readonly', ifnull($action = 'DELETE', FALSE), + 'required', json('true') + ) + ) AS properties +; + +-- ============================================================================= +-- ===================== Display DELETE confirmation =========================== +-- ============================================================================= + +SELECT + 'alert' AS component, + 'warning' AS color, + 'alert-triangle' AS icon, + TRUE AS important, + 'Warning' AS title, + 'Confirm record deletion' AS description +WHERE $action = 'DELETE'; + +-- ============================================================================= +-- ========================== Main Form Buttons ================================ +-- ============================================================================= +-- +-- When confirming record deletion, disable the UPDATE button, replace +-- the Reload button with the Cancel button, invert the DELETE button by +-- removing the outline color, and ajust the POST target. + + +SELECT + 'button' AS component, + 'pill' AS shape, + '' AS size, + 'center' AS justify; + +SELECT -- Default button + '(Re)load' AS title, + 'read_rec' AS id, + 'database' AS icon, + 'database' AS icon_after, + 'green' AS outline, + TRUE AS narrow, + $_curpath AS link, + 'detail_view' AS form, + TRUE AS space_after +WHERE NOT ifnull($action = 'DELETE', FALSE); + +SELECT -- Cancel DELETE button + 'Cancel' AS title, + 'read_rec' AS id, + 'alert-triangle' AS icon, + 'alert-triangle' AS icon_after, + 'primary' AS color, + TRUE AS narrow, + $_curpath AS link, + 'detail_view' AS form, + TRUE AS space_after +WHERE ifnull($action = 'DELETE', FALSE); + +SELECT + 'Update' AS title, -- UPDATE / INSERT button + 'update_rec' AS id, + 'device-floppy' AS icon, + 'device-floppy' AS icon_after, + 'azure' AS outline, + TRUE AS narrow, + $_action_target || '&action=UPDATE' AS link, + 'detail_view' AS form, + ifnull($action = 'DELETE', FALSE) AS disabled, + TRUE AS space_after; + +SELECT -- DELETE button + 'DELETE' AS title, + 'delete_rec' AS id, + 'alert-triangle' AS icon, + 'trash' AS icon_after, + TRUE AS narrow, + iif(ifnull($action = 'DELETE', FALSE), NULL, 'danger') AS outline, + iif(ifnull($action = 'DELETE', FALSE), + $_action_target, $_curpath || '?') || '&action=DELETE' AS link, + 'danger' AS color, + 'detail_view' AS form, + FALSE AS space_after; + +-- ============================================================================= +-- ======================== Display confirmation =============================== +-- ============================================================================= + +SELECT + 'alert' AS component, + 'green' AS color, + 'check' AS icon, + 'Success' AS title, + $info AS description, + True AS dismissible +WHERE $info IS NOT NULL; + +-- ============================================================================= +-- ======================== Display error message ============================== +-- ============================================================================= + +SELECT + 'alert' AS component, + 'red' AS color, + 'thumb-down' AS icon, + $op || ' error' AS title, + $error AS description, + True AS dismissible +WHERE $error IS NOT NULL; + +-- ============================================================================= +-- DEBUG +-- ============================================================================= + +SELECT + 'dynamic' AS component, + sqlpage.run_sql('footer_debug_post-get-set.sql') AS properties; diff --git a/examples/CRUD - Authentication/www/currencies_list.sql b/examples/CRUD - Authentication/www/currencies_list.sql new file mode 100644 index 0000000..12839e1 --- /dev/null +++ b/examples/CRUD - Authentication/www/currencies_list.sql @@ -0,0 +1,114 @@ +-- ============================================================================= +-- =========================== Module Setting ================================== +-- =========================== Login / Logout ================================== +-- ============================================================================= + +-- $_curpath and $_session_required are required for header_shell_session.sql. + +set _curpath = sqlpage.path(); +set _session_required = 1; + +SELECT + 'dynamic' AS component, + sqlpage.run_sql('header_shell_session.sql') AS properties; + +-- ============================================================================= +-- =============================== Module vars ================================= +-- ============================================================================= + +set _getpath = '&path=' || $_curpath; +set _item_form = 'currencies_item_form.sql'; + +-- ============================================================================= +-- ======================== Display confirmation =============================== +-- ============================================================================= + +SELECT + 'alert' AS component, + 'green' AS color, + 'check' AS icon, + 'Success' AS title, + $info AS description, + TRUE AS dismissible +WHERE $info IS NOT NULL; + +-- ============================================================================= +-- ======================== Display error message ============================== +-- ============================================================================= + +SELECT + 'alert' AS component, + 'red' AS color, + 'thumb-down' AS icon, + $op || ' error' AS title, + $error AS description, + TRUE AS dismissible +WHERE $error IS NOT NULL; + +-- ============================================================================= +-- ========================== New record button ================================ +-- ============================================================================= + +SELECT + 'button' AS component, + 'pill' AS shape, + 'lg' AS size, + 'end' AS justify; +SELECT + 'New Record' AS title, + 'insert_rec' AS id, + 'circle-plus' AS icon, + 'circle-plus' AS icon_after, + 'green' AS outline, + $_item_form || '?' || $_getpath || '&action=INSERT' AS link +; + +-- ============================================================================= +-- ============================= Show the table ================================ +-- ============================================================================= + +SELECT + 'divider' AS component, + 'currencies' AS contents; + +-- ============================================================================= + +SELECT + 'title' AS component, + 'Currencies' AS contents, + 4 AS level, + TRUE AS center, + 'title_class' AS class, + 'title_id' AS id; + +-- ============================================================================= +-- TABLE + +SELECT + 'table' AS component, + TRUE AS sort, + TRUE AS search, + TRUE AS border, + TRUE AS hover, + TRUE AS striped_columns, + TRUE AS striped_rows, + 'table_class' AS class, + 'table_id' AS id, + 'actions' AS markdown; + +SELECT + id, + name, + to_rub, + '[![](/icons/outline/edit.svg)](' || $_item_form || '?' || $_getpath || '&id=' || id || ') ' || + '[![](/icons/outline/trash.svg)](' || $_item_form || '?' || $_getpath || '&id=' || id || '&action=DELETE)' AS actions +FROM currencies +ORDER BY id; + +-- ============================================================================= +-- DEBUG +-- ============================================================================= + +SELECT + 'dynamic' AS component, + sqlpage.run_sql('footer_debug_post-get-set.sql') AS properties; diff --git a/examples/CRUD - Authentication/www/footer_debug_post-get-set.sql b/examples/CRUD - Authentication/www/footer_debug_post-get-set.sql new file mode 100644 index 0000000..52b896e --- /dev/null +++ b/examples/CRUD - Authentication/www/footer_debug_post-get-set.sql @@ -0,0 +1,65 @@ +-- ============================================================================= +-- Displays GET/POST/SET variables in sorted tables for debug purposes. The +-- variables are displayed if URL GET variable &DEBUG=1 is set OR &error is +-- defined and not empty. +-- +-- ## Usage +-- +-- Execute this script at the bottom of a page script via +-- +-- ```sql +-- SELECT +-- 'dynamic' AS component, +-- sqlpage.run_sql('footer_debug_post-get-set.sql') AS properties; +-- ``` + +-- GET VARIABLES -- + +SELECT + 'title' AS component, + 'GET Variables' AS contents, + 3 AS level, + TRUE AS center +WHERE $DEBUG OR $error IS NOT NULL; + +SELECT + 'table' AS component, + TRUE AS sort, + TRUE AS search, + TRUE AS border, + TRUE AS hover, + FALSE AS striped_columns, + TRUE AS striped_rows, + 'value' AS markdown +WHERE $DEBUG OR $error IS NOT NULL; + +SELECT "key" AS variable, value +FROM json_each(sqlpage.variables('GET')) +WHERE $DEBUG OR $error IS NOT NULL +ORDER BY substr("key", 1, 1) = '_', "key"; + + +-- POST VARIABLES -- + +SELECT + 'title' AS component, + 'POST Variables' AS contents, + 3 AS level, + TRUE AS center +WHERE $DEBUG OR $error IS NOT NULL; + +SELECT + 'table' AS component, + TRUE AS sort, + TRUE AS search, + TRUE AS border, + TRUE AS hover, + FALSE AS striped_columns, + TRUE AS striped_rows, + 'value' AS markdown +WHERE $DEBUG OR $error IS NOT NULL; + +SELECT "key" AS variable, value +FROM json_each(sqlpage.variables('POST')) +WHERE $DEBUG OR $error IS NOT NULL +ORDER BY "key"; diff --git a/examples/CRUD - Authentication/www/header_shell_session.sql b/examples/CRUD - Authentication/www/header_shell_session.sql new file mode 100644 index 0000000..8628d89 --- /dev/null +++ b/examples/CRUD - Authentication/www/header_shell_session.sql @@ -0,0 +1,175 @@ +-- ============================================================================= +-- Checks for the availablity of an active session and redirects to the login +-- page, if necessary. Using a customized shell_ex component, shows "user" and +-- login/logout buttons appropriately in the top-right corner. +-- +-- Note, any additonal "shell" component settings must also be included here in +-- the same component. It may require extending this script in a flexible way or +-- creating a page-specific copy, which is less desirable, as it would cause code +-- duplication. +-- +-- ## Usage +-- +-- Execute this script via +-- +-- ```sql +-- SELECT +-- 'dynamic' AS component, +-- sqlpage.run_sql('header_shell_session.sql') AS properties; +-- ``` +-- +-- at the top of the page script, but AFTER setting the required variables +-- +-- ```sql +-- set _curpath = sqlpage.path(); +-- set _session_required = 1; +-- set _shell_enabled = 1; +-- ``` +-- +-- ## Reuired SET Variables +-- +-- $_curpath +-- - indicates redirect target passed to the login script +-- $_session_required +-- - 1 - require valid active session for non-public pages +-- - 0 - ignore active session for public pages +-- $_shell_enabled +-- - 1 - execute the shell component in this script (default, if not defined) +-- - 0 - do not execute the shell component in this script +-- Define this value to use page-specific shell component. +-- It id also necessary for no-GUI pages, which are called via a redirect and +-- normally redirect back after the necessary processing is completed. Such +-- pages may still require this script to check for active session, but they +-- will not be able to redirect back if this script outputs GUI buttons. + +-- ============================================================================= +-- ======================= Check required variables ============================ +-- ============================================================================= +-- +-- Set default values (for now) for required variables. +-- Probably should instead show appropriate error messages and abort rendering. + +set _curpath = ifnull($_curpath, '/'); +set _session_required = ifnull($_session_required, 1); +set _shell_enabled = ifnull($_shell_enabled, 1); + +-- ============================================================================= +-- ========================= Check active session ============================== +-- ============================================================================= +-- +-- Check if session is available. +-- Require the user to log in again after 1 day + +set _username = ( + SELECT username + FROM sessions + WHERE sqlpage.cookie('session_token') = id + AND created_at > datetime('now', '-1 day') +); + +-- Redirect to the login page if the user is not logged in. +-- Unprotected pages must +-- set _session_required = (SELECT FALSE); +-- before running this script + +SELECT + 'redirect' AS component, + '/login.sql?path=' || $_curpath AS link +WHERE $_username IS NULL AND $_session_required; + +-- ============================================================================= +-- ==================== Add User and Login/Logout buttons ====================== +-- ============================================================================= +-- + +SELECT + 'dynamic' AS component, + json_array( + json_object( + 'component', 'shell', + 'title', 'CRUD with Authentication', + 'icon', 'database', + 'description', 'Description', + 'layout', 'fluid', + + 'css', + json_array( + '/css/prism-tabler-theme.css', -- Load for code highlighting + '/css/style.css' + ), + + 'javascript', + json_array( + + -- Code highlighting scripts + + 'https://cdn.jsdelivr.net/npm/prismjs@1/components/prism-core.min.js', + 'https://cdn.jsdelivr.net/npm/prismjs@1/plugins/autoloader/prism-autoloader.min.js' + ), + + 'menu_item', + iif($_username IS NOT NULL, + json_array( + json_object( + 'button', FALSE, + 'title', 'Settings', + 'icon', 'settings', + 'submenu', json_array( + json_object( + 'button', TRUE, + 'title', '', + 'icon', 'user-circle', + 'shape', 'pill', + 'size', 'sm', + 'narrow', TRUE, + 'color', 'yellow', + 'outline', '', + 'link', '#', + 'tooltip', 'User profile - Not Implemented' + ), + json_object( + 'button', TRUE, + 'title', '', + 'icon', 'logout', + 'shape', 'pill', + 'size', 'sm', + 'narrow', TRUE, + 'color', 'green', + 'outline', '', + 'link', '/logout.sql?path=' || $_curpath, + 'tooltip', 'Logout' + ) + ) + ) + ), + json_array( + json_object( + 'button', TRUE, + 'title', '', + 'icon', 'user-scan', + 'shape', 'pill', + 'size', 'sm', + 'narrow', TRUE, + 'color', 'warning', + 'outline', '', + 'link', '#', + 'tooltip', 'Sign Up - Not Implemented' + ), + json_object( + 'button', TRUE, + 'title', '', + 'icon', 'login', + 'shape', 'pill', + 'size', 'sm', + 'narrow', TRUE, + 'color', '', + 'outline', 'cyan', + 'link', '/login.sql?path=' || $_curpath, + 'tooltip', 'Login' + ) + ) + ) + + ) + ) AS properties +WHERE CAST($_shell_enabled AS INT) <> 0; diff --git a/examples/CRUD - Authentication/www/icons/earth-icon.svg b/examples/CRUD - Authentication/www/icons/earth-icon.svg new file mode 100644 index 0000000..a85767a --- /dev/null +++ b/examples/CRUD - Authentication/www/icons/earth-icon.svg @@ -0,0 +1,4 @@ + \ No newline at end of file diff --git a/examples/CRUD - Authentication/www/icons/outline/device-floppy.svg b/examples/CRUD - Authentication/www/icons/outline/device-floppy.svg new file mode 100644 index 0000000..0362181 --- /dev/null +++ b/examples/CRUD - Authentication/www/icons/outline/device-floppy.svg @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/examples/CRUD - Authentication/www/icons/outline/edit.svg b/examples/CRUD - Authentication/www/icons/outline/edit.svg new file mode 100644 index 0000000..3dc80af --- /dev/null +++ b/examples/CRUD - Authentication/www/icons/outline/edit.svg @@ -0,0 +1,21 @@ + + + + + + diff --git a/examples/CRUD - Authentication/www/icons/outline/table.svg b/examples/CRUD - Authentication/www/icons/outline/table.svg new file mode 100644 index 0000000..7d746de --- /dev/null +++ b/examples/CRUD - Authentication/www/icons/outline/table.svg @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/examples/CRUD - Authentication/www/icons/outline/trash.svg b/examples/CRUD - Authentication/www/icons/outline/trash.svg new file mode 100644 index 0000000..ccb3b33 --- /dev/null +++ b/examples/CRUD - Authentication/www/icons/outline/trash.svg @@ -0,0 +1,19 @@ + + + + + + + + \ No newline at end of file diff --git a/examples/CRUD - Authentication/www/img/delete_confirmation.png b/examples/CRUD - Authentication/www/img/delete_confirmation.png new file mode 100644 index 0000000..6c2190b Binary files /dev/null and b/examples/CRUD - Authentication/www/img/delete_confirmation.png differ diff --git a/examples/CRUD - Authentication/www/img/detail_view.png b/examples/CRUD - Authentication/www/img/detail_view.png new file mode 100644 index 0000000..eeda526 Binary files /dev/null and b/examples/CRUD - Authentication/www/img/detail_view.png differ diff --git a/examples/CRUD - Authentication/www/img/logout_view.png b/examples/CRUD - Authentication/www/img/logout_view.png new file mode 100644 index 0000000..168d868 Binary files /dev/null and b/examples/CRUD - Authentication/www/img/logout_view.png differ diff --git a/examples/CRUD - Authentication/www/img/table_view.png b/examples/CRUD - Authentication/www/img/table_view.png new file mode 100644 index 0000000..ef48ccb Binary files /dev/null and b/examples/CRUD - Authentication/www/img/table_view.png differ diff --git a/examples/CRUD - Authentication/www/index.sql b/examples/CRUD - Authentication/www/index.sql new file mode 100644 index 0000000..2819e57 --- /dev/null +++ b/examples/CRUD - Authentication/www/index.sql @@ -0,0 +1,34 @@ +SELECT + 'dynamic' AS component, + json_array( + json_object( + 'component', 'shell', + 'title', 'CRUD with Authentication', + 'icon', 'database', + 'description', 'Description', + + 'css', + json_array( + '/css/prism-tabler-theme.css' + ), + + 'javascript', + json_array( + 'https://cdn.jsdelivr.net/npm/prismjs@1/components/prism-core.min.js', + 'https://cdn.jsdelivr.net/npm/prismjs@1/plugins/autoloader/prism-autoloader.min.js' + ) + + ) + ) AS properties; + +-- ============================================================================= + +SELECT + 'text' AS component, + TRUE AS center, + 2 AS level, + 'Demo/Template CRUD with Authentication' AS title, + sqlpage.read_file_as_text('./README.md') AS contents_md; + + + diff --git a/examples/CRUD - Authentication/www/intro.sql b/examples/CRUD - Authentication/www/intro.sql new file mode 100644 index 0000000..eb9219a --- /dev/null +++ b/examples/CRUD - Authentication/www/intro.sql @@ -0,0 +1,354 @@ +-- ============================================================================= +-- =========================== Module Setting ================================== +-- =========================== Login / Logout ================================== +-- ============================================================================= + +-- $_curpath and $_session_required are required for header_shell_session.sql. + +set _curpath = sqlpage.path(); +set _session_required = 0; + +SELECT + 'dynamic' AS component, + sqlpage.run_sql('header_shell_session.sql') AS properties; + +-- ============================================================================= + +SELECT + 'text' AS component, + TRUE AS center, + 2 AS level, + 'SQLite Introspection Information' AS title; + +SELECT + 'divider' AS component, + 'Password Hash' AS contents; + +-- ============================================================================= +-- Password Hash + +SELECT + 'alert' AS component, + 'green' AS color, + 'edit' AS icon, + 'Password Hash: sqlpage.hash_password(''admin'')' AS title, + sqlpage.hash_password('admin') AS description, + TRUE AS dismissible; + +-- ============================================================================= +-- ============================ Alert Template ================================= +-- ============================================================================= + +SELECT + 'divider' AS component, + 'Alert Template' AS contents; + +-- ============================================================================= +-- ALERT + +SELECT + 'alert' AS component, + 'green' AS color, + 'Alert Title' AS title, + 'Description' AS description, + '**Bold MD**' AS description_md, + 'alert_class' AS class, + 'alert_id' AS id, + TRUE AS dismissible, + FALSE AS important, + 'user' AS icon, + 'https://google.com' AS link, + 'LINK TEXT' AS link_text; + +-- ============================================================================= +-- ============================ IDs ============================================ +-- ============================================================================= + +SELECT + 'divider' AS component, + 'IDs' AS contents; + +-- ============================================================================= + +SELECT + 'title' AS component, + 'IDs' AS contents, + 4 AS level, + TRUE AS center, + 'title_class' AS class, + 'title_id' AS id; + +-- ============================================================================= +-- TABLE + +SELECT + 'table' AS component, + TRUE AS sort, + TRUE AS search, + TRUE AS border, + TRUE AS hover, + TRUE AS striped_columns, + TRUE AS striped_rows, + 'table_class' AS class, + 'table_id' AS id; + +SELECT + sqlite_version() AS "SQLite Version", + (SELECT * FROM pragma_application_id()) AS app_id, + (SELECT * FROM pragma_user_version()) AS user_version, + (SELECT * FROM pragma_schema_version()) AS schema_version; + +-- ============================================================================= +-- ============================ SQLite_Master ================================== +-- ============================================================================= + +SELECT + 'divider' AS component, + 'SQLite_Master' AS contents; + +-- ============================================================================= + +SELECT + 'title' AS component, + 'SQLite_Master' AS contents, + 4 AS level, + TRUE AS center, + 'title_class' AS class, + 'title_id' AS id; + +-- ============================================================================= +-- TABLE + +SELECT + 'table' AS component, + TRUE AS sort, + TRUE AS search, + TRUE AS border, + TRUE AS hover, + TRUE AS striped_columns, + TRUE AS striped_rows, + TRUE AS small, + 'table_class' AS class, + TRUE AS overflow; + +SELECT * FROM sqlite_master; + +-- ============================================================================= +-- ============================ Function List ================================== +-- ============================================================================= + +SELECT + 'divider' AS component, + 'Function List' AS contents; + +-- ============================================================================= + +SELECT + 'Function List. Total ' || (SELECT count(DISTINCT name) + FROM pragma_function_list()) + || ' distinct' AS contents, + 'title' AS component, + 4 AS level, + TRUE AS center, + 'title_class' AS class, + 'title_id' AS id; + +-- ============================================================================= +-- TABLE + +SELECT + 'table' AS component, + TRUE AS sort, + TRUE AS search, + TRUE AS border, + TRUE AS hover, + TRUE AS striped_columns, + TRUE AS striped_rows, + TRUE AS small, + 'table_class' AS class, + 'table_id' AS id; + +SELECT * FROM pragma_function_list() ORDER BY name, narg; + +-- ============================================================================= +-- ============================ Collation List ================================= +-- ============================================================================= + +SELECT + 'divider' AS component, + 'Collation List' AS contents; + +-- ============================================================================= + +SELECT + 'Collation List' AS contents, + 'title' AS component, + 4 AS level, + TRUE AS center, + 'title_class' AS class, + 'title_id' AS id; + +-- ============================================================================= +-- TABLE + +SELECT + 'table' AS component, + TRUE AS sort, + TRUE AS search, + TRUE AS border, + TRUE AS hover, + TRUE AS striped_columns, + TRUE AS striped_rows, + TRUE AS small, + 'table_class' AS class, + 'table_id' AS id; + +SELECT * FROM pragma_collation_list() ORDER BY rowid; + +-- ============================================================================= +-- ============================ Pragma List ==================================== +-- ============================================================================= + +SELECT + 'divider' AS component, + 'Pragma List' AS contents; + +-- ============================================================================= + +SELECT + 'Pragma List. Total ' || (SELECT count(*) FROM pragma_pragma_list()) AS contents, + 'title' AS component, + 4 AS level, + TRUE AS center, + 'title_class' AS class, + 'title_id' AS id; + +-- ============================================================================= +-- TABLE + +SELECT + 'table' AS component, + TRUE AS sort, + TRUE AS search, + TRUE AS border, + TRUE AS hover, + TRUE AS striped_columns, + TRUE AS striped_rows, + TRUE AS small, + 'table_class' AS class, + 'table_id' AS id; + +SELECT * FROM pragma_pragma_list() AS functions ORDER BY name; + +-- ============================================================================= +-- ============================ Module List ==================================== +-- ============================================================================= + +SELECT + 'divider' AS component, + 'Pragma List' AS contents; + +-- ============================================================================= + +SELECT + 'Module List. Total ' || (SELECT count(*) FROM pragma_module_list()) AS contents, + 'title' AS component, + 4 AS level, + TRUE AS center, + 'title_class' AS class, + 'title_id' AS id; + +-- ============================================================================= +-- TABLE + +SELECT + 'table' AS component, + TRUE AS sort, + TRUE AS search, + TRUE AS border, + TRUE AS hover, + TRUE AS striped_columns, + TRUE AS striped_rows, + TRUE AS small, + 'table_class' AS class, + 'table_id' AS id; + +SELECT * FROM pragma_module_list() ORDER BY name; + +-- ============================================================================= +-- ============================ Table List ===================================== +-- ============================================================================= + +SELECT + 'divider' AS component, + 'Table List' AS contents; + +-- ============================================================================= + +SELECT + 'Table List' AS contents, + 'title' AS component, + 4 AS level, + TRUE AS center, + 'title_class' AS class, + 'title_id' AS id; + +-- ============================================================================= +-- TABLE + +SELECT + 'table' AS component, + TRUE AS sort, + TRUE AS search, + TRUE AS border, + TRUE AS hover, + TRUE AS striped_columns, + TRUE AS striped_rows, + TRUE AS small, + 'table_class' AS class, + 'table_id' AS id; + +SELECT * FROM pragma_table_list() ORDER BY type, name; + +-- ============================================================================= +-- ============================ Database List ================================== +-- ============================================================================= + +SELECT + 'divider' AS component, + 'Database List' AS contents; + +-- ============================================================================= + +SELECT + 'Database List' AS contents, + 'title' AS component, + 4 AS level, + TRUE AS center, + 'title_class' AS class, + 'title_id' AS id; + +-- ============================================================================= +-- TABLE + +SELECT + 'table' AS component, + TRUE AS sort, + TRUE AS search, + TRUE AS border, + TRUE AS hover, + TRUE AS striped_columns, + TRUE AS striped_rows, + TRUE AS small, + 'table_class' AS class, + 'table_id' AS id; + +SELECT * FROM pragma_database_list() ORDER BY seq; + +-- ============================================================================= +-- DEBUG +-- ============================================================================= + +SELECT + 'dynamic' AS component, + sqlpage.run_sql('footer_debug_post-get-set.sql') AS properties; diff --git a/examples/CRUD - Authentication/www/login.sql b/examples/CRUD - Authentication/www/login.sql new file mode 100644 index 0000000..f725931 --- /dev/null +++ b/examples/CRUD - Authentication/www/login.sql @@ -0,0 +1,48 @@ +-- Authentication Fence + +set username = ( + SELECT username + FROM sessions + WHERE sqlpage.cookie('session_token') = id + AND created_at > datetime('now', '-1 day') -- require the user to log in again after 1 day +); + +SELECT + 'redirect' AS component, + '/' AS link -- redirect to the front page if the user is logged in +WHERE $username IS NOT NULL; + +-- ============================================================================= + +SELECT + 'shell' AS component, + 'CRUD with Authentication' AS title, + 'database' AS icon; + +-- ============================================================================= +-- ================================= Login ===================================== +-- ============================================================================= + +SELECT + 'form' AS component, + 'Login' AS title, + 'create_session.sql' || ifnull('?path=' || $path, '') AS action; + +-- Define form fields + +SELECT + column1 AS name, column2 AS label, + column3 AS type, column4 AS required +FROM (VALUES + ('username', 'Username', 'text', TRUE), + ('password', 'Password', 'password', TRUE) +); + +-- Show alert on failed authentication. + +SELECT + 'alert' AS component, + 'danger' AS color, + 'You are not logged in' AS title, + 'Sorry, we could not log you in. Please try again.' AS description +WHERE $error IS NOT NULL; \ No newline at end of file diff --git a/examples/CRUD - Authentication/www/logout.sql b/examples/CRUD - Authentication/www/logout.sql new file mode 100644 index 0000000..ceaf157 --- /dev/null +++ b/examples/CRUD - Authentication/www/logout.sql @@ -0,0 +1,8 @@ +SELECT + 'cookie' AS component, + 'session_token' AS name, + TRUE AS remove; + +SELECT + 'redirect' AS component, + ifnull($path, '/login.sql') AS link -- redirect to the login page after the user logs out diff --git a/examples/CRUD - Authentication/www/menu_test/dummy.sql b/examples/CRUD - Authentication/www/menu_test/dummy.sql new file mode 100644 index 0000000..9261951 --- /dev/null +++ b/examples/CRUD - Authentication/www/menu_test/dummy.sql @@ -0,0 +1,22 @@ +select + 'shell' as component, + 'SQLPage' as title, + 'database' as icon, + '/' as link, + 'Top' as menu_item, + '{"title":"About","submenu":[{"link":"/safety.sql","title":"Security"},{"link":"/performance.sql","title":"Performance"},{"link":"//github.com/sqlpage/SQLPage/blob/main/LICENSE.txt","title":"License"},{"link":"/blog.sql","title":"Articles"}]}' as menu_item, + NULL as menu_item, + '{"title":"Examples","submenu":[{"link":"/examples/tabs/","title":"Tabs"},{"link":"/examples/layouts.sql","title":"Layouts"},{"link":"/examples/multistep-form","title":"Forms"},{"link":"/examples/handle_picture_upload.sql","title":"File uploads"},{"link":"/examples/hash_password.sql","title":"Password protection"},{"link":"//github.com/sqlpage/SQLPage/blob/main/examples/","title":"All examples & demos"}]}' as menu_item, + '{"title":"z", "icon": "settings"}' as menu_item, + '{"title":"", "icon": ""}' as menu_item, + '{"title":"Community","submenu":[{"link":"blog.sql","title":"Blog"},{"link":"//github.com/sqlpage/SQLPage/issues","title":"Report a bug"},{"link":"//github.com/sqlpage/SQLPage/discussions","title":"Discussions"},{"link":"//github.com/sqlpage/SQLPage","title":"Github"}]}' as menu_item, + NULL as menu_item, + '{"title":"Documentation","submenu":[{"link":"/your-first-sql-website","title":"Getting started"},{"link":"/components.sql","title":"All Components"},{"link":"/functions.sql","title":"SQLPage Functions"},{"link":"/custom_components.sql","title":"Custom Components"},{"link":"//github.com/sqlpage/SQLPage/blob/main/configuration.md#configuring-sqlpage","title":"Configuration"}]}' as menu_item, + 'boxed' as layout, + 'en-US' as language, + 'Documentation for the SQLPage low-code web application framework.' as description, + 'Poppins' as font, + 'https://cdn.jsdelivr.net/npm/prismjs@1/components/prism-core.min.js' as javascript, + 'https://cdn.jsdelivr.net/npm/prismjs@1/plugins/autoloader/prism-autoloader.min.js' as javascript, + '/prism-tabler-theme.css' as css, + 'Official [SQLPage](https://sql-page.com) documentation' as footer; \ No newline at end of file diff --git a/examples/CRUD - Authentication/www/menu_test/dummy_menu.sql b/examples/CRUD - Authentication/www/menu_test/dummy_menu.sql new file mode 100644 index 0000000..2ce87ff --- /dev/null +++ b/examples/CRUD - Authentication/www/menu_test/dummy_menu.sql @@ -0,0 +1,271 @@ +set _get_vars = ( + SELECT + json_group_object( + "key", + iif(CAST(CAST(value AS NUMERIC) AS TEXT) = value, CAST(value AS NUMERIC), value) + ) AS get_var + FROM + json_each(sqlpage.variables('GET')) + WHERE NOT "key" like '\_%' ESCAPE '\' +); + + +set _locale_code = $lang; -- 'en', 'ru', 'de', 'fr', 'zh-cn' +set _theme = 'fancy'; --$theme; -- 'default', 'fancy' +set _hide_language_names = $hide_language_names; -- 0, 1 (BOOLEAN) +set _authenticated = $authenticated; -- 0, 1 (BOOLEAN) + +-- ============================================================================= +-- ============================================================================= + +WITH + +-- test_values(_locale_code, _theme, _hide_language_names, _authenticated ) AS (VALUES +-- ( 'en', 'fancy', TRUE, FALSE) +-- ( NULL, NULL, NULL, NULL) +-- ( 'fr', 'default', TRUE, TRUE) +-- ), + + -- Replaces values with appropriate variables + -- ifnull guuards from invalid JSON errors + + config_user AS ( + SELECT + lower(ifnull($_locale_code, '_')) AS locale_code, + lower(ifnull($_theme, '_')) AS theme, + CAST($_hide_language_names AS INTEGER) AS hide_language_names, + CAST(ifnull($_authenticated, FALSE) AS INTEGER) AS authenticated +-- FROM test_values + ), + + -- Inputs data guards + + config_guards AS ( + SELECT + ( + SELECT iif(contents ->> ('$.' || locale_code) IS NULL, NULL, locale_code) + FROM sqlpage_files + WHERE path = 'locales/locales.json' + ) AS locale_code, + ( + SELECT iif(contents ->> ('$.' || theme) IS NULL, NULL, theme) + FROM sqlpage_files + WHERE path = 'themes/themes.json' + ) AS theme, + hide_language_names, + authenticated + FROM config_user + ), + + -- Retrieves locale and theme JSON data + + config AS ( + SELECT + iif(locale_code IS NULL, NULL, ( + SELECT contents ->> '$.menu' + FROM sqlpage_files + WHERE path = 'locales/' || locale_code || '/locale.json' + )) AS locale, + iif(theme IS NULL, NULL, ( + SELECT contents ->> '$.menu' + FROM sqlpage_files + WHERE path = 'themes/' || theme || '/theme.json' + )) AS theme, + ( + SELECT contents ->> '$.menu' + FROM sqlpage_files + WHERE path = 'themes/default/theme.json' + ) AS theme_default, + ( + SELECT contents ->> '$.meta.label' + FROM sqlpage_files + WHERE path = 'locales/' || locale_code || '/locale.json' + ) AS locale_label, + hide_language_names, + authenticated + FROM config_guards + ), + + -- Prepares language items. + -- This is a dynamically generated menu item. + + locale_codes AS ( + SELECT "key" AS code, value AS position + FROM sqlpage_files, json_each(contents) + WHERE sqlpage_files.path = 'locales/locales.json' + ), + languages AS ( + SELECT + position, + code, + contents ->> '$.meta.label' AS label + FROM sqlpage_files, locale_codes + WHERE path like 'locales/%/locale.json' + AND contents ->> '$.meta.code' = code COLLATE NOCASE + ORDER BY position + ), + + -- Prepares a list of top menu items with default icons. + -- The language menu includes the "global/neutral/undefinded" icon. + + top_menus_global AS ( + SELECT + position, + label, + + -- Hide top menu with submenu if a particular filter is included in + -- state_filter and its current value does match the specified value. + -- + -- Note that the "class" attribute is set to two classes: + -- 'menu_' || lower(label) and the same with '_slim' suffix. + -- These classes are applied to respective submenus for css-based fine-tuning. + + CASE + WHEN (state_filter ->> '$.authenticated') IS NULL + OR state_filter ->> '$.authenticated' = ifnull(authenticated, FALSE) THEN + json_object( + 'title', iif(icon_only IS TRUE, NULL, ifnull(locale ->> ('$.' || label), label)), + iif(theme IS NULL, 'icon', 'image'), + iif(theme IS NULL, tabler_icon, '/' || (theme ->> ('$.' || label))), + 'class', + -- Handles special cases: only sets the '_slim' class on the 'Language' + -- menu when language names (labels) are hidden. Only set the base class + -- for English localization (the extra whitespaces are painfull...) + iif(ifnull(locale_label, 'English') IN ('English', 'Chinese'), + ' menu_' || lower(label), '') || + iif(label = 'Language' AND hide_language_names IS NOT TRUE, '', + ' menu_' || lower(label) || '_slim' + ) + ) + ELSE + NULL + END AS top_item + FROM menus, config + WHERE parent_label IS NULL + ORDER BY position + ), + + -- The sole purpose of this separate modification is to set the icon + -- of the top language menu to reflect the current locale + + top_menus AS ( + SELECT + position, + label, + iif(label <> 'Language' OR locale IS NULL OR top_item IS NULL, + top_item, + json_set( + top_item, + '$.image', + '/' || (theme ->> ('$.' || locale_label)) + ) + ) AS top_item + FROM top_menus_global, config + ORDER BY position + ), + + -- Prepares a list of submenu lines. + + menu_lines AS ( + SELECT + parent_label, + position, + + -- Hides menu line if a particular filter is included in state_filter + -- and its current value does match the specified value + + CASE + WHEN (state_filter ->> '$.authenticated') IS NULL + OR state_filter ->> '$.authenticated' = ifnull(authenticated, FALSE) THEN + json_object( + 'title', iif(icon_only IS TRUE, NULL, ifnull(locale ->> ('$.' || label), label)), + iif(theme IS NULL, 'icon', 'image'), + iif(theme IS NULL, tabler_icon, '/' || (theme ->> ('$.' || label))), + 'link', link + ) + END AS menu_line + FROM menus, config + WHERE parent_label IS NOT NULL + + -- Generates and appends the Language submenu lines + + UNION ALL + SELECT + 'Language' AS parent_label, + position, + json_object( + 'title', + iif(hide_language_names IS TRUE, NULL, ifnull(locale ->> ('$.' || label), label)), + 'image', '/' || (iif(theme IS NULL, theme_default, theme) ->> ('$.' || label)), + 'link', '?lang=' || code + ) AS menu_line + FROM languages, config + ORDER BY parent_label, position + ), + + -- Groups menu lines into submenus + + menu_lists AS ( + SELECT + parent_label, + json_group_array( + json(menu_line) ORDER BY position + ) AS menu_list + FROM menu_lines + GROUP BY parent_label + ), + + -- Combines submenus with corresponding top menu lines yielding complete menu_item objects + + menu_sets AS ( + SELECT + position, + label, + json_set(json(top_item), '$.submenu', json(menu_list)) AS menu_set + FROM top_menus, menu_lists + WHERE top_menus.label = menu_lists.parent_label + ORDER BY position + ), + + -- Prepares final array of menu_item objects to be used with the "dynamic" component + + menu AS ( + SELECT + json_group_array(json(menu_set) ORDER BY position) AS menu + FROM menu_sets + ), + + -- shell_dynamic_static is included for debugging purposes. Call + -- it to generate "static" SQL for inclusion in an SQLPage script. + + shell_dynamic_static AS ( + SELECT + 'SELECT' || x'0A' || ' ''dynamic'' AS component,' || x'0A' || + quote(json_pretty(json_object( + 'component', 'shell', + 'title', 'SQLPage', + 'icon', 'database', + 'link', '/', + 'css', '/css/style.css', + 'menu_item', json(menu) + ))) || ' AS properties' || x'0A0A' AS properties + FROM menu + ), + + -- Call shell_dynamic if this script is processed directly by SQLPage. + -- After copy-pasting adjust the input controls in the first CTE. + + shell_dynamic AS ( + SELECT + 'dynamic' AS component, + json_object( + 'component', 'shell', + 'title', 'SQLPage', + 'icon', 'database', + 'link', '/', + 'css', '/css/style.css', + 'menu_item', json(menu) + ) AS properties + FROM menu + ) +SELECT * FROM shell_dynamic; diff --git a/examples/CRUD - Authentication/www/menu_test/menu_code.sql b/examples/CRUD - Authentication/www/menu_test/menu_code.sql new file mode 100644 index 0000000..4d7d8f7 --- /dev/null +++ b/examples/CRUD - Authentication/www/menu_test/menu_code.sql @@ -0,0 +1,258 @@ +-- set _locale_code = $lang; -- 'en', 'ru', 'de', 'fr', 'zh-cn' +-- set _theme = $theme; -- 'default', 'fancy' +-- set _hide_language_names = $hide_language_names; -- 0, 1 (BOOLEAN) +-- set _authenticated = $authenticated; -- 0, 1 (BOOLEAN) + +-- ============================================================================= +-- ============================================================================= + +WITH + + test_values(_locale_code, _theme, _hide_language_names, _authenticated ) AS (VALUES +-- ( 'en', 'fancy', TRUE, FALSE) +-- ( NULL, NULL, NULL, NULL) + ( 'fr', 'default', TRUE, TRUE) + ), + + -- Replaces values with appropriate variables + -- ifnull guuards from invalid JSON errors + + config_user AS ( + SELECT + lower(ifnull(_locale_code, '_')) AS locale_code, + lower(ifnull(_theme, '_')) AS theme, + CAST(_hide_language_names AS INTEGER) AS hide_language_names, + CAST(ifnull(_authenticated, FALSE) AS INTEGER) AS authenticated + FROM test_values + ), + + -- Inputs data guards + + config_guards AS ( + SELECT + ( + SELECT iif(contents ->> ('$.' || locale_code) IS NULL, NULL, locale_code) + FROM sqlpage_files + WHERE path = 'locales/locales.json' + ) AS locale_code, + ( + SELECT iif(contents ->> ('$.' || theme) IS NULL, NULL, theme) + FROM sqlpage_files + WHERE path = 'themes/themes.json' + ) AS theme, + hide_language_names, + authenticated + FROM config_user + ), + + -- Retrieves locale and theme JSON data + + config AS ( + SELECT + iif(locale_code IS NULL, NULL, ( + SELECT contents ->> '$.menu' + FROM sqlpage_files + WHERE path = 'locales/' || locale_code || '/locale.json' + )) AS locale, + iif(theme IS NULL, NULL, ( + SELECT contents ->> '$.menu' + FROM sqlpage_files + WHERE path = 'themes/' || theme || '/theme.json' + )) AS theme, + ( + SELECT contents ->> '$.menu' + FROM sqlpage_files + WHERE path = 'themes/default/theme.json' + ) AS theme_default, + ( + SELECT contents ->> '$.meta.label' + FROM sqlpage_files + WHERE path = 'locales/' || locale_code || '/locale.json' + ) AS locale_label, + hide_language_names, + authenticated + FROM config_guards + ), + + -- Prepares language items. + -- This is a dynamically generated menu item. + + locale_codes AS ( + SELECT "key" AS code, value AS position + FROM sqlpage_files, json_each(contents) + WHERE sqlpage_files.path = 'locales/locales.json' + ), + languages AS ( + SELECT + position, + code, + contents ->> '$.meta.label' AS label + FROM sqlpage_files, locale_codes + WHERE path like 'locales/%/locale.json' + AND contents ->> '$.meta.code' = code COLLATE NOCASE + ORDER BY position + ), + + -- Prepares a list of top menu items with default icons. + -- The language menu includes the "global/neutral/undefinded" icon. + + top_menus_global AS ( + SELECT + position, + label, + + -- Hide top menu with submenu if a particular filter is included in + -- state_filter and its current value does match the specified value. + -- + -- Note that the "class" attribute is set to two classes: + -- 'menu_' || lower(label) and the same with '_slim' suffix. + -- These classes are applied to respective submenus for css-based fine-tuning. + + CASE + WHEN (state_filter ->> '$.authenticated') IS NULL + OR state_filter ->> '$.authenticated' = ifnull(authenticated, FALSE) THEN + json_object( + 'title', iif(icon_only IS TRUE, NULL, ifnull(locale ->> ('$.' || label), label)), + iif(theme IS NULL, 'icon', 'image'), + iif(theme IS NULL, tabler_icon, '/' || (theme ->> ('$.' || label))), + 'class', + -- Handles special cases: only sets the '_slim' class on the 'Language' + -- menu when language names (labels) are hidden. Only set the base class + -- for English localization (the extra whitespaces are painfull...) + iif(locale_label = 'English', ' menu_' || lower(label), '') || + iif(label = 'Language' AND hide_language_names IS NOT TRUE, '', + ' menu_' || lower(label) || '_slim' + ) + ) + ELSE + NULL + END AS top_item + FROM menus, config + WHERE parent_label IS NULL + ORDER BY position + ), + + -- The sole purpose of this separate modification is to set the icon + -- of the top language menu to reflect the current locale + + top_menus AS ( + SELECT + position, + label, + iif(label <> 'Language' OR locale IS NULL OR top_item IS NULL, + top_item, + json_set( + top_item, + '$.image', + '/' || (theme ->> ('$.' || locale_label)) + ) + ) AS top_item + FROM top_menus_global, config + ORDER BY position + ), + + -- Prepares a list of submenu lines. + + menu_lines AS ( + SELECT + parent_label, + position, + + -- Hides menu line if a particular filter is included in state_filter + -- and its current value does match the specified value + + CASE + WHEN (state_filter ->> '$.authenticated') IS NULL + OR state_filter ->> '$.authenticated' = ifnull(authenticated, FALSE) THEN + json_object( + 'title', iif(icon_only IS TRUE, NULL, ifnull(locale ->> ('$.' || label), label)), + iif(theme IS NULL, 'icon', 'image'), + iif(theme IS NULL, tabler_icon, '/' || (theme ->> ('$.' || label))), + 'link', link + ) + END AS menu_line + FROM menus, config + WHERE parent_label IS NOT NULL + + -- Generates and appends the Language submenu lines + + UNION ALL + SELECT + 'Language' AS parent_label, + position, + json_object( + 'title', + iif(hide_language_names IS TRUE, NULL, ifnull(locale ->> ('$.' || label), label)), + 'image', '/' || (iif(theme IS NULL, theme_default, theme) ->> ('$.' || label)), + 'link', '/locales/locale.sql?lang=' || code + ) AS menu_line + FROM languages, config + ORDER BY parent_label, position + ), + + -- Groups menu lines into submenus + + menu_lists AS ( + SELECT + parent_label, + json_group_array( + json(menu_line) ORDER BY position + ) AS menu_list + FROM menu_lines + GROUP BY parent_label + ), + + -- Combines submenus with corresponding top menu lines yielding complete menu_item objects + + menu_sets AS ( + SELECT + position, + label, + json_set(json(top_item), '$.submenu', json(menu_list)) AS menu_set + FROM top_menus, menu_lists + WHERE top_menus.label = menu_lists.parent_label + ORDER BY position + ), + + -- Prepares final array of menu_item objects to be used with the "dynamic" component + + menu AS ( + SELECT + json_group_array(json(menu_set) ORDER BY position) AS menu + FROM menu_sets + ), + + -- shell_dynamic_static is included for debugging purposes. Call + -- it to generate "static" SQL for inclusion in an SQLPage script. + + shell_dynamic_static AS ( + SELECT + 'SELECT' || x'0A' || ' ''dynamic'' AS component,' || x'0A' || + quote(json_pretty(json_object( + 'component', 'shell', + 'title', 'SQLPage', + 'icon', 'database', + 'link', '/', + 'css', '/css/style.css', + 'menu_item', json(menu) + ))) || ' AS properties' || x'0A0A' AS properties + FROM menu + ), + + -- Call shell_dynamic if this script is processed directly by SQLPage. + -- After copy-pasting adjust the input controls in the first CTE. + + shell_dynamic AS ( + SELECT + 'dynamic' AS component, + json_object( + 'component', 'shell', + 'title', 'SQLPage', + 'icon', 'database', + 'link', '/', + 'css', '/css/style.css', + 'menu_item', json(menu) + ) AS properties + FROM menu + ) +SELECT * FROM shell_dynamic_static; diff --git a/examples/CRUD - Authentication/www/menu_test/menu_demo.sql b/examples/CRUD - Authentication/www/menu_test/menu_demo.sql new file mode 100644 index 0000000..5d34d86 --- /dev/null +++ b/examples/CRUD - Authentication/www/menu_test/menu_demo.sql @@ -0,0 +1,116 @@ +select 'dynamic' as component, +'[ + { + "component": "shell", + "title": "SQLPage", + "icon": "database", + "link": "/", + "menu_item": [ + { + "icon": "settings", + "title": "Z", + "button": true, + "shape": "pill", + "narrow": true, + "outline": "warning", + "submenu": [ + { + "link": "/safety.sql", + "icon": "user", + "button": true, + "shape": "pill", + "size": "sm", + "tooltip": "User", + "color": "yellow" + }, + {}, + { + "link": "/performance.sql", + "icon": "logout", + "tooltip": "Logout", + "button": true, + "shape": "pill", + "size": "sm", + "outline": "warning" + + }, + { + "link": "/performance.sql", + "icon": "", + "tooltip": "Logout", + "button": true, + "shape": "pill", + "size": "sm", + "outline": "warning" + + } + + ] + }, + { + "icon": "database", + "title": "Dummy", + "button": true, + "shape": "pill", + "narrow": true, + "color": "green" + }, + { + "icon": "", + "title": "", + "button": true, + "shape": "pill", + "narrow": true, + "color": "blue" + }, + { + "title": "Examples", + "icon": "trash", + "submenu": [ + { + "link": "/examples/tabs/", + "icon": "device-floppy", + "title": "Tabs" + }, + { + "link": "/examples/layouts.sql", + "button": true, + "tooltip": "Layouts", + "title": "Layouts", + "color": "primary" + }, + { + "link": "/examples/multistep-form", + "title": "Forms" + } + ] + }, + { + "title": "Community", + "submenu": [ + { + "link": "blog.sql", + "title": "Blog" + }, + { + "link": "//github.com/sqlpage/SQLPage/issues", + "title": "Report a bug" + } + ] + } + ] + } +]' AS properties; + +SELECT + 'button' AS component, + 'pill' AS shape, + '' AS size, + 'center' AS justify; +SELECT + '' AS title, + 'browse_rec' AS id, + 'green' AS outline, + TRUE AS narrow, + '#' AS link, + '/icons/earth-icon.svg' AS img; diff --git a/examples/PostGIS - using sqlpage with geographic data/README.md b/examples/PostGIS - using sqlpage with geographic data/README.md new file mode 100644 index 0000000..0ba101a --- /dev/null +++ b/examples/PostGIS - using sqlpage with geographic data/README.md @@ -0,0 +1,19 @@ +# Using PostGIS to build a geographic data application + +## Introduction + +This is a small application that uses [PostGIS](https://postgis.net/) +to save data associated with geographic coordinates. + +If you are using a SQLite database, see [this other example instead](../make%20a%20geographic%20data%20application%20using%20sqlite%20extensions/), +which implements the same application using the `spatialite` extension for SQLite. + +### Installation + +You need to install the `postgis` extension for postgres. Follow [the official instructions](https://postgis.net/documentation/getting_started/). + +Then you can instruct SQLPage to connect to your database by editing the [`sqlpage/sqlpage.json`](./sqlpage/sqlpage.json) file. + +## Screenshots + +![](./screenshots/code.png) \ No newline at end of file diff --git a/examples/PostGIS - using sqlpage with geographic data/add_point.sql b/examples/PostGIS - using sqlpage with geographic data/add_point.sql new file mode 100644 index 0000000..32b86bf --- /dev/null +++ b/examples/PostGIS - using sqlpage with geographic data/add_point.sql @@ -0,0 +1,11 @@ +INSERT INTO spatial_data (title, geom, description) +VALUES ( + :Title, + ST_MakePoint( + CAST(:Longitude AS REAL), + CAST(:Latitude AS REAL + ), 4326), + :Text +) RETURNING + 'redirect' AS component, + 'index.sql' AS link; \ No newline at end of file diff --git a/examples/PostGIS - using sqlpage with geographic data/docker-compose.yml b/examples/PostGIS - using sqlpage with geographic data/docker-compose.yml new file mode 100644 index 0000000..a5c3621 --- /dev/null +++ b/examples/PostGIS - using sqlpage with geographic data/docker-compose.yml @@ -0,0 +1,17 @@ +services: + web: + image: lovasoa/sqlpage:main + ports: + - "8080:8080" + volumes: + - .:/var/www + - ./sqlpage:/etc/sqlpage + depends_on: + - db + environment: + DATABASE_URL: postgres://postgres:postgres@db/postgres + db: + image: postgis/postgis:latest + platform: linux/amd64 + environment: + POSTGRES_PASSWORD: postgres diff --git a/examples/PostGIS - using sqlpage with geographic data/edition_form.sql b/examples/PostGIS - using sqlpage with geographic data/edition_form.sql new file mode 100644 index 0000000..1b26ff6 --- /dev/null +++ b/examples/PostGIS - using sqlpage with geographic data/edition_form.sql @@ -0,0 +1,24 @@ +SELECT 'shell' as component, + 'Point edition' AS title, + '/' as link, + 'book' as icon; + +UPDATE spatial_data +SET description = :Text +WHERE id = $id::int AND :Text IS NOT NULL; + +SELECT 'form' AS component, title +FROM spatial_data WHERE id = $id::int; + +SELECT + 'Text' AS name, + 'Description for the point: ' || title AS description, + 'textarea' AS type, + description AS value +FROM spatial_data +WHERE id = $id::int; + +SELECT 'text' as component, + description as contents_md +FROM spatial_data +WHERE id = $id::int; \ No newline at end of file diff --git a/examples/PostGIS - using sqlpage with geographic data/index.sql b/examples/PostGIS - using sqlpage with geographic data/index.sql new file mode 100644 index 0000000..5c58453 --- /dev/null +++ b/examples/PostGIS - using sqlpage with geographic data/index.sql @@ -0,0 +1,38 @@ +SELECT 'shell' as component, + 'Map' AS title, + '/' as link, + 'book' as icon; + +SELECT 'map' AS component, + ST_Y(geom) AS latitude, + ST_X(geom) AS longitude +FROM spatial_data +WHERE id = $id::int; + +SELECT 'map' as component, + 'Points of interest' as title, + 2 as zoom, + 700 as height; +SELECT title, + ST_Y(geom) AS latitude, + ST_X(geom) AS longitude, + format('[View](point.sql?id=%s) [Edit](edition_form.sql?id=%s)', id, id) as description_md +FROM spatial_data; + + +SELECT 'list' as component, + 'My points' as title; +SELECT title, + 'point.sql?id=' || id as link, + 'red' as color, + 'world-pin' as icon +FROM spatial_data; + +SELECT 'form' AS component, + 'Add a point' AS title, + 'add_point.sql' AS action; + +SELECT 'Title' AS name; +SELECT 'Latitude' AS name, 'number' AS type, 0.00000001 AS step; +SELECT 'Longitude' AS name, 'number' AS type, 0.00000001 AS step; +SELECT 'Text' AS name, 'textarea' AS type; \ No newline at end of file diff --git a/examples/PostGIS - using sqlpage with geographic data/point.sql b/examples/PostGIS - using sqlpage with geographic data/point.sql new file mode 100644 index 0000000..41a2268 --- /dev/null +++ b/examples/PostGIS - using sqlpage with geographic data/point.sql @@ -0,0 +1,58 @@ +SELECT 'shell' as component, + title, + '/' as link, + 'index' as menu_item, + 'book' as icon +FROM spatial_data +WHERE id = $id::int; + +SELECT 'datagrid' as component, title FROM spatial_data WHERE id = $id::int; + +SELECT 'Latitude' as title, + ST_Y(geom) as description, + 'purple' as color, + 'world-latitude' as icon +FROM spatial_data WHERE id = $id::int; + +SELECT 'Longitude' as title, + ST_X(geom) as description, + 'purple' as color, + 'world-longitude' as icon +FROM spatial_data WHERE id = $id::int; + +SELECT 'Created at' as title, + created_at as description, + 'calendar' as icon, + 'Date and time of creation' as footer +FROM spatial_data WHERE id = $id::int; + +SELECT 'Label' as title, + title as description, + 'geo:' || ST_Y(geom) || ',' || ST_X(geom) || '?z=16' AS link, + 'blue' as color, + 'world' as icon, + 'User-generated point name' as footer +FROM spatial_data +WHERE id = $id::int; + +SELECT 'text' as component, + description || + format(E'\n\n [Edit description](edition_form.sql?id=%s)', id) + as contents_md +FROM spatial_data +WHERE id = $id::int; + +SELECT 'list' as component, 'Closest points' as title; +SELECT to_label as title, + ROUND(distance::decimal, 3) || ' km' as description, + 'point.sql?id=' || to_id as link, + 'red' as color, + 'world-pin' as icon +FROM distances +WHERE from_id = $id::int +ORDER BY distance +LIMIT 5; + +SELECT 'map' AS component, + ST_Y(geom) AS latitude, ST_X(geom) AS longitude +FROM spatial_data WHERE id = $id::int; \ No newline at end of file diff --git a/examples/PostGIS - using sqlpage with geographic data/screenshots/code.png b/examples/PostGIS - using sqlpage with geographic data/screenshots/code.png new file mode 100644 index 0000000..d22b849 Binary files /dev/null and b/examples/PostGIS - using sqlpage with geographic data/screenshots/code.png differ diff --git a/examples/PostGIS - using sqlpage with geographic data/sqlpage/migrations/0000_create_db.sql b/examples/PostGIS - using sqlpage with geographic data/sqlpage/migrations/0000_create_db.sql new file mode 100644 index 0000000..3d3fb17 --- /dev/null +++ b/examples/PostGIS - using sqlpage with geographic data/sqlpage/migrations/0000_create_db.sql @@ -0,0 +1,24 @@ +CREATE EXTENSION IF NOT EXISTS postgis; + +-- Create a table with a postgis geometry column +CREATE TABLE IF NOT EXISTS spatial_data ( + id serial primary key NOT NULL, + title text NOT NULL, + geom geometry NULL, + description text NOT NULL, + created_at timestamp without time zone NULL DEFAULT CURRENT_TIMESTAMP +); + + +CREATE OR REPLACE VIEW distances AS +SELECT from_point.id AS from_id, + from_point.title AS from_label, + to_point.id AS to_id, + to_point.title AS to_label, + ST_Distance( + from_point.geom, + to_point.geom, + TRUE + ) AS distance +FROM spatial_data AS from_point, spatial_data AS to_point +WHERE from_point.id != to_point.id; \ No newline at end of file diff --git a/examples/PostGIS - using sqlpage with geographic data/sqlpage/sqlpage.json b/examples/PostGIS - using sqlpage with geographic data/sqlpage/sqlpage.json new file mode 100644 index 0000000..5216cfa --- /dev/null +++ b/examples/PostGIS - using sqlpage with geographic data/sqlpage/sqlpage.json @@ -0,0 +1,3 @@ +{ + "database_url": "postgres://my_username:my_password@localhost:5432/my_geographic_database" +} diff --git a/examples/PostGIS - using sqlpage with geographic data/test.hurl b/examples/PostGIS - using sqlpage with geographic data/test.hurl new file mode 100644 index 0000000..3e9154e --- /dev/null +++ b/examples/PostGIS - using sqlpage with geographic data/test.hurl @@ -0,0 +1,53 @@ +GET http://localhost:8080/ +HTTP 200 +[Asserts] +body contains "Points of interest" +body contains "Add a point" +body not contains "An error occurred" + +POST http://localhost:8080/add_point.sql +[FormParams] +Title: Hurl Paris +Latitude: 48.8566 +Longitude: 2.3522 +Text: First Hurl point +HTTP 302 +[Asserts] +header "Location" == "index.sql" + +POST http://localhost:8080/add_point.sql +[FormParams] +Title: Hurl Lyon +Latitude: 45.764 +Longitude: 4.8357 +Text: Second Hurl point +HTTP 302 + +GET http://localhost:8080/ +HTTP 200 +[Captures] +paris_id: xpath "substring-after(string(//a[normalize-space(.)='Hurl Paris']/@href), 'id=')" +[Asserts] +body contains "Hurl Paris" +body contains "Hurl Lyon" +body htmlUnescape contains "point.sql?id={{paris_id}}" +body not contains "An error occurred" + +GET http://localhost:8080/point.sql?id={{paris_id}} +HTTP 200 +[Asserts] +body contains "48.8566" +body contains "2.3522" +body contains "First Hurl point" +body contains "Closest points" +body contains "Hurl Lyon" +body htmlUnescape contains "edition_form.sql?id={{paris_id}}" +body not contains "An error occurred" + +POST http://localhost:8080/edition_form.sql?id={{paris_id}} +[FormParams] +Text: Updated Hurl point +HTTP 200 +[Asserts] +body contains "Updated Hurl point" +body not contains "An error occurred" diff --git a/examples/SQLPage developer user interface/README.md b/examples/SQLPage developer user interface/README.md new file mode 100644 index 0000000..b4a714a --- /dev/null +++ b/examples/SQLPage developer user interface/README.md @@ -0,0 +1,5 @@ +# Website editor + +SQLPage supports rendering `.sql` files that are stored directly inside the database, in a table called `sqlpage_files`. + +This application allows you to edit these files directly from your browser, for an easy in-browser data app authoring experience. \ No newline at end of file diff --git a/examples/SQLPage developer user interface/docker-compose.yml b/examples/SQLPage developer user interface/docker-compose.yml new file mode 100644 index 0000000..9d8374c --- /dev/null +++ b/examples/SQLPage developer user interface/docker-compose.yml @@ -0,0 +1,18 @@ +services: + web: + image: lovasoa/sqlpage:main # main is cutting edge, use sqlpage/SQLPage:latest for the latest stable version + ports: + - "8080:8080" + volumes: + - ./website:/var/www + - ./sqlpage:/etc/sqlpage + depends_on: + - db + environment: + DATABASE_URL: postgres://root:secret@db/sqlpage + db: # The DB environment variable can be set to "mariadb" or "postgres" to test the code with different databases + image: postgres + environment: + POSTGRES_USER: root + POSTGRES_DB: sqlpage + POSTGRES_PASSWORD: secret diff --git a/examples/SQLPage developer user interface/sqlpage/migrations/00001_table_contents.sql b/examples/SQLPage developer user interface/sqlpage/migrations/00001_table_contents.sql new file mode 100644 index 0000000..ab2293f --- /dev/null +++ b/examples/SQLPage developer user interface/sqlpage/migrations/00001_table_contents.sql @@ -0,0 +1,10 @@ +-- Given a table name as text, return the contents of the table as a set of json objects +-- Safely escapes the table name to prevent SQL injection. +-- Accepts only normal tables, not postgres system tables. +CREATE OR REPLACE FUNCTION table_contents (table_name text) +RETURNS SETOF json AS $$ +BEGIN + RETURN QUERY EXECUTE + format('SELECT row_to_json(%I) FROM %I', table_name, table_name); +END; +$$ LANGUAGE plpgsql; \ No newline at end of file diff --git a/examples/SQLPage developer user interface/sqlpage/migrations/0000_create_sqlpage_files_table.sql b/examples/SQLPage developer user interface/sqlpage/migrations/0000_create_sqlpage_files_table.sql new file mode 100644 index 0000000..ebff6cb --- /dev/null +++ b/examples/SQLPage developer user interface/sqlpage/migrations/0000_create_sqlpage_files_table.sql @@ -0,0 +1,20 @@ +CREATE TABLE + sqlpage_files ( + path VARCHAR(255) NOT NULL PRIMARY KEY, + contents BYTEA NOT NULL, + last_modified TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + +-- automatically update last_modified timestamp + +CREATE OR REPLACE FUNCTION update_last_modified_sqlpage_files() +RETURNS TRIGGER AS $$ +BEGIN + NEW.last_modified = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER update_last_modified BEFORE +UPDATE ON sqlpage_files FOR EACH ROW +EXECUTE PROCEDURE update_last_modified_sqlpage_files(); \ No newline at end of file diff --git a/examples/SQLPage developer user interface/sqlpage/sqlpage.json b/examples/SQLPage developer user interface/sqlpage/sqlpage.json new file mode 100644 index 0000000..6759cb6 --- /dev/null +++ b/examples/SQLPage developer user interface/sqlpage/sqlpage.json @@ -0,0 +1,3 @@ +{ + "content_security_policy": "script-src blob: 'self' https://cdn.jsdelivr.net;" +} diff --git a/examples/SQLPage developer user interface/test.hurl b/examples/SQLPage developer user interface/test.hurl new file mode 100644 index 0000000..7784e7d --- /dev/null +++ b/examples/SQLPage developer user interface/test.hurl @@ -0,0 +1,65 @@ +GET http://localhost:8080/ +HTTP 200 +[Asserts] +header "Content-Type" contains "text/html" +body contains "Website files" +body contains "Create new file" +body contains "Database tables" +body contains "sqlpage_files" +body htmlUnescape contains "view_table.sql?table%5Fname=sqlpage%5Ffiles" +body not contains "An error occurred" + +GET http://localhost:8080/edit.sql +HTTP 200 +[Asserts] +body contains "New page" +body contains "insert_file.sql" +body contains "code-editor" +body not contains "An error occurred" + +POST http://localhost:8080/insert_file.sql +[FormParams] +path: hurl_test.sql +contents: SELECT 'text' as component, 'Hello from Hurl' as contents; +HTTP 302 +[Asserts] +header "Location" == "index.sql?inserted=hurl%5Ftest%2Esql" + +GET http://localhost:8080/hurl_test.sql +HTTP 200 +[Asserts] +body contains "Hello from Hurl" +body not contains "An error occurred" + +GET http://localhost:8080/ +HTTP 200 +[Asserts] +body contains "hurl_test.sql" +body htmlUnescape contains "edit.sql?path=hurl%5Ftest%2Esql" +body htmlUnescape contains "delete.sql?path=hurl%5Ftest%2Esql" +body not contains "An error occurred" + +GET http://localhost:8080/view_table.sql?table_name=sqlpage_files +HTTP 200 +[Asserts] +body contains "sqlpage_files" +body contains "hurl_test.sql" +body not contains "An error occurred" + +GET http://localhost:8080/delete.sql?path=hurl_test.sql +HTTP 200 +[Asserts] +body contains "Delete hurl_test.sql ?" +body htmlUnescape contains "delete.sql?path=hurl%5Ftest%2Esql&confirm=yes" +body not contains "An error occurred" + +GET http://localhost:8080/delete.sql?path=hurl_test.sql&confirm=yes +HTTP 302 +[Asserts] +header "Location" == "index.sql?deleted=hurl%5Ftest%2Esql" + +GET http://localhost:8080/ +HTTP 200 +[Asserts] +body not contains "hurl_test.sql" +body not contains "An error occurred" diff --git a/examples/SQLPage developer user interface/website/delete.sql b/examples/SQLPage developer user interface/website/delete.sql new file mode 100644 index 0000000..3b00f60 --- /dev/null +++ b/examples/SQLPage developer user interface/website/delete.sql @@ -0,0 +1,15 @@ +delete from sqlpage_files +where path = $path and $confirm = 'yes' +returning + 'redirect' as component, + sqlpage.link( + 'index.sql', + json_build_object('deleted', $path) + ) as link; + +select 'alert' as component, + 'Delete ' || $path || ' ?' as title, + 'Are you sure you want to delete ' || $path || '?' as description, + 'warning' as color, + sqlpage.link('delete.sql', json_build_object('path', $path, 'confirm', 'yes')) as link, + 'Delete' as link_text; \ No newline at end of file diff --git a/examples/SQLPage developer user interface/website/edit.sql b/examples/SQLPage developer user interface/website/edit.sql new file mode 100644 index 0000000..5ef9784 --- /dev/null +++ b/examples/SQLPage developer user interface/website/edit.sql @@ -0,0 +1,23 @@ +select 'shell' as component, + 'js/code-editor.js' as javascript; + +select + 'form' as component, + COALESCE('Editing ' || $path, 'New page') as title, + 'insert_file.sql' as action; + +select + 'path' as name, + 'text' as type, + 'Name' as label, + $path as value, + 'test.sql' as placeholder; + +select + 'textarea' as type, + 'contents' as name, + 'code-editor' as id, + 'Contents' as label, + (select contents from sqlpage_files where path = $path) as value, + 'SELECT ''text'' as component, + ''Hello, world!'' as contents;' as placeholder; \ No newline at end of file diff --git a/examples/SQLPage developer user interface/website/index.sql b/examples/SQLPage developer user interface/website/index.sql new file mode 100644 index 0000000..41f65de --- /dev/null +++ b/examples/SQLPage developer user interface/website/index.sql @@ -0,0 +1,29 @@ +select + 'list' as component, + 'Website files' as title; + +select + path as title, + path as link, + sqlpage.link ('edit.sql', json_build_object ('path', path)) as edit_link, + sqlpage.link ('delete.sql', json_build_object ('path', path)) as delete_link +from + sqlpage_files; + +select + 'Create new file' as title, + 'edit.sql' as link, + 'file-plus' as icon, + 'green' as color; + +select 'list' as component, + 'Database tables' as title; + +select + table_name as title, + sqlpage.link ('view_table.sql', json_build_object('table_name', table_name)) as link +from + information_schema.tables +where + table_schema = 'public' + and table_type = 'BASE TABLE'; \ No newline at end of file diff --git a/examples/SQLPage developer user interface/website/insert_file.sql b/examples/SQLPage developer user interface/website/insert_file.sql new file mode 100644 index 0000000..3d21fc3 --- /dev/null +++ b/examples/SQLPage developer user interface/website/insert_file.sql @@ -0,0 +1,10 @@ +insert into sqlpage_files (path, contents) +values (:path, :contents::bytea) +on conflict (path) +do update set contents = excluded.contents +returning + 'redirect' as component, + sqlpage.link( + 'index.sql', + json_build_object('inserted', :path) + ) as link; \ No newline at end of file diff --git a/examples/SQLPage developer user interface/website/js/code-editor.js b/examples/SQLPage developer user interface/website/js/code-editor.js new file mode 100644 index 0000000..e8c35f5 --- /dev/null +++ b/examples/SQLPage developer user interface/website/js/code-editor.js @@ -0,0 +1,34 @@ +const cdn = "https://cdn.jsdelivr.net/npm/monaco-editor@0.50.0/"; + +function on_monaco_load() { + // Create an editor div, display it after the '#code-editor' textarea, hide the textarea, and create a Monaco editor in the div with the contents of the textarea + // When the form is submitted, set the value of the textarea to the value of the Monaco editor + const textarea = document.getElementById("code-editor"); + const editorDiv = document.createElement("div"); + editorDiv.style.width = "100%"; + editorDiv.style.height = "700px"; + textarea.parentNode.insertBefore(editorDiv, textarea.nextSibling); + const monacoConfig = { + value: textarea.value, + language: "sql", + }; + + self.MonacoEnvironment = { + baseUrl: `${cdn}min/`, + }; + const editor = monaco.editor.create(editorDiv, monacoConfig); + textarea.style.display = "none"; + const form = textarea.form; + form.addEventListener("submit", () => { + textarea.value = editor.getValue(); + }); +} + +function set_require_config() { + require.config({ paths: { vs: `${cdn}min/vs` } }); + require(["vs/editor/editor.main"], on_monaco_load); +} +const loader_script = document.createElement("script"); +loader_script.src = `${cdn}min/vs/loader.js`; +loader_script.onload = set_require_config; +document.head.appendChild(loader_script); diff --git a/examples/SQLPage developer user interface/website/view_table.sql b/examples/SQLPage developer user interface/website/view_table.sql new file mode 100644 index 0000000..c52779f --- /dev/null +++ b/examples/SQLPage developer user interface/website/view_table.sql @@ -0,0 +1,15 @@ +select 'title' as component, $table_name as contents; +select 'table' as component, true as search, true as sort; + +select 'dynamic' as component, t as properties +from table_contents($table_name) t +LIMIT 1000; + +select 'alert' as component, + CASE + WHEN COUNT(*) = 0 THEN 'The table is empty.' + WHEN COUNT(*) > 1000 THEN 'Only the first 1000 rows are shown.' + END as description, + 'info' as color +from table_contents($table_name) +HAVING NOT COUNT(*) BETWEEN 1 AND 1000; \ No newline at end of file diff --git a/examples/cards-with-remote-content/README.md b/examples/cards-with-remote-content/README.md new file mode 100644 index 0000000..7ed2f05 --- /dev/null +++ b/examples/cards-with-remote-content/README.md @@ -0,0 +1,10 @@ +# Remote Content Demo + +This small SQLPage example shows how to: + - lazy-load other page in cards including: + - chart component rendered by sqlpage + - map component rendered by sqlpage + - table component rendered by sqlpage + +![remote content screenshot](screenshot.png) + diff --git a/examples/cards-with-remote-content/chart-example.sql b/examples/cards-with-remote-content/chart-example.sql new file mode 100644 index 0000000..651da48 --- /dev/null +++ b/examples/cards-with-remote-content/chart-example.sql @@ -0,0 +1,30 @@ +select + 'chart' as component, + 'Quarterly Revenue' as title, + 'area' as type, + 'indigo' as color, + 5 as marker, + TRUE as time; +select + '2022-01-01T00:00:00Z' as x, + 15 as y; +select + '2022-04-01T00:00:00Z' as x, + 46 as y; +select + '2022-07-01T00:00:00Z' as x, + 23 as y; +select + '2022-10-01T00:00:00Z' as x, + 70 as y; +select + '2023-01-01T00:00:00Z' as x, + 35 as y; +select + '2023-04-01T00:00:00Z' as x, + 106 as y; +select + '2023-07-01T00:00:00Z' as x, + 53 as y; + + diff --git a/examples/cards-with-remote-content/docker-compose.yml b/examples/cards-with-remote-content/docker-compose.yml new file mode 100644 index 0000000..8d0813b --- /dev/null +++ b/examples/cards-with-remote-content/docker-compose.yml @@ -0,0 +1,7 @@ +services: + web: + image: lovasoa/sqlpage:main + ports: + - "8080:8080" + volumes: + - .:/var/www diff --git a/examples/cards-with-remote-content/index.sql b/examples/cards-with-remote-content/index.sql new file mode 100644 index 0000000..825f9cc --- /dev/null +++ b/examples/cards-with-remote-content/index.sql @@ -0,0 +1,21 @@ +select + 'card' as component, + 2 as columns; +select + 'A card with a Markdown description' as title, + 'This is a card with a **Markdown** description. + +This is useful if you want to display a lot of text in the card, with many options for formatting, such as + - **bold**, + - *italics*, + - [links](index.sql), + - etc.' as description_md; +select + 'A card with lazy-loaded chart' as title, + '/chart-example.sql?_sqlpage_embed' as embed; +select + 'A card with lazy-loaded map' as title, + '/map-example.sql?_sqlpage_embed' as embed; +select + 'A card with lazy-loaded table' as title, + '/table-example.sql?_sqlpage_embed' as embed; diff --git a/examples/cards-with-remote-content/map-example.sql b/examples/cards-with-remote-content/map-example.sql new file mode 100644 index 0000000..18e3465 --- /dev/null +++ b/examples/cards-with-remote-content/map-example.sql @@ -0,0 +1,9 @@ +select + 'map' as component, + 1 as zoom; +select + 'New Delhi' as title, + 28.6139 as latitude, + 77.209 as longitude; + + diff --git a/examples/cards-with-remote-content/screenshot.png b/examples/cards-with-remote-content/screenshot.png new file mode 100644 index 0000000..1b891c6 Binary files /dev/null and b/examples/cards-with-remote-content/screenshot.png differ diff --git a/examples/cards-with-remote-content/table-example.sql b/examples/cards-with-remote-content/table-example.sql new file mode 100644 index 0000000..15ba37f --- /dev/null +++ b/examples/cards-with-remote-content/table-example.sql @@ -0,0 +1,13 @@ +select + 'table' as component, + TRUE as sort, + TRUE as search; +select + 'Ophir' as Forename, + 'Lojkine' as Surname, + 'lovasoa' as Pseudonym; +select + 'Linus' as Forename, + 'Torvalds' as Surname, + 'torvalds' as Pseudonym; + diff --git a/examples/cards-with-remote-content/test.hurl b/examples/cards-with-remote-content/test.hurl new file mode 100644 index 0000000..627dd51 --- /dev/null +++ b/examples/cards-with-remote-content/test.hurl @@ -0,0 +1,32 @@ +GET http://localhost:8080/ +HTTP 200 +[Asserts] +xpath "count(//div[contains(@class, 'card')])" >= 4 +body contains "A card with a Markdown description" +body contains "Markdown" +body htmlUnescape contains "/chart-example.sql?_sqlpage_embed" +body htmlUnescape contains "/map-example.sql?_sqlpage_embed" +body htmlUnescape contains "/table-example.sql?_sqlpage_embed" +body not contains "An error occurred" + +GET http://localhost:8080/chart-example.sql?_sqlpage_embed +HTTP 200 +[Asserts] +xpath "normalize-space(//*[contains(., 'Quarterly Revenue')])" contains "Quarterly Revenue" +body contains "2023-07-01T00:00:00Z" +body not contains "An error occurred" + +GET http://localhost:8080/map-example.sql?_sqlpage_embed +HTTP 200 +[Asserts] +body contains "New Delhi" +body contains "28.6139" +body not contains "An error occurred" + +GET http://localhost:8080/table-example.sql?_sqlpage_embed +HTTP 200 +[Asserts] +xpath "normalize-space(//*[contains(., 'Forename')])" contains "Forename" +body contains "Ophir" +body contains "Torvalds" +body not contains "An error occurred" diff --git a/examples/charts, computations and custom components/README.md b/examples/charts, computations and custom components/README.md new file mode 100644 index 0000000..e3fc231 --- /dev/null +++ b/examples/charts, computations and custom components/README.md @@ -0,0 +1,21 @@ +# TapTempo + +This small SQLPage example shows how to : + - save request logs to the database + - display graphs + - make computations on the database + - write a custom component with HTML and CSS + +## TapTempo: a webapp to measure the tempo of a song + +TapTempo is a webapp that lets you measure the tempo of a song by tapping on a button in rhythm with the music. + +It is a simple example of a webapp that stores data in a database, and displays graphs. + +### Implementation + +At each tap, the webapp sends a request to the server, which stores the timestamp of the tap in the `tap` table. + +A [window function](https://www.sqlite.org/windowfunctions.html) is used to compute the tempo of the song from the timestamps of the last two taps. + +![taptempo screenshot](screenshot.png) \ No newline at end of file diff --git a/examples/charts, computations and custom components/docker-compose.yml b/examples/charts, computations and custom components/docker-compose.yml new file mode 100644 index 0000000..7305eda --- /dev/null +++ b/examples/charts, computations and custom components/docker-compose.yml @@ -0,0 +1,8 @@ +services: + web: + image: lovasoa/sqlpage:main + ports: + - "8080:8080" + volumes: + - .:/var/www + - ./sqlpage:/etc/sqlpage diff --git a/examples/charts, computations and custom components/drums by Nana Yaw Otoo.jpg b/examples/charts, computations and custom components/drums by Nana Yaw Otoo.jpg new file mode 100644 index 0000000..020cd6a Binary files /dev/null and b/examples/charts, computations and custom components/drums by Nana Yaw Otoo.jpg differ diff --git a/examples/charts, computations and custom components/index.sql b/examples/charts, computations and custom components/index.sql new file mode 100644 index 0000000..eca56fa --- /dev/null +++ b/examples/charts, computations and custom components/index.sql @@ -0,0 +1,37 @@ +SELECT 'shell' AS component, + 'Tap Tempo' as title, + '/' as link, + 'en-US' as lang, + 'A tool to measure a tempo in bpm by clicking a button in rythm.' as description, + 'Vollkorn' as font, + 'music' as icon, + 'Proudly powered by [SQLPage](https://sql-page.com)' as footer; + +SELECT 'hero' as component, + 'Tap Tempo' as title, + 'Tap Tempo is a tool to **measure a tempo in bpm** by clicking a button in rythm.' as description_md, + 'drums by Nana Yaw Otoo.jpg' as image, + sqlpage.link('taptempo.sql', json_object('session', random())) as link, + 'Start tapping !' as link_text; + +SELECT 'text' as component, + 'About TapTempo' as title, + ' +## Context + +This tool is written in the SQL database query language, and uses the [SQLPage](https://sql-page.com) framework to generate the web interface. + +It serves as a demo for the framework. + +If what you really want is to measure a tempo, not learn about website building and databases, +you should probably use something else, that does not require a web server and a database to run 😉. + +## History + +There is a large family of implementations of the tap tempo tool. + +It originates from a french linux discussion website, [linuxfr.org](https://linuxfr.org/), where it was implemented in C++ by [mzf](https://linuxfr.org/users/mzf). + +[See alternative implementations](https://linuxfr.org/wiki/taptempo). + + ' as contents_md; \ No newline at end of file diff --git a/examples/charts, computations and custom components/screenshot.png b/examples/charts, computations and custom components/screenshot.png new file mode 100644 index 0000000..e4d1156 Binary files /dev/null and b/examples/charts, computations and custom components/screenshot.png differ diff --git a/examples/charts, computations and custom components/sqlpage/migrations/000_init.sql b/examples/charts, computations and custom components/sqlpage/migrations/000_init.sql new file mode 100644 index 0000000..584ec01 --- /dev/null +++ b/examples/charts, computations and custom components/sqlpage/migrations/000_init.sql @@ -0,0 +1,19 @@ +CREATE TABLE tap( + tapping_session INTEGER, + day REAL NOT NULL, -- fractional julian day, easy to manipulate in SQLite + PRIMARY KEY (tapping_session, day) +); + +CREATE VIEW tap_bpm AS +SELECT + *, + CAST( + 1 / ((24 * 60) * (day - previous)) + AS INTEGER + ) AS bpm +FROM ( + SELECT + *, + lag(day) OVER (ORDER BY day) AS previous + FROM tap +); \ No newline at end of file diff --git a/examples/charts, computations and custom components/sqlpage/templates/big_button.handlebars b/examples/charts, computations and custom components/sqlpage/templates/big_button.handlebars new file mode 100644 index 0000000..3799b5f --- /dev/null +++ b/examples/charts, computations and custom components/sqlpage/templates/big_button.handlebars @@ -0,0 +1,31 @@ +{{text}} + + diff --git a/examples/charts, computations and custom components/taptempo.sql b/examples/charts, computations and custom components/taptempo.sql new file mode 100644 index 0000000..b36ce79 --- /dev/null +++ b/examples/charts, computations and custom components/taptempo.sql @@ -0,0 +1,21 @@ +SELECT 'shell' AS component, 'Tap Tempo' as title, 'Vollkorn' as font, 'music' as icon; + +-- See https://www.sqlite.org/lang_datefunc.html (unixepoch is not available in the current version of SQLite) +INSERT INTO tap(tapping_session, day) VALUES ($session, julianday('now')); + +SELECT 'big_button' as component, + COALESCE( + (SELECT bpm || ' bpm' FROM tap_bpm WHERE tapping_session = $session ORDER BY day DESC LIMIT 1), + 'Tap' + ) AS text, + sqlpage.link('taptempo.sql', json_object('session', $session)) as link; + +SELECT 'chart' as component, 'BPM over time' as title, 'area' as type, 'indigo' as color, 0 AS ymin, 200 AS ymax, 'BPM' as ytitle; +SELECT * FROM ( + SELECT + strftime('%H:%M:%f', day) AS x, + bpm AS y + FROM tap_bpm + WHERE tapping_session = $session + ORDER BY day DESC LIMIT 10 +) ORDER BY x ASC; \ No newline at end of file diff --git a/examples/charts, computations and custom components/test.hurl b/examples/charts, computations and custom components/test.hurl new file mode 100644 index 0000000..49b2012 --- /dev/null +++ b/examples/charts, computations and custom components/test.hurl @@ -0,0 +1,22 @@ +GET http://localhost:8080/ +HTTP 200 +[Captures] +start_url: xpath "string(//a[normalize-space()='Start tapping !']/@href)" +[Asserts] +body contains "Tap Tempo is a tool to" +body contains "measure a tempo in bpm" +body contains "About TapTempo" + +GET http://localhost:8080/{{start_url}} +HTTP 200 +[Captures] +tap_url: xpath "string(//a[normalize-space()='Tap']/@href)" +[Asserts] +body contains "Tap" +body contains "BPM over time" + +GET http://localhost:8080/{{tap_url}} +HTTP 200 +[Asserts] +body contains " bpm" +body contains "BPM over time" diff --git a/examples/corporate-conundrum/.gitignore b/examples/corporate-conundrum/.gitignore new file mode 100644 index 0000000..17be6bb --- /dev/null +++ b/examples/corporate-conundrum/.gitignore @@ -0,0 +1 @@ +sqlpage.db diff --git a/examples/corporate-conundrum/Dockerfile b/examples/corporate-conundrum/Dockerfile new file mode 100644 index 0000000..e7d8eb0 --- /dev/null +++ b/examples/corporate-conundrum/Dockerfile @@ -0,0 +1,4 @@ +FROM lovasoa/sqlpage:main + +COPY ./sqlpage /etc/sqlpage +COPY . /var/www \ No newline at end of file diff --git a/examples/corporate-conundrum/New Game.sql b/examples/corporate-conundrum/New Game.sql new file mode 100644 index 0000000..289ccc5 --- /dev/null +++ b/examples/corporate-conundrum/New Game.sql @@ -0,0 +1,5 @@ +INSERT INTO games(id) +VALUES(random()) +RETURNING + 'redirect' as component, + CONCAT('game.sql?id=', id) as link; \ No newline at end of file diff --git a/examples/corporate-conundrum/README.md b/examples/corporate-conundrum/README.md new file mode 100644 index 0000000..32a9492 --- /dev/null +++ b/examples/corporate-conundrum/README.md @@ -0,0 +1,38 @@ +# Corporate Conundrum: Decisions in Disguise + +This is the web version of a board game, coded entirely in SQL. + +## Pitch +Unleash your inner executive as you step into the shoes of top-level employees in a high-stakes corporate meeting. But beware, among your trusted colleagues lurks a cunning infiltrator sent by a rival company to sabotage your decision-making process. Will you be able to outsmart the saboteur and make the right choices to lead your company to success? + +## Rules + +**Objective**: As a team of genuine employees, your goal is to make accurate decisions based on challenging questions. The infiltrator's objective is to sway you toward incorrect answers. + +**Gameplay**: Each turn, a question will be presented to the group. One player, secretly assigned as the infiltrator, will receive a specific wrong answer. They must cunningly lead others astray, while you must collaborate and deduce the correct answer. + +**Discussion Phase**: Engage in lively debates and exchange ideas to uncover the truth. Analyze arguments, question motives, and use your critical thinking skills to navigate the murky waters of corporate deception. + +**Hidden Votes**: After the discussion phase, all players simultaneously submit their individual answers privately, without revealing them to others. + +**Scoring System**: Points are awarded based on the proximity of each player's answer to the true answer. + + - If a player's answer is closer to the true answer than to the wrong answer provided by the saboteur, they earn one point. + - Conversely, if a player's answer is closer to the wrong answer, they inadvertently contribute one point to the saboteur's score. + +**Continuing Gameplay**: The game progresses with new questions and role assignments, allowing each player to take turns as the infiltrator. The player with the highest score at the end of the predetermined number of rounds wins the game. + + +## Web app usage + +Create a new game by clicking the "New Game" button. You will be prompted to add new players to the game by adding their names. Share the game URL with other players so they can join the game. + +Role Assignment: The web app will randomly assign roles to players, designating one as the infiltrator. + +Question and Wrong Answer: The web app will display the question to all players, and only the infiltrator will see an additional mention with the assigned wrong answer. + +Discussion and Decision-making: You need to be in the same room as the other players, and discuss the question and the wrong answer. The infiltrator will try to convince you to choose the wrong answer. + +Answer Submission: Once the discussion phase ends, enter your individual answer into the web app. The web app will track points earned based on the accuracy of each player's answer. + +Get ready to immerse yourself in the thrilling world of corporate espionage, where every decision is shrouded in uncertainty. Can you decipher the truth from deceit and lead your company to victory? Prepare for "Corporate Conundrum: Decisions in Disguise" and prove your mettle as a strategic mastermind! \ No newline at end of file diff --git a/examples/corporate-conundrum/docker-compose.yml b/examples/corporate-conundrum/docker-compose.yml new file mode 100644 index 0000000..90844c7 --- /dev/null +++ b/examples/corporate-conundrum/docker-compose.yml @@ -0,0 +1,7 @@ +services: + web: + build: . + ports: + - "8080:8080" + environment: + DATABASE_URL: sqlite:///tmp/corporate-conundrum.db?mode=rwc diff --git a/examples/corporate-conundrum/favicon.ico b/examples/corporate-conundrum/favicon.ico new file mode 100644 index 0000000..0b20f5f Binary files /dev/null and b/examples/corporate-conundrum/favicon.ico differ diff --git a/examples/corporate-conundrum/game-over.sql b/examples/corporate-conundrum/game-over.sql new file mode 100644 index 0000000..0a40059 --- /dev/null +++ b/examples/corporate-conundrum/game-over.sql @@ -0,0 +1,44 @@ +select * FROM sqlpage_shell; + +-- Game over, questions breakdown +select 'card' as component, + 2 AS columns, + 'The game is over' as title, + 'Breaking down the results per question' as description; +SELECT question_text as title, + 'The true answer was ' || true_answer || '. ' || impostor || ' tried to make everyone think it was ' || wrong_answer || '. ' || ( + select group_concat( + player_name || ' voted ' || answer_value || CASE + WHEN abs(answer_value - true_answer) < abs(answer_value - wrong_answer) + THEN ' and earned a point' + WHEN player_name = impostor + THEN ' and did not earn a point' + ELSE ' and gave a point to ' || impostor + END, + ', ' + ) + from answers + where answers.game_id = $game_id::integer + and answers.question_id = questions.id + ) || '.' as description, + explanation as footer +FROM game_questions + JOIN questions ON game_questions.question_id = questions.id +WHERE game_id = $game_id::integer +ORDER BY game_order; + +-- Point count +SELECT 'chart' as component, + 'Scores' as title, + 'bar' as type; +SELECT name as label, + ( + select sum( + (players.name = player_name AND NOT impostor_won) + OR (players.name != player_name AND players.name = impostor AND impostor_won) + ) + FROM game_results WHERE game_id = $game_id + ) as value +FROM players +WHERE game_id = $game_id::integer +ORDER BY value DESC; \ No newline at end of file diff --git a/examples/corporate-conundrum/game.sql b/examples/corporate-conundrum/game.sql new file mode 100644 index 0000000..ab620aa --- /dev/null +++ b/examples/corporate-conundrum/game.sql @@ -0,0 +1,57 @@ +select * FROM sqlpage_shell; + +-- Display the list of players with a link for each one to start playing +INSERT INTO players(name, game_id) +SELECT $Player as name, + CAST($id AS INTEGER) as game_id +WHERE $Player IS NOT NULL; + +SELECT 'list' as component, + 'Players' as title; +SELECT name as title, + sqlpage.link( + 'next-question.sql', + json_object( + 'game_id', game_id, + 'player', name + ) + ) as link +FROM players +WHERE game_id = CAST($id AS INTEGER); +--------------------------- +-- Player insertion form -- +--------------------------- +SELECT 'form' as component, + 'Add a new player' as title, + 'Add to game' as validate; +SELECT 'Player' as name, + 'Player name' as placeholder, + TRUE as autofocus; +-- Insert a new question into the game_questions table for each new player +INSERT INTO game_questions( + game_id, + question_id, + wrong_answer, + impostor, + game_order + ) +SELECT CAST($id AS INTEGER) as game_id, + questions.id as question_id, + -- When the true answer is small, set the wrong answer to just +/- 1, otherwise -25%/+75%. + -- When it is a date between 1200 and 2100, make it -25 % or +75 % of the distance to today + CAST(CASE + WHEN questions.true_answer < 10 THEN questions.true_answer + 1 - 2 * abs(random() %2) -- wrong answer = true answer +/- 1 + WHEN questions.true_answer > 1200 + AND questions.true_answer < 2100 THEN 2023 - (2023 - questions.true_answer) * (abs(random() %2) + 0.75) -- wrong answer = true answer -25% or +75% of the distance to today + ELSE questions.true_answer * (abs(random() %2) + 0.75) + END AS INTEGER) as wrong_answer, + -- wrong answer = true answer +/- 50% TODO: better wrong answer generation + $Player as impostor, + random() as game_order +FROM questions + LEFT JOIN game_questions ON questions.id = game_questions.question_id + AND game_questions.game_id = CAST($id AS INTEGER) +WHERE game_questions.question_id IS NULL + AND $Player IS NOT NULL +ORDER BY random() +LIMIT 1; diff --git a/examples/corporate-conundrum/index.sql b/examples/corporate-conundrum/index.sql new file mode 100644 index 0000000..bfc228e --- /dev/null +++ b/examples/corporate-conundrum/index.sql @@ -0,0 +1,22 @@ +select * FROM sqlpage_shell; +SELECT 'hero' as component, + 'Welcome to Corporate Conundrum' as title, + 'Unleash your inner executive in this thrilling board game of corporate espionage. Make the right choices to lead your company to success!' as description, + 'New Game.sql' as link, + 'Start a New Game' as link_text; +SELECT 'Lively discussions' as title, + 'Each turn, a question will be presented to the group. One player will be assigned as the infiltrator and receive a specific wrong answer. Engage in lively real-life debates and exchange ideas to uncover the truth and make accurate decisions.' as description, + 'help-hexagon' as icon, + 'blue' as color; +SELECT 'Hidden Votes' as title, + 'After the discussion phase, all players submit their individual answers privately. Points are awarded based on their proximity to the true answer.' as description, + 'file' as icon, + 'green' as color; +SELECT 'Role Assignment' as title, + 'The web app randomly assigns roles to players, designating one as the infiltrator. The infiltrator''s objective is to sway others toward incorrect answers, while the team tries to collaborate and deduce the correct answer.' as description, + 'spy' as icon, + 'red' as color; +SELECT 'Continuing Gameplay' as title, + 'The game progresses with new questions and role assignments, allowing each player to take turns as the infiltrator. The player with the highest score at the end of the predetermined number of rounds wins the game.' as description, + 'player-play' as icon, + 'purple' as color; diff --git a/examples/corporate-conundrum/next-question.sql b/examples/corporate-conundrum/next-question.sql new file mode 100644 index 0000000..177cc71 --- /dev/null +++ b/examples/corporate-conundrum/next-question.sql @@ -0,0 +1,32 @@ +-- We need to redirect the user to the next question in the game if there is one, or to the game over page if there are no more questions. +with next_question as ( + SELECT + 'question.sql' as page, + json_object( + 'game_id', $game_id, + 'question_id', game_questions.question_id, + 'player', $player + ) as params + FROM game_questions + WHERE game_id = $game_id::integer + AND NOT EXISTS ( + -- This will filter out questions that have already been answered by the player + SELECT 1 + FROM answers + WHERE answers.game_id = game_questions.game_id + AND answers.player_name = $player + AND answers.question_id = game_questions.question_id + ) + ORDER BY game_order + LIMIT 1 +), +next_page as ( + SELECT * FROM next_question + UNION ALL + SELECT 'game-over.sql' as page, json_object('game_id', $game_id) as params + WHERE NOT EXISTS (SELECT 1 FROM next_question) +) +SELECT 'redirect' as component, + sqlpage.link(page, params) as link +FROM next_page +LIMIT 1; \ No newline at end of file diff --git a/examples/corporate-conundrum/question.sql b/examples/corporate-conundrum/question.sql new file mode 100644 index 0000000..2ccdd2d --- /dev/null +++ b/examples/corporate-conundrum/question.sql @@ -0,0 +1,34 @@ +select * FROM sqlpage_shell; + +SELECT 'form' as component, + question_text as title, + 'Submit your answer' as validate, + sqlpage.link('wait.sql', json_object('game_id', $game_id, 'question_id', $question_id, 'player', $player)) as action +FROM questions +where id = $question_id::integer; + +SELECT 'answer' as name, + CASE + $player + WHEN impostor THEN 'Try to trick the other players into answering: ' || wrong_answer || ', but try to make your own answer correct.' + ELSE 'Discuss the question with the other players and then submit your answer' + END as label, + 'Your answer' as placeholder, + 'number' as type, + TRUE as required, + TRUE as autofocus, + 0 as min +FROM game_questions +WHERE game_id = $game_id::integer + AND question_id = $question_id::integer; + +SELECT 'alert' as component, + 'red' as color, + 'Make them guess: ' || wrong_answer as title, + 'You are the impostor! + Your goal is to sabotage the game by making others give an answer that will be closer to ' || wrong_answer || ' than to the true answer. + The more other players you manage to trick, the more points you will get.' as description +FROM game_questions +WHERE game_id = $game_id::integer + AND impostor = $player + AND question_id = $question_id::integer; \ No newline at end of file diff --git a/examples/corporate-conundrum/rules.sql b/examples/corporate-conundrum/rules.sql new file mode 100644 index 0000000..7ac0bd4 --- /dev/null +++ b/examples/corporate-conundrum/rules.sql @@ -0,0 +1,30 @@ +select * FROM sqlpage_shell; +SELECT 'steps' as component, + 1 as counter, + 'cyan' as color, + 'Game rules' as title; +SELECT 'Create game' as title, + 'plus' as icon, + 'Create a new game from the home page.' as description; +SELECT 'Add players' as title, + 'user-plus' as icon, + 'Add players by their name, and send them their own unique game URL.' as description; +SELECT 'Answer questions' as title, + 'Answer trivia questions to get points. Don''t be fooled by the imposter.' as description, + 'help-hexagon' as icon; +SELECT 'Trick the others' as title, + 'When you become the imposter, try to trick the others into giving wrong answers.' as description, + 'help-hexagon' as icon; +SELECT 'The smartest wins' as title, + 'In the end, the game counts your points. You win if you tricked the others and did not get tricked.' as description, + 'brain' as icon; + +select 'card' as component, 1 as columns; +SELECT 'Objective' as title, 'As a team of genuine employees, your goal is to make accurate decisions based on challenging questions. The infiltrator''s objective is to sway you toward incorrect answers.' as description; +SELECT 'Gameplay' as title, 'Each turn, a question will be presented to the group. One player, secretly assigned as the infiltrator, will receive a specific wrong answer. They must cunningly lead others astray, while you must collaborate and deduce the correct answer. Every player will be the infiltrator once during the game.' as description; +SELECT 'Discussion Phase' as title, 'Engage in lively debates and exchange ideas to uncover the truth. Analyze arguments, question motives, and use your critical thinking skills to navigate the murky waters of corporate deception.' as description; +SELECT 'Hidden Votes' as title, 'After the discussion phase, all players simultaneously submit their individual answers privately, without revealing them to others. Votes will not be revealed until the end of the game.' as description; +SELECT 'Scoring System' as title, 'Points are awarded based on the proximity of each player''s answer to the true answer. +If a player''s answer is closer to the true answer than to the wrong answer provided by the saboteur, they earn one point. +Conversely, if a player''s answer is closer to the wrong answer, they inadvertently contribute one point to the saboteur''s score.' as description; +SELECT 'Continuing Gameplay' as title, 'The game progresses with new questions and role assignments, allowing each player to take turns as the infiltrator. The player with the highest score at the end of the predetermined number of rounds wins the game.' as description; \ No newline at end of file diff --git a/examples/corporate-conundrum/sqlpage/migrations/00_base_structure.sql b/examples/corporate-conundrum/sqlpage/migrations/00_base_structure.sql new file mode 100644 index 0000000..52e09f6 --- /dev/null +++ b/examples/corporate-conundrum/sqlpage/migrations/00_base_structure.sql @@ -0,0 +1,63 @@ +CREATE TABLE games ( + id INTEGER PRIMARY KEY, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE players ( + name TEXT NOT NULL, + game_id INTEGER NOT NULL, + PRIMARY KEY (name, game_id), + FOREIGN KEY (game_id) REFERENCES games(id) +); + +CREATE TABLE questions ( + id INTEGER PRIMARY KEY, + question_text TEXT, + category CHAR(4), + explanation TEXT, + true_answer INTEGER +); + +CREATE TABLE game_questions ( + game_id INTEGER, + question_id INTEGER, + wrong_answer INTEGER, -- the wrong answer that the impostor will try to convince others is correct + impostor TEXT, -- the player who will receive the wrong answer + game_order INTEGER, -- indicates the order in which the questions are asked + PRIMARY KEY (game_id, question_id), + FOREIGN KEY (question_id) REFERENCES questions(id), + FOREIGN KEY (game_id, impostor) REFERENCES players(game_id, name) +); + +CREATE TABLE answers ( + game_id INTEGER, + player_name TEXT, + question_id INTEGER, + answer_value INTEGER, + PRIMARY KEY (game_id, question_id, player_name), + FOREIGN KEY (game_id) REFERENCES games(id), + FOREIGN KEY (player_name, game_id) REFERENCES players(name, game_id), + FOREIGN KEY (question_id) REFERENCES questions(id) +); + +CREATE VIEW game_results AS +SELECT answers.game_id, + player_name, + impostor, + abs(answer_value - true_answer) > abs(answer_value - wrong_answer) as impostor_won +FROM answers + INNER JOIN game_questions ON answers.question_id = game_questions.question_id AND answers.game_id = game_questions.game_id + INNER JOIN questions ON game_questions.question_id = questions.id; + +-- transform the above to a create view +CREATE VIEW sqlpage_shell AS +SELECT + 'shell' AS component, + 'Corporate Conundrum' AS title, + 'Unleash your inner executive in this thrilling board game of corporate espionage. Make the right choices to lead your company to success!' AS description, + 'affiliate' AS icon, + '/' AS link, + '["New Game", "rules"]' AS menu_item, + 'Libre Baskerville' AS font, + 'en-US' AS lang +; \ No newline at end of file diff --git a/examples/corporate-conundrum/sqlpage/migrations/01_questions.sql b/examples/corporate-conundrum/sqlpage/migrations/01_questions.sql new file mode 100644 index 0000000..116f6a4 --- /dev/null +++ b/examples/corporate-conundrum/sqlpage/migrations/01_questions.sql @@ -0,0 +1,497 @@ +INSERT INTO questions ( + question_text, + category, + explanation, + true_answer + ) +VALUES ( + 'How tall is the Eiffel Tower in meters (including the tip)?', + 'GEOG', + 'The Eiffel Tower stands at a height of 330 meters, including the tip. This measurement includes the pinnacle that crowns the tower, providing an accurate representation of its total height from the ground to the highest point.', + 330 + ), + ( + 'When was the first submarine built?', + 'HIST', + 'The first submersible of whose construction there exists reliable information was designed and built in 1620 by Cornelis Drebbel, a Dutchman in the service of James I of England.', + 1620 + ), + ( + 'How many planets are in our solar system?', + 'SCIE', + 'There are eight planets in our solar system: Mercury, Venus, Earth, Mars, Jupiter, Saturn, Uranus, and Neptune.', + 8 + ), + ( + 'How many bones are there in the human body?', + 'SCIE', + 'The human body has 206 bones. This count includes all the bones in the skeleton, ranging from the tiny bones in the ear to the larger bones in the arms, legs, spine, and skull. The number of bones can vary slightly due to fusion of certain bones during growth and development.', + 206 + ), + ( + 'How many symphonies did Ludwig van Beethoven compose?', + 'MUSI', + 'Recognised as one of the greatest and most influential composers of the Western classical tradition, he defied the onset of deafness from the age of 28 to produce an output that encompasses 722 works, including 9 symphonies, 35 piano sonatas and 16 string quartets.', + 9 + ), + ( + 'In what year did George Washington die?', + 'HIST', + 'George Washington (February 22, 1732 – December 14, 1799) was an American political leader, military general, statesman, and Founding Father who served as the first president of the United States from 1789 to 1797.', + 1799 + ), + ( + 'What is the size (diameter) of Jupiter in kilometers ?', + 'SCIE', + 'Jupiter is the largest planet in our solar system at nearly 11 times the size of Earth and 317 times its mass.', + 139820 + ), + ( + 'What is the population of Australia in millions ?', + 'GEOG', + 'The population of Australia is estimated to be around 27 millions in 2023. Australia is the 55th most populous country in the world.', + 27 + ), + ( + 'what is the population of Kazakhstan in millions ?', + 'GEOG', + 'It has a population of 19 million people and one of the lowest population densities in the world, at fewer than 6 people per square kilometre.', + 19 + ), + ( + 'How many stairs are there in the Eiffel Tower?', + 'GEOG', + 'There are 1665 steps in the Eiffel Tower.', + 1665 + ), + ( + 'How heavy is the Empire State Building in tons?', + 'GEOG', + 'The Empire State Building weighs approximately 365,000 tons.', + 365000 + ), + ( + 'How many people die in car crashes globally every day?', + 'SCIE', + 'Approximately 3,700 people die in car crashes every day around the world.', + 3700 + ), + ( + 'How many liters of pure alcohol do people drink per year in the world ?', + 'SCIE', + 'The average person (aged 15 years or older) consumes 6 liters of pure alcohol per year, according to the latest data from the World Health Organization.', + 6 + ), + ( + 'In what year was Nigeria formed ?', + 'HIST', + 'Lagos was occupied by British forces in 1851 and formally annexed by Britain in the year 1865. Nigeria became a British protectorate in 1901.The period of British rule lasted until 1960 when an independence movement led to the country being granted independence.', + 1960 + ), + ( + 'In what year was Gandhi assassinated ?', + 'HIST', + 'Mohandas Karamchand Gandhi was assassinated on 30 January 1948 in the compound of Birla House (now Gandhi Smriti), a large mansion in New Delhi.', + 1948 + ), + ( + 'How many babies are born each second in the world ?', + 'SCIE', + 'Around 140 million babies are born every year in the world. That''s a little more than four births every second of every day.', + 4 + ), + ( + 'How old was the oldest iguana to ever live ?', + 'SCIE', + 'An almost 41-year-old Rhinoceros Iguana in Australia Zoo has made won an honour for world''s oldest living one in captivity. Iguana named Rhino, which is soon to turn 41 received the Guinness World Record for being the World''s Oldest Iguana.', + 41 + ), + ( + 'How many countries are there in Africa?', + 'GEOG', + 'There are 54 countries in Africa.', + 54 + ), + ( + 'How many players are there on a polo team?', + 'SPORTS', + 'A polo team is comprised of four players.The object of the game is to move the polo ball down the field, + hitting the ball through the goal posts to score.', + 4 + ), + ( + 'What is the atomic number of oxygen?', + 'SCIE', + 'The atomic number of oxygen is 8.', + 8 + ), + ( + 'What is the current population of China in millions?', + 'GEOG', + 'The current population of China is 1.4 billion.', + 1444 + ), + ( + 'How many planets are closer to the Sun than Earth?', + 'SCIE', + 'There are two planets closer to the Sun than Earth: Mercury and Venus.', + 2 + ), + ( + 'How many time zones are there in Kazakhstan ?', + 'GEOG', + 'Even though its territory spans four geographical time zones (from +3 to +6), standard time in Kazakhstan + is either UTC+05:00 or UTC+06:00. + These times apply throughout the year as Kazakhstan does not observe Daylight saving time.', + 2 + ), + ( + 'What is the speed limit in the United Arab Emirates on the Sheikh Khalifa highway in kilometers per hour ?', + 'SCIE', + 'The UAE is notable for having the highest posted speed limits in the world, with two major highways, the Abu Dhabi-Al Ain highway and the Sheikh Khalifa highway, both + having limits of 160 km / h (99 mph).Speed limits in the Emirate of Abu Dhabi are generally higher than the other Emirates.The general speed + limit in Abu Dhabi is 140 km / h whereas in Dubai it is 110 km / h and in the Northern Emirates its 120km / h.', + 160 + ), + ( + 'How many chambers are there in a human heart?', + 'SCIE', + 'There are four chambers in a human heart: two atria and two ventricles.', + 4 + ), + ( + 'What is the highest score achievable in a game of bowling?', + 'SPORTS', + 'A perfect game is the highest score possible in a game of bowling, achieved by scoring a strike in every frame. + In common bowling games (that use 10 pins) the highest possible score is 300, achieved by bowling 12 strikes + in a row in a traditional single game: one strike in each of the first nine frames, and three more in the tenth frame.', + 300 + ), + ( + 'How many bones are there in the human body?', + 'SCIE', + 'An adult human body has 206 bones.', + 206 + ), + ( + 'What is the population of India in millions?', + 'GEOG', + 'The population of India is around 1408 millions.', + 1408 + ), + ( + 'How many Grand Slam titles has Serena Williams won?', + 'SPORTS', + 'Serena Williams has won 23 Grand Slam singles titles.', + 23 + ), + ( + 'What is the record for the fastest 100-meter sprint in milliseconds?', + 'SPORTS', + 'The current record for the fastest 100-meter sprint is 9.58 seconds, set by Usain Bolt in 2009.', + 9580 + ), + ( + 'How many elements are there in the periodic table?', + 'SCIE', + 'There are currently 118 known elements in the periodic table.', + 118 + ), + ( + 'What is the diameter of the Moon in kilometers?', + 'SCIE', + 'The diameter of the Moon is approximately 3,474 kilometers.', + 3474 + ), + ( + 'How many strings does a violin have?', + 'MUSI', + 'A violin typically has 4 strings.', + 4 + ), + ( + 'What is the speed of sound in meters per second?', + 'SCIE', + 'The speed of sound in dry air at 20 degrees Celsius is approximately 343 meters per second.', + 343 + ), + ( + 'How many Oscars did the movie "Titanic" win?', + 'MUSI', + 'The movie "Titanic" won 11 Oscars.', + 11 + ), + ( + 'In what year did the Battle of Agincourt take place?', + 'HIST', + 'The Battle of Agincourt was a major English victory in the Hundred Years'' War. It was fought on 25 October 1415.', + 1415 + ), + ( + 'When did the Ming Dynasty establish its rule in China?', + 'HIST', + 'The Ming Dynasty ruled China from 1368 to 1644, following the collapse of the Mongol-led Yuan Dynasty.', + 1368 + ), + ( + 'When did the Byzantine Empire fall to the Ottoman Turks?', + 'HIST', + 'The Fall of Constantinople was the capture of the capital of the Byzantine Empire by an invading Ottoman army on 29 May 1453.', + 1453 + ), + ( + 'When did the Black Death pandemic arrive in Europe?', + 'HIST', + 'The Black Death, also known as the Bubonic Plague, ravaged Europe from 1347 to 1351, causing widespread death and social upheaval.', + 1347 + ), + ( + 'In what year did the Opium Wars between China and Britain start?', + 'HIST', + 'The Opium Wars were a series of conflicts between the Qing Dynasty of China and the British Empire. The First Opium War occurred from 1839 to 1842.', + 1839 + ), + ( + 'When was the Great Fire of London?', + 'HIST', + 'The Great Fire of London was a major conflagration that swept through the central parts of the city from 2 to 6 September 1666.', + 1666 + ), + ( + 'In what year did the Sengoku period start in Japan?', + 'HIST', + 'The Sengoku period (''Warring States period'') is the period in Japanese history in which civil wars and social upheavals + took place almost continuously in the 15th and 16th centuries. + Though the Ōnin War (1467) is generally chosen as the Sengoku period''s start date, + there are many competing historiographies for its end date, ranging from 1568, the date of Oda Nobunaga''s march on Kyoto, + to the suppression of the Shimabara Rebellion in 1638', + 1467 + ), + ( + 'In what year was "Pride and Prejudice" by Jane Austen first published?', + 'LITE', + 'Jane Austen''s "Pride and Prejudice" was first published in 1813. It is a classic novel of manners that explores themes of love, marriage, and societal expectations.', + 1813 + ), + ( + 'How many chapters are there in "Moby-Dick" by Herman Melville?', + 'LITE', + '"Moby-Dick" by Herman Melville consists of 135 chapters. This epic novel tells the story of Captain Ahab''s quest for revenge against the white whale, Moby Dick.', + 135 + ), + ( + 'In what year was "To Kill a Mockingbird" by Harper Lee published?', + 'LITE', + '"To Kill a Mockingbird" was first published in 1960. The novel explores themes of racial injustice and the loss of innocence through the eyes of Scout Finch.', + 1960 + ), + ( + 'How many volumes make up Marcel Proust''s "In Search of Lost Time"?', + 'LITE', + 'Marcel Proust''s "In Search of Lost Time" is a seven-volume novel. It is considered one of the greatest works of modernist literature.', + 7 + ), + ( + 'In what year was "The Catcher in the Rye" by J.D. Salinger published?', + 'LITE', + '"The Catcher in the Rye" was first published in 1951. The novel follows the rebellious teenager Holden Caulfield as he navigates adolescence and society.', + 1951 + ), + ( + 'How many sisters are there in Louisa May Alcott''s "Little Women"?', + 'LITE', + '"Little Women" by Louisa May Alcott features four sisters: Meg, Jo, Beth, and Amy. The novel explores their coming of age and their relationships with each other.', + 4 + ), + ( + 'In what year was "1984" by George Orwell published?', + 'LITE', + '"1984" by George Orwell was published in 1949. It is a dystopian novel that depicts a totalitarian society where individuality and independent thinking are suppressed.', + 1949 + ), + ( + 'How many chapters are there in F. Scott Fitzgerald''s "The Great Gatsby"?', + 'LITE', + '"The Great Gatsby" by F. Scott Fitzgerald consists of 9 chapters. The novel explores themes of wealth, love, and the American Dream during the Roaring Twenties.', + 9 + ), + ( + 'In what year was "Don Quixote" by Miguel de Cervantes first published?', + 'LITE', + '"Don Quixote" by Miguel de Cervantes was first published in 1605. It is considered one of the most important works of literature and is often regarded as the first modern novel.', + 1605 + ), + ( + 'How many volumes make up J.R.R. Tolkien''s "The Lord of the Rings" trilogy?', + 'LITE', + 'J.R.R. Tolkien''s "The Lord of the Rings" trilogy consists of three volumes: "The Fellowship of the Ring," "The Two Towers," and "The Return of the King."', + 3 + ), + ( + 'In what year was "Crime and Punishment" by Fyodor Dostoevsky first published?', + 'LITE', + '"Crime and Punishment" by Fyodor Dostoevsky was first published in 1866. The novel explores the psychological turmoil of the main character, Raskolnikov.', + 1866 + ), + ( + 'How many parts are there in Victor Hugo''s "Les Misérables"?', + 'LITE', + '"Les Misérables" by Victor Hugo is divided into five parts. The novel follows the lives of several characters amidst the backdrop of social and political unrest in 19th-century France.', + 5 + ), + ( + 'In what year was "The Hobbit" by J.R.R. Tolkien published?', + 'LITE', + '"The Hobbit" by J.R.R. Tolkien was first published in 1937. It is a fantasy novel that serves as a prelude to Tolkien''s later work, "The Lord of the Rings."', + 1937 + ), + ( + 'How many players are there in a Kabaddi team?', + 'SPORTS', + 'A Kabaddi team consists of 7 players on the field. Kabaddi is a popular contact team sport originating from ancient India.', + 7 + ), + ( + 'What is the maximum score achievable with a single dart in a game of darts?', + 'SPORTS', + 'The maximum score achievable with a single dart in a game of darts is 60. This can be achieved by hitting the triple 20 segment.', + 60 + ), + ( + 'How many players are there on an Australian Rules Football team?', + 'SPORTS', + 'An Australian Rules Football team has 18 players on the field at any given time. Australian Rules Football is a unique and popular sport in Australia.', + 18 + ), + ( + 'What is the length of a cricket pitch in yards?', + 'SPORTS', + 'The length of a cricket pitch is 22 yards, which is equivalent to approximately 20.12 meters. Cricket is a widely played sport in many countries.', + 22 + ), + ( + 'How many players are there on a water polo team?', + 'SPORTS', + 'A water polo team consists of 7 players in the water at a time, including the goalkeeper. Water polo is a competitive water sport played in a pool.', + 7 + ), + ( + 'What is the weight of a standard table tennis ball in milligrams?', + 'SPORTS', + 'A standard table tennis ball weighs approximately 2.7 grams. Table tennis, also known as ping pong, is a popular indoor sport.', + 2700 + ), + ( + 'How many rounds are there in professional boxing championship fights?', + 'SPORTS', + 'Professional boxing championship fights typically have 12 rounds. Each round is usually 3 minutes long, with a minute of rest in between.', + 12 + ), + ( + 'What is the maximum score a gymnast can achieve on a single apparatus in artistic gymnastics?', + 'SPORTS', + 'In artistic gymnastics, the maximum score a gymnast can achieve on a single apparatus is 10. The score is based on a combination of difficulty and execution.', + 10 + ), + ( + 'What is the length of an Olympic-sized swimming pool in meters?', + 'SPORTS', + 'An Olympic-sized swimming pool has a length of 50 meters. It is the standard length for competitive swimming events in the Olympics.', + 50 + ), + ( + 'How many symphonies did Wolfgang Amadeus Mozart compose?', + 'ARTS', + 'Wolfgang Amadeus Mozart composed a total of 41 symphonies. His symphonies are considered masterpieces of classical music.', + 41 + ), + ( + 'What is the duration of Beethoven''s Symphony No. 9 in minutes ?', + 'ARTS', + 'Beethoven''s Symphony No. 9, also known as the "Choral Symphony," has a duration of approximately 70 minutes. It is one of Beethoven''s most famous and enduring works.', + 70 + ), + ( + 'How many acts are there in William Shakespeare''s play "Hamlet"?', + 'ARTS', + 'William Shakespeare''s play "Hamlet" is divided into five acts. It is a tragedy that explores themes of revenge, madness, and moral corruption.', + 5 + ), + ( + 'In what year was Vincent van Gogh''s painting "Starry Night" created?', + 'ARTS', + 'Vincent van Gogh''s painting "Starry Night" was created in the year 1889. It is one of his most famous and recognizable works.', + 1889 + ), + ( + 'How many sonnets did William Shakespeare write?', + 'ARTS', + 'William Shakespeare wrote a total of 154 sonnets. His sonnets are considered some of the greatest poetry in the English language.', + 154 + ), + ( + 'How many ranks are there in the hierarchy of the Paris Opera Ballet?', + 'ARTS', + 'There are five ranks of dancers in the Paris Opera Ballet; from highest to lowest they are: Danseur Étoile, premier danseur, sujet, coryphée, and quadrille.', + 5 + ), + ( + 'What is the total number of keys on a standard piano?', + 'ARTS', + 'A standard piano has a total of 88 keys. These keys include both white keys and black keys, spanning several octaves.', + 88 + ), + ( + 'How many acts are there in Giuseppe Verdi''s opera "La Traviata"?', + 'ARTS', + 'Giuseppe Verdi''s opera "La Traviata" is divided into three acts. It is a tragic love story set in 19th-century Paris.', + 3 + ), + ( + 'What is the running time of the film "Gone with the Wind" in minutes ?', + 'ARTS', + 'The film "Gone with the Wind," directed by Victor Fleming, has a running time of approximately 3 hours and 58 minutes. It is an epic historical romance set during the American Civil War.', + 238 + ), + ( + 'How many atoms of carbon are there in a molecule of glucose?', + 'SCIE', + 'A molecule of glucose (C6H12O6) contains 6 atoms of carbon. Glucose is a simple sugar and an important source of energy in biological systems.', + 6 + ), + ( + 'What is the atomic number of gold?', + 'SCIE', + 'The atomic number of gold is 79. Gold is a dense, soft, and highly valuable metal known for its lustrous yellow appearance.', + 79 + ), + ( + 'How many chromosomes are found in a human somatic cell?', + 'SCIE', + 'A human somatic cell typically contains 46 chromosomes. These chromosomes carry genetic information and are located in the nucleus of the cell.', + 46 + ), + ( + 'How many chambers are there in the heart of a frog?', + 'SCIE', + 'Unlike humans and other mammals, frogs have a three-chambered heart consisting of two atria and one ventricle. This unique cardiac structure allows for efficient circulation in amphibians.', + 3 + ), + ( + 'What is the boiling point of liquid nitrogen in Kelvins?', + 'SCIE', + 'Liquid nitrogen has a boiling point of approximately -196 degrees Celsius, or 77 Kelvins. It is commonly used in various scientific and industrial applications due to its extremely low temperature.', + 77 + ), + ( + 'How many bones are there in the human hand?', + 'SCIE', + 'The human hand is composed of 27 bones. These bones include the eight carpal bones in the wrist, five metacarpal bones in the palm, and 14 phalanges in the fingers.', + 27 + ), + ( + 'What is the pH value of pure water at room temperature?', + 'SCIE', + 'Pure water at room temperature has a pH value of approximately 7, making it neutral on the pH scale. This means that it is neither acidic nor alkaline.', + 7 + ); \ No newline at end of file diff --git a/examples/corporate-conundrum/test.hurl b/examples/corporate-conundrum/test.hurl new file mode 100644 index 0000000..0e7063e --- /dev/null +++ b/examples/corporate-conundrum/test.hurl @@ -0,0 +1,55 @@ +GET http://localhost:8080 +HTTP 200 +[Asserts] +body contains "Welcome to Corporate Conundrum" +body contains "Start a New Game" +body not contains "An error occurred" + +GET http://localhost:8080/New%20Game.sql +HTTP 302 +[Captures] +game_id: header "Location" regex "id=(-?\\d+)" +[Asserts] +header "Location" matches "^game\\.sql\\?id=-?\\d+$" + +GET http://localhost:8080/game.sql?id={{game_id}}&Player=Alice +HTTP 200 +[Asserts] +body contains "Players" +body contains "Alice" +body contains "Add a new player" +body not contains "An error occurred" + +GET http://localhost:8080/next-question.sql?game_id={{game_id}}&player=Alice +HTTP 302 +[Captures] +question_id: header "Location" regex "question%5Fid=(\\d+)" +[Asserts] +header "Location" contains "question.sql" +header "Location" contains "player=Alice" + +GET http://localhost:8080/question.sql?game_id={{game_id}}&question_id={{question_id}}&player=Alice +HTTP 200 +[Asserts] +body contains "Submit your answer" +body contains "Your answer" +body not contains "An error occurred" + +GET http://localhost:8080/wait.sql?game_id={{game_id}}&question_id={{question_id}}&player=Alice&answer=42 +HTTP 200 +[Asserts] +body contains "Waiting for other players to answer" +body not contains "An error occurred" + +GET http://localhost:8080/next-question.sql?game_id={{game_id}}&player=Alice +HTTP 302 +[Asserts] +header "Location" contains "game-over.sql" + +GET http://localhost:8080/game-over.sql?game_id={{game_id}} +HTTP 200 +[Asserts] +body contains "The game is over" +body contains "Scores" +body contains "Alice" +body not contains "An error occurred" diff --git a/examples/corporate-conundrum/wait.sql b/examples/corporate-conundrum/wait.sql new file mode 100644 index 0000000..c612482 --- /dev/null +++ b/examples/corporate-conundrum/wait.sql @@ -0,0 +1,32 @@ +-- Redirect to the next question when all players have answered +set page_params = json_object('game_id', $game_id, 'player', $player); +select CASE + (SELECT count(*) FROM answers WHERE question_id = $question_id AND game_id = CAST($game_id AS INTEGER)) + WHEN (SELECT count(*) FROM players WHERE game_id = CAST($game_id AS INTEGER)) + THEN '0; ' || sqlpage.link('next-question.sql', $page_params) + ELSE 3 + END as refresh, + sqlpage_shell.* +FROM sqlpage_shell; + +-- Insert the answer into the answers table +INSERT INTO answers(game_id, player_name, question_id, answer_value) +SELECT CAST($game_id AS INTEGER) as game_id, + $player as player_name, + CAST($question_id AS INTEGER) as question_id, + CAST($answer AS INTEGER) as answer_value +WHERE $answer IS NOT NULL; +-- Redirect to the next question +SELECT 'text' as component, + 'Waiting for other players to answer... The following players still have not answered: ' as contents; +select group_concat(name, ', ') as contents, + TRUE as bold +from players +where game_id = CAST($game_id AS INTEGER) + and not EXISTS ( + SELECT 1 + FROM answers + WHERE answers.game_id = CAST($game_id AS INTEGER) + AND answers.player_name = players.name + AND answers.question_id = CAST($question_id AS INTEGER) + ); diff --git a/examples/custom form component/README.md b/examples/custom form component/README.md new file mode 100644 index 0000000..7f3d7c1 --- /dev/null +++ b/examples/custom form component/README.md @@ -0,0 +1,19 @@ +# Custom form component + +This example shows how to create a simple custom component in handlebars, and call it from SQL. + +It uses MySQL, but it should be easy to adapt to other databases. +The only MySQL-specific features used here are: + - `json_table`, which is supported by MariaDB and MySQL 8.0 and later, + - MySQL's `json_merge` function. + +Both [have analogs in other databases](https://sql-page.com/blog.sql?post=JSON%20in%20SQL%3A%20A%20Comprehensive%20Guide). + +![screenshot](screenshot.png) + + +## Key features illustrated in this example + +- How to create a custom component in handlebars, with dynamic behavior implemented in JavaScript +- How to manage multiple-option select boxes, with pre-selected items, and multiple choices +- Including a common menu between different pages using a `shell.sql` file, the dynamic component, and the `sqlpage.run_sql` function. diff --git a/examples/custom form component/basic.sql b/examples/custom form component/basic.sql new file mode 100644 index 0000000..77f8b29 --- /dev/null +++ b/examples/custom form component/basic.sql @@ -0,0 +1,40 @@ +select + 'dynamic' as component, + sqlpage.run_sql ('shell.sql') as properties; + +-- this does the same thing as index.sql, but uses the normal form component instead of our fancy dual-list component +select + 'form' as component, + 'form_action.sql' as action; + +select + 'select' as type, + true as searchable, + true as multiple, + 'selected_items[]' as name, + 'Users in this group' as label, + -- JSON_MERGE combines two JSON documents: + -- 1. A JSON object with an empty label + -- 2. An array of user objects created by JSON_ARRAYAGG + JSON_MERGE ( + -- Creates a simple JSON object with a single empty property {"label": ""} + JSON_OBJECT ('label', ''), + -- JSON_ARRAYAGG takes multiple rows and combines them into a JSON array + -- Each element in the array is a JSON object created by json_object() + JSON_ARRAYAGG ( + -- Creates a JSON object for each user with: + -- - {"label": "the user's name", "value": "the user's ID", "selected": true } (if the user is in the group) + json_object ( + 'label', + users.name, + 'value', + users.id, + 'selected', + group_members.group_id is not null -- the left join creates NULLs for users not in the group + ) + ) + ) as options +from + users + left join group_members on users.id = group_members.user_id + and group_members.group_id = 1; \ No newline at end of file diff --git a/examples/custom form component/docker-compose.yml b/examples/custom form component/docker-compose.yml new file mode 100644 index 0000000..5b221e7 --- /dev/null +++ b/examples/custom form component/docker-compose.yml @@ -0,0 +1,17 @@ +services: + web: + image: lovasoa/sqlpage:main # main is cutting edge, use sqlpage/SQLPage:latest for the latest stable version + ports: + - "8080:8080" + volumes: + - .:/var/www + - ./sqlpage:/etc/sqlpage + depends_on: + - db + environment: + DATABASE_URL: mysql://root:secret@db/sqlpage + db: # The DB environment variable can be set to "mariadb" or "postgres" to test the code with different databases + image: mysql:9 # support for json_table was added in mariadb 10.6 + environment: + MYSQL_ROOT_PASSWORD: secret + MYSQL_DATABASE: sqlpage diff --git a/examples/custom form component/form_action.sql b/examples/custom form component/form_action.sql new file mode 100644 index 0000000..bb71cd3 --- /dev/null +++ b/examples/custom form component/form_action.sql @@ -0,0 +1,28 @@ +-- remove all existing members from this group +delete from group_members where group_id = 1; + +-- add the selected members to this group +-- This query takes a JSON array and converts it to rows +-- :selected_items contains a JSON array of user IDs, e.g. ["1", "2", "3"], generated by SQLPage from the multiple-select box answers posted by the browser +-- json_table breaks down the JSON array into individual rows +-- '$[*]' means "look at each element in the root array" +-- columns (id int path '$') extracts each array element as an integer into a column named 'id' +-- The result is a temporary table with one integer column (id) and one row per array element +insert into group_members (group_id, user_id) +select 1, id +from json_table( + :selected_items, + '$[*]' columns (id int path '$') +) as submitted_items; + +select 'alert' as component, 'Group members successfully updated !' as title, 'success' as color; + +select 'list' as component, 'Users in this group' as title; + +select name as title, email as description +from users +join group_members on users.id = group_members.user_id +where group_members.group_id = 1; + +select 'button' as component; +select 'Go back' as title, 'index.sql' as link; \ No newline at end of file diff --git a/examples/custom form component/index.sql b/examples/custom form component/index.sql new file mode 100644 index 0000000..4cd48d9 --- /dev/null +++ b/examples/custom form component/index.sql @@ -0,0 +1,17 @@ +-- include the common menu +select 'dynamic' as component, sqlpage.run_sql('shell.sql') as properties; + +-- Call our custom component from ./sqlpage/templates/dual-list.handlebars +select + 'dual-list' as component, + 'form_action.sql' as action; + +-- This SQL query returns the list of users, with a boolean indicating if they are in the group +select + id, + name as label, + group_members.group_id is not null as selected +from + users + left join group_members on users.id = group_members.user_id + and group_members.group_id = 1; \ No newline at end of file diff --git a/examples/custom form component/screenshot.png b/examples/custom form component/screenshot.png new file mode 100644 index 0000000..dc41e84 Binary files /dev/null and b/examples/custom form component/screenshot.png differ diff --git a/examples/custom form component/shell.sql b/examples/custom form component/shell.sql new file mode 100644 index 0000000..580a4fe --- /dev/null +++ b/examples/custom form component/shell.sql @@ -0,0 +1,5 @@ +select + 'shell' as component, + 'Custom form component' as title, + 'index' as menu_item, + 'basic' as menu_item; \ No newline at end of file diff --git a/examples/custom form component/sqlpage/migrations/0001_users_and_groups.sql b/examples/custom form component/sqlpage/migrations/0001_users_and_groups.sql new file mode 100644 index 0000000..1409f7a --- /dev/null +++ b/examples/custom form component/sqlpage/migrations/0001_users_and_groups.sql @@ -0,0 +1,40 @@ +create table users ( + id int primary key auto_increment, + name varchar(255) not null, + email varchar(255) not null +); + +create table `groups` ( + id int primary key auto_increment, + name varchar(255) not null +); + +create table group_members ( + group_id int not null, + user_id int not null, + primary key (group_id, user_id), + foreign key (group_id) references `groups` (id), + foreign key (user_id) references users (id) +); + +INSERT INTO users (id, name, email) VALUES +(1, 'John Smith', 'john@email.com'), +(2, 'Jane Doe', 'jane@email.com'), +(3, 'Bob Wilson', 'bob@email.com'), +(4, 'Mary Johnson', 'mary@email.com'), +(5, 'James Brown', 'james@email.com'), +(6, 'Sarah Davis', 'sarah@email.com'), +(7, 'Michael Lee', 'michael@email.com'), +(8, 'Lisa Anderson', 'lisa@email.com'), +(9, 'David Miller', 'david@email.com'), +(10, 'Emma Wilson', 'emma@email.com'); + +INSERT INTO `groups` (id, name) VALUES +(1, 'Team Alpha'); + +INSERT INTO group_members (group_id, user_id) VALUES +(1, 1), +(1, 2), +(1, 3), +(1, 4), +(1, 5); \ No newline at end of file diff --git a/examples/custom form component/sqlpage/templates/dual-list.handlebars b/examples/custom form component/sqlpage/templates/dual-list.handlebars new file mode 100644 index 0000000..ce103c0 --- /dev/null +++ b/examples/custom form component/sqlpage/templates/dual-list.handlebars @@ -0,0 +1,126 @@ +{{!-- This is a form that will post data to the URL specified in the top-level 'action' property coming from the SQL query --}} +
+ {{!-- Create a row with centered content and spacing between items --}} +
+ {{!-- Left List Box: 5 columns wide (out of the 12 made available by bootstrap) --}} +
+ {{!-- Card with no border and subtle shadow --}} +
+ {{!-- Card header with white background, no border, semibold font, and secondary text color --}} +
+ Available Items +
+
+ {{!-- Multiple-select dropdown list, 300px tall --}} + +
+
+
+ + {{!-- Middle section with transfer buttons (auto-sized column) --}} +
+ {{!-- Right arrow button (→) to move items to selected list --}} + + {{!-- Left arrow button (←) to remove items from selected list --}} + +
+ + {{!-- Right List Box (5 columns wide) --}} +
+
+
+ Selected Items +
+
+ {{!-- Multiple-select dropdown that will contain selected items. The name attribute makes it submit as an array --}} + +
+
+
+ + {{!-- Submit Button Section (full width) --}} +
+ +
+
+
+ +{{!-- JavaScript code with CSP (Content Security Policy) nonce for security --}} + diff --git a/examples/custom form component/test.hurl b/examples/custom form component/test.hurl new file mode 100644 index 0000000..613b9f7 --- /dev/null +++ b/examples/custom form component/test.hurl @@ -0,0 +1,23 @@ +GET http://localhost:8080/index.sql +HTTP 200 +[Asserts] +body contains "Available Items" +body contains "Selected Items" +body contains "John Smith" +body contains "Jane Doe" +body not contains "An error occurred" + +POST http://localhost:8080/form_action.sql +[FormParams] +selected_items[]: 6 +selected_items[]: 8 +HTTP 200 +[Asserts] +body contains "Group members successfully updated" +body contains "Users in this group" +body contains "Sarah Davis" +body contains "sarah@email.com" +body contains "Lisa Anderson" +body contains "lisa@email.com" +body not contains "John Smith" +body not contains "An error occurred" diff --git a/examples/forms with a variable number of fields/README.md b/examples/forms with a variable number of fields/README.md new file mode 100644 index 0000000..a0b9c21 --- /dev/null +++ b/examples/forms with a variable number of fields/README.md @@ -0,0 +1,40 @@ +# Handling forms with a variable number of fields + +This example shows how to handle forms with a number of fields +that is not known in advance, but depends on the contents of the database. + +## The model + +We have a database with a table +`products` that contains the products, +a table `orders` that contains the orders, +and a table `order_items` that contains the items of each order. + +We want to display a form to create a new order, with a field for each product. + +We cannot know in advance how many products there are, +so we cannot write a simple insert statement for `orders_items`, +like + +```sql +INSERT INTO order_items (product_id, quantity) +VALUES (:product_1, :product_1_quantity); +``` + +Instead, we use a single POST variable of type array to store the +product IDs and quantities, like so: + +```sql +SELECT + 'product_quantity[]' AS name, + 'Quantity of ' || name AS label +FROM products +``` + +And then we parse the array in SQLite with the [JSON_EACH](https://www.sqlite.org/json1.html#jeach) function. + +## Screenshots + +![](./screenshots/home.png) +![](./screenshots/order-form.png) +![](./screenshots/order.png) \ No newline at end of file diff --git a/examples/forms with a variable number of fields/docker-compose.yml b/examples/forms with a variable number of fields/docker-compose.yml new file mode 100644 index 0000000..7305eda --- /dev/null +++ b/examples/forms with a variable number of fields/docker-compose.yml @@ -0,0 +1,8 @@ +services: + web: + image: lovasoa/sqlpage:main + ports: + - "8080:8080" + volumes: + - .:/var/www + - ./sqlpage:/etc/sqlpage diff --git a/examples/forms with a variable number of fields/index.sql b/examples/forms with a variable number of fields/index.sql new file mode 100644 index 0000000..851cdab --- /dev/null +++ b/examples/forms with a variable number of fields/index.sql @@ -0,0 +1,15 @@ +SELECT 'shell' AS component, 'Products' AS title; + +SELECT 'list' AS component, 'Products' AS title; +SELECT 'Add a new product' AS title, + 'red' AS color, + 'new_product_form.sql' AS link, + TRUE AS active; +SELECT 'Pass an order' AS title, + 'blue' AS color, + 'order_form.sql' AS link, + TRUE AS active; +SELECT + name AS title, + price || '€' AS description +FROM products; \ No newline at end of file diff --git a/examples/forms with a variable number of fields/new_product_form.sql b/examples/forms with a variable number of fields/new_product_form.sql new file mode 100644 index 0000000..19089a5 --- /dev/null +++ b/examples/forms with a variable number of fields/new_product_form.sql @@ -0,0 +1,8 @@ +SELECT 'shell' AS component, 'New product' AS title; + +SELECT 'form' as component, + 'New product' as title, + 'Create product' as validate, + 'new_product_insert.sql' as action; +SELECT 'Name' as name; +SELECT 'Price' as name, 'number' as type; \ No newline at end of file diff --git a/examples/forms with a variable number of fields/new_product_insert.sql b/examples/forms with a variable number of fields/new_product_insert.sql new file mode 100644 index 0000000..5eaaffb --- /dev/null +++ b/examples/forms with a variable number of fields/new_product_insert.sql @@ -0,0 +1,6 @@ +INSERT INTO products (name, price) +VALUES (:Name, :Price) +RETURNING + 'redirect' AS component, + 'index.sql' AS link +; \ No newline at end of file diff --git a/examples/forms with a variable number of fields/order_form.sql b/examples/forms with a variable number of fields/order_form.sql new file mode 100644 index 0000000..8b13870 --- /dev/null +++ b/examples/forms with a variable number of fields/order_form.sql @@ -0,0 +1,18 @@ +SELECT 'shell' AS component, 'Order' AS title; + +SELECT 'form' as component, + 'Pass an order' as title, + 'Order' as validate, + 'order_insert.sql' as action; + +SELECT 'Name' as name, 'Your full name' AS placeholder; +SELECT 'Email' as name, 'Your email address' AS placeholder; +SELECT id AS name, + 'Quantity of ' || name || '' AS label, + 'Number of ' || name || ' you wish to order, for ' || price || ' € each.' AS description, + 'number' AS type, + 1 AS step, + 0 as min, + 0 as value +FROM products +ORDER BY id; diff --git a/examples/forms with a variable number of fields/order_insert.sql b/examples/forms with a variable number of fields/order_insert.sql new file mode 100644 index 0000000..a599d70 --- /dev/null +++ b/examples/forms with a variable number of fields/order_insert.sql @@ -0,0 +1,12 @@ +INSERT INTO orders(customer_name, customer_email) +VALUES (:Name, :Email); + +INSERT INTO order_items(order_id, quantity, product_id) +SELECT + last_insert_rowid() AS order_id, -- The id of the order we just inserted. Requires SQLPage v0.9.0 or later. + CAST(value AS INTEGER) AS quantity, + CAST(key AS INTEGER) AS product_id -- extracts converts the field name into a number +FROM JSON_EACH(sqlpage.variables('post')) -- Iterates on all posted variables. Requires SQLPage v0.15.0 or later +WHERE product_id > 0 -- we used the product id as a variable name in the product quatntity form fields + and CAST(value AS INTEGER) > 0 +RETURNING 'redirect' as component, 'orders.sql?id=' || order_id as link; \ No newline at end of file diff --git a/examples/forms with a variable number of fields/orders.sql b/examples/forms with a variable number of fields/orders.sql new file mode 100644 index 0000000..d98e13b --- /dev/null +++ b/examples/forms with a variable number of fields/orders.sql @@ -0,0 +1,28 @@ +SELECT 'alert' as component, + format('Thanks %s!', customer_name) as title, + 'analyze' as icon, + 'teal' as color, + format('Your order is being processed. + You will shortly receive an email on %s with a receipt.', + customer_email) as description +from orders where id = $id; + +SELECT 'index.sql' as link, + 'Back to homepage' as title; + +SELECT 'list' AS component, + 'Order summary' AS title; +SELECT + quantity || ' x ' || name AS title, + 'Subtotal: ' || quantity || ' x ' || price || ' € = ' || (quantity * price) || ' €' AS description +FROM order_items +INNER JOIN products ON products.id = order_items.product_id +WHERE order_id = $id; + +SELECT + 'Total: ' || SUM(quantity * price) || ' €' AS title, + 'red' AS color, + TRUE AS active +FROM order_items +INNER JOIN products ON products.id = order_items.product_id +WHERE order_id = $id; \ No newline at end of file diff --git a/examples/forms with a variable number of fields/screenshots/home.png b/examples/forms with a variable number of fields/screenshots/home.png new file mode 100644 index 0000000..ce3a86c Binary files /dev/null and b/examples/forms with a variable number of fields/screenshots/home.png differ diff --git a/examples/forms with a variable number of fields/screenshots/order-form.png b/examples/forms with a variable number of fields/screenshots/order-form.png new file mode 100644 index 0000000..ac59e48 Binary files /dev/null and b/examples/forms with a variable number of fields/screenshots/order-form.png differ diff --git a/examples/forms with a variable number of fields/screenshots/order.png b/examples/forms with a variable number of fields/screenshots/order.png new file mode 100644 index 0000000..2e14e8e Binary files /dev/null and b/examples/forms with a variable number of fields/screenshots/order.png differ diff --git a/examples/forms with a variable number of fields/sqlpage/migrations/0000_schema.sql b/examples/forms with a variable number of fields/sqlpage/migrations/0000_schema.sql new file mode 100644 index 0000000..eeb4df9 --- /dev/null +++ b/examples/forms with a variable number of fields/sqlpage/migrations/0000_schema.sql @@ -0,0 +1,21 @@ +CREATE TABLE products ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR(255) NOT NULL, + price DECIMAL(10, 2) NOT NULL +); + +CREATE TABLE orders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + customer_name VARCHAR(255) NOT NULL, + customer_email VARCHAR(255) NOT NULL, + date_created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE order_items ( + order_id INTEGER NOT NULL, + product_id INTEGER NOT NULL, + quantity INTEGER NOT NULL, + FOREIGN KEY (order_id) REFERENCES orders (id), + FOREIGN KEY (product_id) REFERENCES products (id), + PRIMARY KEY (order_id, product_id) +); \ No newline at end of file diff --git a/examples/forms with a variable number of fields/sqlpage/migrations/0001_initial_products.sql b/examples/forms with a variable number of fields/sqlpage/migrations/0001_initial_products.sql new file mode 100644 index 0000000..3358256 --- /dev/null +++ b/examples/forms with a variable number of fields/sqlpage/migrations/0001_initial_products.sql @@ -0,0 +1,6 @@ +-- Insert statements to populate the 'products' table with unusual products +INSERT INTO products (name, price) VALUES ('Invisible Umbrella', 99.99); +INSERT INTO products (name, price) VALUES ('Silent Alarm Clock', 49.95); +INSERT INTO products (name, price) VALUES ('Pet Rock 2.0', 19.99); +INSERT INTO products (name, price) VALUES ('Chocolate Teapot', 39.99); +INSERT INTO products (name, price) VALUES ('Square Wheels for Your Bicycle', 29.99); diff --git a/examples/forms with a variable number of fields/test.hurl b/examples/forms with a variable number of fields/test.hurl new file mode 100644 index 0000000..e95b791 --- /dev/null +++ b/examples/forms with a variable number of fields/test.hurl @@ -0,0 +1,34 @@ +GET http://localhost:8080/order_form.sql +HTTP 200 +[Asserts] +body contains "Pass an order" +body contains "Quantity of Invisible Umbrella" +body contains "Quantity of Silent Alarm Clock" +body contains "Quantity of Pet Rock 2.0" +body not contains "An error occurred" + +POST http://localhost:8080/order_insert.sql +[FormParams] +Name: Ada Lovelace +Email: ada@example.com +1: 2 +2: 1 +3: 0 +HTTP 302 +[Captures] +location: header "Location" +[Asserts] +header "Location" matches "^orders\\.sql\\?id=\\d+$" + +GET http://localhost:8080/{{location}} +HTTP 200 +[Asserts] +body contains "Thanks Ada Lovelace!" +body contains "2 x Invisible Umbrella" +body contains "Subtotal: 2 x 99.99" +body contains "199.98" +body contains "1 x Silent Alarm Clock" +body contains "Subtotal: 1 x 49.95" +body contains "Total: 249.93 €" +body not contains "Pet Rock 2.0" +body not contains "An error occurred" diff --git a/examples/forms-with-multiple-steps/README.md b/examples/forms-with-multiple-steps/README.md new file mode 100644 index 0000000..a742ff5 --- /dev/null +++ b/examples/forms-with-multiple-steps/README.md @@ -0,0 +1,64 @@ +# Forms with multiple steps + +Multi-steps forms are forms where the user has to go through multiple pages +to fill in all the information. +They are a good practice to improve the user experience +on complex forms by removing the cognitive load of filling in a long form at once. +Additionally, they allow you to validate the input at each step, +and create dynamic forms, where the next step depends on the user's input. + +There are multiple ways to create forms with multiple steps in SQLPage, +which vary in the way the state of the partially filled form +is persisted between steps. + +This example illustrates the main ones. +All the examples will implement the same simple form: +a form that asks for a person's name, email, and age. + +## [Storing the state in hidden fields](./hidden/) + +![schema](./hidden/illustration.png) + +You can store the state of the partially filled form in hidden fields, +using `'hidden' as type` in the [form component](https://sql-page.com/component.sql?component=form#component). + + - **advantages** + - simple to implement + - the form state is not sent to the server when the user navigates to other pages + - **disadvantages** + - the entire state is re-sent to the server on each step + - you need to reference all the previous answers in each step + - no *backwards navigation*: the user has to fill in the steps in order. If they go back to a previous step, you cannot prefill the form with the previous answers, or save the data they have already entered. + +## [Storing the state in the database](./database/) + +You can store the state of the partially filled form in the database, +either in the final table where you want to store the data, +or in a dedicated table that will be used to store only partial data, +allowing you to have more relaxed column constraints in the partially filled data. + + - **advantages** + - the website administrator can access user inputs before they submit the final form + - the user can start filling the form on one device, and continue on another one. + - the user can have multiple partially filled forms in flight at the same time. + - **disadvantages** + - the website administrator needs to manage a dedicated table for the form state + - old partially filled forms may pile up in the database + +## [Storing the state in cookies](./cookies/) + +You can store each answer of the user in a cookie, +using the +[`cookie` component](https://sql-page.com/component.sql?component=cookie#component). +and retrieve it on the next step using the +[`sqlpage.cookie` function](https://sql-page.com/functions.sql?function=cookie#function). + + - **advantages** + - simple to implement + - if the user leaves the form before submitting it, and returns to it later, + the state will be persisted. + - works even if some of the steps do not use the form component. + - **disadvantages** + - the entire state is re-sent to the server on each step + - the user needs to have cookies enabled to fill in the form + - if the user leaves the form before submitting it, the form state will keep being sent to all the pages he visits until he submits the form. \ No newline at end of file diff --git a/examples/forms-with-multiple-steps/cookies/finish.sql b/examples/forms-with-multiple-steps/cookies/finish.sql new file mode 100644 index 0000000..7aa66f1 --- /dev/null +++ b/examples/forms-with-multiple-steps/cookies/finish.sql @@ -0,0 +1,20 @@ +insert into users ( + name, email, age +) values ( + sqlpage.cookie('name'), + sqlpage.cookie('email'), + :age -- This is the age that was submitted from the form in step_3.sql +); + +-- remove cookies +with t(name) as (values ('name'), ('email'), ('age')) +select 'cookie' as component, name, '/cookies/' as path, true as remove from t; + +select + 'alert' as component, + 'Welcome, ' || name || '!' as title, + 'You are user #' || id || '. [Create a new user](step_1.sql)' as description_md +from users where id = last_insert_rowid(); + +select 'list' as component, 'Existing users' as title, 'users' as value; +select name as title, email as description from users; \ No newline at end of file diff --git a/examples/forms-with-multiple-steps/cookies/index.sql b/examples/forms-with-multiple-steps/cookies/index.sql new file mode 100644 index 0000000..e4d419a --- /dev/null +++ b/examples/forms-with-multiple-steps/cookies/index.sql @@ -0,0 +1 @@ +select 'redirect' as component, 'step_1.sql' as link; diff --git a/examples/forms-with-multiple-steps/cookies/step_1.sql b/examples/forms-with-multiple-steps/cookies/step_1.sql new file mode 100644 index 0000000..ad9a2ad --- /dev/null +++ b/examples/forms-with-multiple-steps/cookies/step_1.sql @@ -0,0 +1,8 @@ +select + 'form' as component, + 'step_2.sql' as action; + +select + 'name' as name, + true as required, + sqlpage.cookie ('name') as value; \ No newline at end of file diff --git a/examples/forms-with-multiple-steps/cookies/step_2.sql b/examples/forms-with-multiple-steps/cookies/step_2.sql new file mode 100644 index 0000000..ebb531f --- /dev/null +++ b/examples/forms-with-multiple-steps/cookies/step_2.sql @@ -0,0 +1,17 @@ +select + 'cookie' as component, + 'name' as name, + :name as value, + '/cookies/' as path; -- Only send the cookie for pages in the /cookies/ directory + +select + 'form' as component, + 'step_3.sql' as action; + +select + 'email' as name, + 'email' as type, + true as required, + sqlpage.cookie ('email') as value, + 'you@example.com' as placeholder, + 'Hey ' || coalesce(:name, sqlpage.cookie('name')) || '! what is your email?' as description; diff --git a/examples/forms-with-multiple-steps/cookies/step_3.sql b/examples/forms-with-multiple-steps/cookies/step_3.sql new file mode 100644 index 0000000..57b960d --- /dev/null +++ b/examples/forms-with-multiple-steps/cookies/step_3.sql @@ -0,0 +1,15 @@ +select + 'cookie' as component, + 'email' as name, + :email as value, + '/cookies/' as path; + +select + 'form' as component, + 'finish.sql' as action; + +select + 'age' as name, + 'number' as type, + true as required, + 'How old are you, ' || sqlpage.cookie('name') || '?' as description; \ No newline at end of file diff --git a/examples/forms-with-multiple-steps/database/finish.sql b/examples/forms-with-multiple-steps/database/finish.sql new file mode 100644 index 0000000..40dfc70 --- /dev/null +++ b/examples/forms-with-multiple-steps/database/finish.sql @@ -0,0 +1,16 @@ +update partially_filled_users set age = :age +where :age is not null and id = $id; + +insert into users (name, email, age) +select name, email, age from partially_filled_users where id = $id; + +delete from partially_filled_users where id = $id; + +select + 'alert' as component, + 'Welcome, ' || name || '!' as title, + 'You are user #' || id || '. [Create a new user](index.sql)' as description_md +from users where id = last_insert_rowid(); + +select 'list' as component, 'Existing users' as title, 'users' as value; +select name as title, email as description from users; \ No newline at end of file diff --git a/examples/forms-with-multiple-steps/database/index.sql b/examples/forms-with-multiple-steps/database/index.sql new file mode 100644 index 0000000..eba04a4 --- /dev/null +++ b/examples/forms-with-multiple-steps/database/index.sql @@ -0,0 +1,5 @@ +-- create a new empty partially_filled_users row, returning its id +insert into partially_filled_users default values +returning + 'redirect' as component, + 'step_1.sql?id=' || id as link; diff --git a/examples/forms-with-multiple-steps/database/step_1.sql b/examples/forms-with-multiple-steps/database/step_1.sql new file mode 100644 index 0000000..97c492b --- /dev/null +++ b/examples/forms-with-multiple-steps/database/step_1.sql @@ -0,0 +1,4 @@ +select 'form' as component, 'step_2.sql?id=' || $id as action; + +select 'name' as name, true as required, name as value +from partially_filled_users where id = $id; \ No newline at end of file diff --git a/examples/forms-with-multiple-steps/database/step_2.sql b/examples/forms-with-multiple-steps/database/step_2.sql new file mode 100644 index 0000000..68b4782 --- /dev/null +++ b/examples/forms-with-multiple-steps/database/step_2.sql @@ -0,0 +1,9 @@ +update partially_filled_users set name = :name +where :name is not null and id = $id; + +select 'form' as component, 'step_3.sql?id=' || $id as action; + +select 'email' as name, 'email' as type, true as required, email as value, + 'you@example.com' as placeholder, + 'Hey ' || name || '! what is your email?' as description +from partially_filled_users where id = $id; \ No newline at end of file diff --git a/examples/forms-with-multiple-steps/database/step_3.sql b/examples/forms-with-multiple-steps/database/step_3.sql new file mode 100644 index 0000000..f4b2762 --- /dev/null +++ b/examples/forms-with-multiple-steps/database/step_3.sql @@ -0,0 +1,8 @@ +update partially_filled_users set email = :email +where :email is not null and id = $id; + +select 'form' as component, 'finish.sql?id=' || $id as action; + +select 'age' as name, 'number' as type, true as required, age as value, + 'How old are you, ' || name || '?' as description +from partially_filled_users where id = $id; diff --git a/examples/forms-with-multiple-steps/docker-compose.yml b/examples/forms-with-multiple-steps/docker-compose.yml new file mode 100644 index 0000000..7305eda --- /dev/null +++ b/examples/forms-with-multiple-steps/docker-compose.yml @@ -0,0 +1,8 @@ +services: + web: + image: lovasoa/sqlpage:main + ports: + - "8080:8080" + volumes: + - .:/var/www + - ./sqlpage:/etc/sqlpage diff --git a/examples/forms-with-multiple-steps/hidden/finish.sql b/examples/forms-with-multiple-steps/hidden/finish.sql new file mode 100644 index 0000000..7a34bf3 --- /dev/null +++ b/examples/forms-with-multiple-steps/hidden/finish.sql @@ -0,0 +1,8 @@ +insert into users (name, email, age) values (:name, :email, :age) +returning + 'alert' as component, + 'Welcome, ' || name || '!' as title, + 'You are user #' || id || '. [Create a new user](step_1.sql)' as description_md; + +select 'list' as component, 'Existing users' as title, 'users' as value; +select name as title, email as description from users; \ No newline at end of file diff --git a/examples/forms-with-multiple-steps/hidden/illustration.png b/examples/forms-with-multiple-steps/hidden/illustration.png new file mode 100644 index 0000000..8cee254 Binary files /dev/null and b/examples/forms-with-multiple-steps/hidden/illustration.png differ diff --git a/examples/forms-with-multiple-steps/hidden/index.sql b/examples/forms-with-multiple-steps/hidden/index.sql new file mode 100644 index 0000000..e4d419a --- /dev/null +++ b/examples/forms-with-multiple-steps/hidden/index.sql @@ -0,0 +1 @@ +select 'redirect' as component, 'step_1.sql' as link; diff --git a/examples/forms-with-multiple-steps/hidden/step_1.sql b/examples/forms-with-multiple-steps/hidden/step_1.sql new file mode 100644 index 0000000..fbaefa1 --- /dev/null +++ b/examples/forms-with-multiple-steps/hidden/step_1.sql @@ -0,0 +1,7 @@ +select + 'form' as component, + 'step_2.sql' as action; + +select + 'name' as name, + true as required; \ No newline at end of file diff --git a/examples/forms-with-multiple-steps/hidden/step_2.sql b/examples/forms-with-multiple-steps/hidden/step_2.sql new file mode 100644 index 0000000..24e4ac4 --- /dev/null +++ b/examples/forms-with-multiple-steps/hidden/step_2.sql @@ -0,0 +1,13 @@ +select + 'form' as component, + 'step_3.sql' as action; + +select + 'email' as name, + 'email' as type, + true as required, + 'you@example.com' as placeholder, + 'Hey ' || :name || '! what is your email?' as description; + +with previous_answers(name, value) as (values ('name', :name)) +select 'hidden' as type, name, value from previous_answers; diff --git a/examples/forms-with-multiple-steps/hidden/step_3.sql b/examples/forms-with-multiple-steps/hidden/step_3.sql new file mode 100644 index 0000000..d2883a7 --- /dev/null +++ b/examples/forms-with-multiple-steps/hidden/step_3.sql @@ -0,0 +1,12 @@ +select + 'form' as component, + 'finish.sql' as action; + +select + 'age' as name, + 'number' as type, + true as required, + 'How old are you, ' || :name || '?' as description; + +with previous_answers(name, value) as (values ('name', :name), ('email', :email)) +select 'hidden' as type, name, value from previous_answers; diff --git a/examples/forms-with-multiple-steps/index.sql b/examples/forms-with-multiple-steps/index.sql new file mode 100644 index 0000000..0f74378 --- /dev/null +++ b/examples/forms-with-multiple-steps/index.sql @@ -0,0 +1,7 @@ +select 'list' as component, 'Forms with multiple steps' as title; + +select 'Database persistence' as title, 'database' as link; +select 'Cookies' as title, 'cookies' as link; +select 'Hidden fields' as title, 'hidden' as link; + +select 'text' as component, sqlpage.read_file_as_text('README.md') as contents_md; \ No newline at end of file diff --git a/examples/forms-with-multiple-steps/sqlpage/migrations/01_users.sql b/examples/forms-with-multiple-steps/sqlpage/migrations/01_users.sql new file mode 100644 index 0000000..f902c39 --- /dev/null +++ b/examples/forms-with-multiple-steps/sqlpage/migrations/01_users.sql @@ -0,0 +1,7 @@ +-- Simple SQLite users table +create table users ( + id integer primary key autoincrement, + name text not null, + email text not null, + age integer not null check(age > 0) +); diff --git a/examples/forms-with-multiple-steps/sqlpage/migrations/02_database_persistence.sql b/examples/forms-with-multiple-steps/sqlpage/migrations/02_database_persistence.sql new file mode 100644 index 0000000..b5afd15 --- /dev/null +++ b/examples/forms-with-multiple-steps/sqlpage/migrations/02_database_persistence.sql @@ -0,0 +1,7 @@ +-- this table will store partially filled user forms +create table partially_filled_users ( + id integer primary key autoincrement, + name text null, -- all fields are nullable, because the user may not have filled them yet + email text null, + age integer null check(age > 0) +); diff --git a/examples/forms-with-multiple-steps/test.hurl b/examples/forms-with-multiple-steps/test.hurl new file mode 100644 index 0000000..9201910 --- /dev/null +++ b/examples/forms-with-multiple-steps/test.hurl @@ -0,0 +1,33 @@ +GET http://localhost:8080/hidden/step_1.sql +HTTP 200 +[Asserts] +body contains "name" +body contains "step_2.sql" + +POST http://localhost:8080/hidden/step_2.sql +[FormParams] +name: Hurl User +HTTP 200 +[Asserts] +body contains "Hey Hurl User! what is your email?" +body contains "step_3.sql" + +POST http://localhost:8080/hidden/step_3.sql +[FormParams] +name: Hurl User +email: hurl@example.com +HTTP 200 +[Asserts] +body contains "How old are you, Hurl User?" +body contains "finish.sql" + +POST http://localhost:8080/hidden/finish.sql +[FormParams] +name: Hurl User +email: hurl@example.com +age: 42 +HTTP 200 +[Asserts] +body contains "Welcome, Hurl User!" +body contains "Existing users" +body contains "hurl@example.com" diff --git a/examples/handle-404/api/404.sql b/examples/handle-404/api/404.sql new file mode 100644 index 0000000..e24e8a7 --- /dev/null +++ b/examples/handle-404/api/404.sql @@ -0,0 +1,9 @@ +SELECT 'debug' AS component, + 'api/404.sql' AS serving_file, + sqlpage.path() AS request_path; + +SELECT 'button' AS component; +SELECT + 'Back home' AS title, + 'home' AS icon, + '/' AS link; diff --git a/examples/handle-404/api/index.sql b/examples/handle-404/api/index.sql new file mode 100644 index 0000000..727bead --- /dev/null +++ b/examples/handle-404/api/index.sql @@ -0,0 +1,9 @@ +SELECT + 'title' AS component, + 'Welcome to the API' AS contents; + +SELECT 'button' AS component; +SELECT + 'Back home' AS title, + 'home' AS icon, + '/' AS link; diff --git a/examples/handle-404/docker-compose.yml b/examples/handle-404/docker-compose.yml new file mode 100644 index 0000000..8d0813b --- /dev/null +++ b/examples/handle-404/docker-compose.yml @@ -0,0 +1,7 @@ +services: + web: + image: lovasoa/sqlpage:main + ports: + - "8080:8080" + volumes: + - .:/var/www diff --git a/examples/handle-404/index.sql b/examples/handle-404/index.sql new file mode 100644 index 0000000..edb55e3 --- /dev/null +++ b/examples/handle-404/index.sql @@ -0,0 +1,42 @@ +SELECT 'list' AS component, + 'Navigation' AS title; + +SELECT + column1 AS title, column2 AS link, column3 AS description_md +FROM (VALUES + ('Link to arbitrary path', '/api/does/not/actually/exist', 'Covered by `api/404.sql`'), + ('Link to arbitrary file', '/api/nothing.png', 'Covered by `api/404.sql`'), + ('Link to non-existing .sql file', '/api/inexistent.sql', 'Covered by `api/404.sql`'), + ('Link to 404 handler', '/api/404.sql', 'Actually `api/404.sql`'), + ('Link to API landing page', '/api', 'Covered by `api/index.sql`'), + ('Link to arbitrary broken path', '/backend/does/not/actually/exist', 'Not covered by anything, will yield a 404 error') +); + +SELECT 'text' AS component, + ' +# Overview + +This demo shows how a `404.sql` file can serve as a fallback error handler. Whenever a `404 Not +Found` error would be emitted, instead a dedicated `404.sql` is called (if it exists) to serve the +request. This is usefull in two scenarios: + +1. Providing custom 404 error pages. +2. To provide content under dynamic paths. + +The former use-case is primarily of cosmetic nature, it allows for more informative, customized +failure modes, enabling better UX. The latter use-case opens the door especially for REST API +design, where dynamic paths are often used to convey arguments, i.e. `/api/resource/5` where `5` is +the id of a resource. + + +# Fallback Handler Selection + +When a normal request to either a `.sql` or a static file fails with `404`, the `404` error is +intercepted. The reuquest path''s target directory is scanned for a `404.sql`. If it exists, it is +called. Otherwise, the parent directory is scanned for `404.sql`, which will be called if it exists. +This search traverses up until it reaches the `web_root`. If even the webroot does not contain a +`404.sql`, then the original `404` error is served as response to the HTTP client. + +The fallback handler is not recursive; i.e. if anything causes another `404` during the call to a +`404.sql`, then the request fails (emitting a `404` response to the HTTP client). + ' AS contents_md; diff --git a/examples/handle-404/test.hurl b/examples/handle-404/test.hurl new file mode 100644 index 0000000..ecc430d --- /dev/null +++ b/examples/handle-404/test.hurl @@ -0,0 +1,14 @@ +GET http://localhost:8080/api/ +HTTP 200 +[Asserts] +body contains "Welcome to the API" +body contains "Back home" +body not contains "An error occurred" + +GET http://localhost:8080/api/does/not/actually/exist +HTTP 200 +[Asserts] +body htmlUnescape contains "\"serving_file\":\"api/404.sql\"" +body htmlUnescape contains "\"request_path\":\"/api/does/not/actually/exist\"" +body contains "Back home" +body not contains "An error occurred" diff --git a/examples/image gallery with user uploads/.gitignore b/examples/image gallery with user uploads/.gitignore new file mode 100644 index 0000000..ba28150 --- /dev/null +++ b/examples/image gallery with user uploads/.gitignore @@ -0,0 +1 @@ +images/ \ No newline at end of file diff --git a/examples/image gallery with user uploads/README.md b/examples/image gallery with user uploads/README.md new file mode 100644 index 0000000..c4e964d --- /dev/null +++ b/examples/image gallery with user uploads/README.md @@ -0,0 +1,14 @@ +# Image gallery + +This example shows how to create an image gallery with user uploads. + +![Screenshot of the image gallery](./screenshots/homepage.png) + +Users can log in (default login is `admin`/`admin`) and upload images. +Uploaded images are stored in the database as data urls. +Using data urls allows us to store the images in the database, without having to set up a separate file server, +which is the simplest way to get started. However, this is not a good idea if you want to store large images, +as data urls are not very efficient, and will need to be transferred in full from the database to sqlpage to the user's browser. One can use `sqlpage.exec` to move the images to a separate file server, and store only the file urls in the database. + +![Screenshot of the upload page](./screenshots/upload.png) +![Screenshot of the login page](./screenshots/login.png) \ No newline at end of file diff --git a/examples/image gallery with user uploads/create_session.sql b/examples/image gallery with user uploads/create_session.sql new file mode 100644 index 0000000..85f4049 --- /dev/null +++ b/examples/image gallery with user uploads/create_session.sql @@ -0,0 +1,19 @@ +-- redirect to the login page if the password is not correct +SELECT 'authentication' AS component, + 'login.sql?error' AS link, + (select password_hash from user where username = :Username) AS password_hash, + :Password AS password; + +-- code after this line will only be executed if the user is authenticated +-- (i.e. if the password that they sent matches the password hash that we have stored for them) + +insert into session (id, username) +values (sqlpage.random_string(32), :Username) +returning + 'cookie' AS component, + 'session_token' AS name, + id AS value; + +-- The user browser will now have a cookie named `session_token` that we can check later +-- to see if the user is logged in. +select 'redirect' as component, '/' as link; diff --git a/examples/image gallery with user uploads/docker-compose.yml b/examples/image gallery with user uploads/docker-compose.yml new file mode 100644 index 0000000..3edf5fe --- /dev/null +++ b/examples/image gallery with user uploads/docker-compose.yml @@ -0,0 +1,17 @@ +services: + init-uploads: + image: alpine + command: ["sh", "-c", "mkdir -p /var/www/images && chmod 777 /var/www/images"] + volumes: + - .:/var/www + + web: + image: lovasoa/sqlpage:main + ports: + - "8080:8080" + volumes: + - .:/var/www + - ./sqlpage:/etc/sqlpage + depends_on: + init-uploads: + condition: service_completed_successfully diff --git a/examples/image gallery with user uploads/index.sql b/examples/image gallery with user uploads/index.sql new file mode 100644 index 0000000..21ecc49 --- /dev/null +++ b/examples/image gallery with user uploads/index.sql @@ -0,0 +1,23 @@ +select 'shell' as component, + 'My image gallery' as title, + ( + case when sqlpage.cookie('session_token') is null then 'login' + else 'logout' end + ) as menu_item; + +select 'card' as component, + 'My image gallery' as title; + +select title, description, image_url as top_image +from image; + +select 'Your gallery is empty' as title, + 'You have not uploaded any images yet. Click the button below to upload a new image.' as description +where not exists (select 1 from image); + +select 'button' as component; +select + 'Upload a new image' as title, + 'upload_form.sql' as link, + 'plus' as icon, + 'primary' as color; diff --git a/examples/image gallery with user uploads/login.sql b/examples/image gallery with user uploads/login.sql new file mode 100644 index 0000000..8cfe562 --- /dev/null +++ b/examples/image gallery with user uploads/login.sql @@ -0,0 +1,12 @@ +select 'shell' as component, 'My image gallery' as title; + +select 'form' as component, 'Login' as title, 'create_session.sql' as action; +select 'text' as type, 'Username' as name, true as required; +select 'password' as type, 'Password' as name, true as required; + + +select 'alert' as component, + 'danger' as color, + 'You are not logged in' as title, + 'Sorry, we could not log you in. Please try again.' as description +where $error is not null; \ No newline at end of file diff --git a/examples/image gallery with user uploads/logout.sql b/examples/image gallery with user uploads/logout.sql new file mode 100644 index 0000000..2ebc7fa --- /dev/null +++ b/examples/image gallery with user uploads/logout.sql @@ -0,0 +1,6 @@ +select + 'cookie' AS component, + 'session_token' AS name, + true AS remove; + +select 'redirect' as component, '/login.sql' as link -- redirect to the login page after the user logs out diff --git a/examples/image gallery with user uploads/screenshots/homepage.png b/examples/image gallery with user uploads/screenshots/homepage.png new file mode 100644 index 0000000..9736085 Binary files /dev/null and b/examples/image gallery with user uploads/screenshots/homepage.png differ diff --git a/examples/image gallery with user uploads/screenshots/login.png b/examples/image gallery with user uploads/screenshots/login.png new file mode 100644 index 0000000..d0ab9bb Binary files /dev/null and b/examples/image gallery with user uploads/screenshots/login.png differ diff --git a/examples/image gallery with user uploads/screenshots/upload.png b/examples/image gallery with user uploads/screenshots/upload.png new file mode 100644 index 0000000..8182c22 Binary files /dev/null and b/examples/image gallery with user uploads/screenshots/upload.png differ diff --git a/examples/image gallery with user uploads/sqlpage/migrations/0001_images_table.sql b/examples/image gallery with user uploads/sqlpage/migrations/0001_images_table.sql new file mode 100644 index 0000000..0832a50 --- /dev/null +++ b/examples/image gallery with user uploads/sqlpage/migrations/0001_images_table.sql @@ -0,0 +1,8 @@ +-- a sqlite table that will hold the image URLs together with a title and a description +CREATE TABLE image ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + description TEXT NOT NULL, + image_url TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/examples/image gallery with user uploads/sqlpage/migrations/0002_users.sql b/examples/image gallery with user uploads/sqlpage/migrations/0002_users.sql new file mode 100644 index 0000000..325dae6 --- /dev/null +++ b/examples/image gallery with user uploads/sqlpage/migrations/0002_users.sql @@ -0,0 +1,14 @@ +create table user ( + username text primary key, + password_hash text not null +); + +create table session ( + id text primary key, + username text not null references user(username), + created_at timestamp not null default current_timestamp +); + +-- Creates an initial user with the username `admin` and the password `admin` (hashed using sqlpage.hash_password('admin')) +insert into user (username, password_hash) +values ('admin', '$argon2id$v=19$m=19456,t=2,p=1$4lu3hSvaqXK0dMCPZLOIPg$PUFJSB6L3J5eZ33z9WX7y0nOH6KawV2FdW0abMuPE7o'); \ No newline at end of file diff --git a/examples/image gallery with user uploads/sqlpage/sqlpage.json b/examples/image gallery with user uploads/sqlpage/sqlpage.json new file mode 100644 index 0000000..d7dc65b --- /dev/null +++ b/examples/image gallery with user uploads/sqlpage/sqlpage.json @@ -0,0 +1,3 @@ +{ + "max_uploaded_file_size": 5000000 +} diff --git a/examples/image gallery with user uploads/test.hurl b/examples/image gallery with user uploads/test.hurl new file mode 100644 index 0000000..a51609c --- /dev/null +++ b/examples/image gallery with user uploads/test.hurl @@ -0,0 +1,61 @@ +# Public gallery is reachable without a session. +GET http://localhost:8080 +HTTP 200 +[Asserts] +header "Content-Type" contains "text/html" +body contains "My image gallery" +body contains "Upload a new image" +body not contains "An error occurred" + +# Upload form is protected. +GET http://localhost:8080/upload_form.sql +HTTP 302 +[Asserts] +header "Location" == "/login.sql" + +GET http://localhost:8080/login.sql +HTTP 200 +[Asserts] +body contains "Login" +body contains "Username" +body contains "Password" +body not contains "An error occurred" + +# Demo credentials from the example migration: admin/admin. +POST http://localhost:8080/create_session.sql +[Form] +Username: admin +Password: admin +HTTP 302 +[Asserts] +header "Set-Cookie" contains "session_token=" +header "Location" == "/" + +# Hurl's cookie store reuses the session_token set above. +GET http://localhost:8080/upload_form.sql +HTTP 200 +[Asserts] +body contains "Upload a new image" +body contains "Title" +body contains "Description" +body contains "Image" +body not contains "An error occurred" + +POST http://localhost:8080/upload.sql +[Multipart] +Title: Hurl test image +Description: Uploaded by Hurl +Image: file,screenshots/upload.png; image/png +HTTP 302 +[Captures] +location: header "Location" +[Asserts] +header "Location" matches "^/\\?created_id=\\d+$" + +GET http://localhost:8080{{location}} +HTTP 200 +[Asserts] +body contains "Hurl test image" +body contains "Uploaded by Hurl" +body contains "/images/" +body not contains "An error occurred" diff --git a/examples/image gallery with user uploads/upload.sql b/examples/image gallery with user uploads/upload.sql new file mode 100644 index 0000000..0b7464d --- /dev/null +++ b/examples/image gallery with user uploads/upload.sql @@ -0,0 +1,32 @@ +-- important: we do not accept file uploads from unauthenticated users +select 'redirect' as component, '/login.sql' as link -- redirect to the login page if the user is not logged in +where not exists ( + select true from session + where + sqlpage.cookie('session_token') = id and + created_at > datetime('now', '-1 day') -- require the user to log in again after 1 day +); + +-- Redirect the user back to the form if no file was uploaded +select 'redirect' as component, '/upload_form.sql' as link +where sqlpage.uploaded_file_mime_type('Image') NOT LIKE 'image/%'; + +insert or ignore into image (title, description, image_url) +values ( + :Title, + :Description, + -- Persist the uploaded file to the local "images" folder at the root of the website and return the path + sqlpage.persist_uploaded_file('Image', 'images', 'jpg,jpeg,png,gif') + -- alternatively, if the images are small, you could store them in the database directly with the following line + -- sqlpage.read_file_as_data_url(sqlpage.uploaded_file_path('Image')) +) +returning 'redirect' as component, + format('/?created_id=%d', id) as link; + +-- If the insert failed, warn the user +select 'alert' as component, + 'red' as color, + 'alert-triangle' as icon, + 'Failed to upload image' as title, + 'Please try again with a smaller picture. Maximum allowed file size is 500Kb.' as description +; \ No newline at end of file diff --git a/examples/image gallery with user uploads/upload_form.sql b/examples/image gallery with user uploads/upload_form.sql new file mode 100644 index 0000000..0c23417 --- /dev/null +++ b/examples/image gallery with user uploads/upload_form.sql @@ -0,0 +1,7 @@ +select 'redirect' as component, '/login.sql' as link -- redirect to the login page if the user is not logged in +where not exists (select true from session where sqlpage.cookie('session_token') = id and created_at > datetime('now', '-1 day')); -- require the user to log in again after 1 day + +select 'form' as component, 'Upload a new image' as title, 'upload.sql' as action; +select 'text' as type, 'Title' as name, true as required; +select 'text' as type, 'Description' as name; +select 'file' as type, 'Image' as name, 'image/*' as accept; \ No newline at end of file diff --git a/examples/light-dark-toggle/README.md b/examples/light-dark-toggle/README.md new file mode 100644 index 0000000..fb50c28 --- /dev/null +++ b/examples/light-dark-toggle/README.md @@ -0,0 +1,29 @@ +# Switching between light mode and dark mode in SQLPage + +This is a demo of a light/dark background toggle mecanism for websites built with [SQLpage](https://sql-page.com/). + +![screenshot](./screenshot.png) + +This example demonstrates: + - how to give the same header, footer, and style to all your pages using the `dynamic` component and the `run_sql` function. + - how to use the `theme` property of the `shell` component. + - how to store and reuse persistent user-specific preferences using cookies. + + +## Installation + +The SQL backend to this is SQLite, so the installation is easy: + +1. [Install SQLpage](https://sql-page.com/your-first-sql-website/) + +1. Clone SQLpage''s repository: `git clone https://github.com/sqlpage/SQLPage.git` + +1. cd to `SQLpage/examples/light-dark-toggle` and run `sqlpage` in the cloned directory + +1. Open a browser window to `http://localhost:5005` + +## Usage + +The initial background theme is light. To switch to dark mode, click on the '' ☀ '' symbol on the right of the top menu bar. Click on the same symbol to switch back to light mode. + +I have included some dummy pages under a mock ''Categories'' menu to show that the ''light'' or ''dark'' setting is kept between pages. This is done by setting and reading a cookie called ''lightdarkstatus''.' AS contents_md; diff --git a/examples/light-dark-toggle/biography.sql b/examples/light-dark-toggle/biography.sql new file mode 100644 index 0000000..8a1dd0a --- /dev/null +++ b/examples/light-dark-toggle/biography.sql @@ -0,0 +1,7 @@ +SELECT 'dynamic' AS component, + sqlpage.run_sql('shell.sql') + AS properties; + +SELECT 'text' AS component, + 'Biography' AS title; +SELECT 'Morbi fermentum porttitor bibendum. Vivamus eu tempus purus. Sed ligula risus, consectetur in ligula eu, lobortis sollicitudin tellus. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin purus justo, lacinia in velit sed, fringilla imperdiet neque. Suspendisse iaculis lacus metus, at imperdiet justo rutrum nec. Duis accumsan fermentum nisi quis ornare. Aenean at placerat quam, quis gravida diam. Sed sollicitudin justo sit amet mattis eleifend. Vestibulum eget porttitor quam.' AS contents; diff --git a/examples/light-dark-toggle/codeconduct.sql b/examples/light-dark-toggle/codeconduct.sql new file mode 100644 index 0000000..c7b8875 --- /dev/null +++ b/examples/light-dark-toggle/codeconduct.sql @@ -0,0 +1,7 @@ +SELECT 'dynamic' AS component, + sqlpage.run_sql('shell.sql') + AS properties; + +SELECT 'text' AS component, + 'Code Of Conduct' AS title; +SELECT 'Pellentesque sed consequat ligula. Ut fermentum elit diam, sit amet ullamcorper orci volutpat quis. Nunc nec ipsum eu nibh interdum interdum ut vitae neque. Sed ac hendrerit tortor, ac tincidunt nibh. Mauris vel tempor odio, quis varius lorem. In sed nibh placerat, fermentum nisl eget, dictum orci. Nullam sit amet ligula velit. Maecenas faucibus massa a orci pharetra, eu fringilla enim ornare. Vestibulum quis rutrum nisi. Pellentesque nec nulla eu tellus aliquet bibendum accumsan egestas dui. Phasellus arcu felis, dictum venenatis metus vel, consectetur finibus enim. Praesent tristique semper dolor, a mollis orci pharetra vel. Vivamus mattis, lectus blandit finibus euismod, magna justo ornare nisi, vel convallis nisi velit eu purus. Aliquam erat volutpat.' AS contents; diff --git a/examples/light-dark-toggle/docker-compose.yml b/examples/light-dark-toggle/docker-compose.yml new file mode 100644 index 0000000..d6e36ed --- /dev/null +++ b/examples/light-dark-toggle/docker-compose.yml @@ -0,0 +1,8 @@ +services: + web: + image: lovasoa/sqlpage:main + ports: + - "8080:5005" + volumes: + - .:/var/www + - ./sqlpage:/etc/sqlpage diff --git a/examples/light-dark-toggle/index.sql b/examples/light-dark-toggle/index.sql new file mode 100644 index 0000000..248f486 --- /dev/null +++ b/examples/light-dark-toggle/index.sql @@ -0,0 +1,28 @@ +SELECT 'dynamic' AS component, + sqlpage.run_sql('shell.sql') + AS properties; + +SELECT 'title' AS component, + 'Toggle Light / Dark Mode' AS contents, + TRUE AS center; + +SELECT 'text' AS component; +SELECT 'This is a demo of a light/dark background toggle mecanism for websites built with [SQLpage](https://sql-page.com/), Ophir Lojkine''s fantastic tool + +## Installation + +The SQL backend to this is SQLite, so the installation is easy: + +1. [Install SQLpage](https://sql-page.com/your-first-sql-website/) + +1. Clone SQLpage''s repository: `git clone https://github.com/sqlpage/SQLPage.git` + +1. cd to `SQLpage/examples/light-dark-toggle` and run `sqlpage` in the cloned directory + +1. Open a browser window to `http://localhost:5005` + +## Usage + +The initial background theme is light. To switch to dark mode, click on the '' ☀ '' symbol on the right of the top menu bar. Click on the same symbol to switch back to light mode. + +I have included some dummy pages under a mock ''Categories'' menu to show that the ''light'' or ''dark'' setting is kept between pages. This is done by setting and reading a cookie called ''lightdarkstatus''.' AS contents_md; diff --git a/examples/light-dark-toggle/presentation.sql b/examples/light-dark-toggle/presentation.sql new file mode 100644 index 0000000..1f98c50 --- /dev/null +++ b/examples/light-dark-toggle/presentation.sql @@ -0,0 +1,14 @@ +SELECT 'dynamic' AS component, + sqlpage.run_sql('shell.sql') + AS properties; + +SELECT 'hero' AS component, + 'Presentation' AS title, + 'This sample site demonstrate a light/dark toggle' AS description; +SELECT 'text' AS component, +'Aenean pellentesque orci metus, ac imperdiet odio accumsan ac. Praesent vehicula sem lorem, in ultricies ex ultricies vitae. Nam lorem ipsum, ultrices faucibus pharetra a, maximus quis dolor. Donec malesuada, enim ut posuere pulvinar, nisl libero molestie felis, sit amet venenatis massa tortor ac enim. Etiam dui nisl, hendrerit sit amet lacinia quis, congue sed lorem. Nulla nec augue fermentum, convallis massa vel, mollis purus. Phasellus hendrerit finibus lorem vel volutpat. Cras sodales laoreet eros id consequat. Phasellus euismod ligula vitae sapien scelerisque lobortis. + +Suspendisse potenti. In tempus, turpis in laoreet auctor, justo velit ullamcorper tortor, a elementum justo felis in risus. Nullam rhoncus convallis pretium. Morbi nec nisl in magna mollis ultricies quis sed tortor. Phasellus rutrum elementum vehicula. Praesent vel malesuada turpis. Vestibulum massa ante, consequat non euismod sit amet, pretium quis nisi. Nam vestibulum nulla lorem. Sed pharetra euismod eleifend. Cras ac lacus sed nunc volutpat tristique sed quis nunc. + +Ut rutrum tempor orci eu fermentum. Aenean fringilla, metus a molestie blandit, velit nunc ornare ex, vel feugiat neque odio sed erat. Proin convallis, dui sit amet auctor venenatis, mauris elit hendrerit justo, sed maximus nulla orci eget felis. Praesent dolor velit, luctus et urna posuere, pulvinar dictum urna. Curabitur sed dictum felis. In at neque ornare, convallis nibh et, mollis risus. Praesent commodo vehicula dolor in egestas. Praesent euismod nunc risus, sed consequat turpis venenatis quis. Proin in risus ornare, mattis tortor sed, porttitor nunc.' +AS contents_md; diff --git a/examples/light-dark-toggle/screenshot.png b/examples/light-dark-toggle/screenshot.png new file mode 100644 index 0000000..e99c364 Binary files /dev/null and b/examples/light-dark-toggle/screenshot.png differ diff --git a/examples/light-dark-toggle/shell.sql b/examples/light-dark-toggle/shell.sql new file mode 100644 index 0000000..d5f95b6 --- /dev/null +++ b/examples/light-dark-toggle/shell.sql @@ -0,0 +1,18 @@ +-- This shell goes to every page + +SELECT 'shell' AS component, + 'LightDark' AS title, + sqlpage.cookie('lightdarkstatus') AS theme, + '/' AS link, + '[ + {"title":"Categories", + "submenu": [ + {"title":"Home","link":"/"}, + {"title":"Presentation","link":"/presentation.sql"}, + {"title":"Biography","link":"/biography.sql"}, + {"title":"Code of conduct","link":"/codeconduct.sql"} + ]}, + {"title":"☀","link":"/toggle.sql"} + ]' AS menu_item, + 'sqlpage ' || sqlpage.version() + AS footer; diff --git a/examples/light-dark-toggle/sqlpage/sqlpage.yaml b/examples/light-dark-toggle/sqlpage/sqlpage.yaml new file mode 100644 index 0000000..69c042e --- /dev/null +++ b/examples/light-dark-toggle/sqlpage/sqlpage.yaml @@ -0,0 +1,2 @@ +database_url: "sqlite://:memory:" +port: 5005 diff --git a/examples/light-dark-toggle/test.hurl b/examples/light-dark-toggle/test.hurl new file mode 100644 index 0000000..c9265b0 --- /dev/null +++ b/examples/light-dark-toggle/test.hurl @@ -0,0 +1,29 @@ +GET http://localhost:8080/ +HTTP 200 +[Asserts] +body contains "Toggle Light / Dark Mode" +body contains "Categories" +body contains "☀" +body not contains "An error occurred" + +GET http://localhost:8080/toggle.sql +Referer: http://localhost:8080/ +HTTP 302 +[Asserts] +header "Set-Cookie" contains "lightdarkstatus=dark" +header "Location" == "http://localhost:8080/" + +GET http://localhost:8080/biography.sql +HTTP 200 +[Asserts] +body contains "Biography" +body contains "Morbi fermentum" +body contains "data-bs-theme=\"dark\"" +body not contains "An error occurred" + +GET http://localhost:8080/toggle.sql +Referer: http://localhost:8080/biography.sql +HTTP 302 +[Asserts] +header "Set-Cookie" contains "lightdarkstatus=" +header "Location" == "http://localhost:8080/biography.sql" diff --git a/examples/light-dark-toggle/toggle.sql b/examples/light-dark-toggle/toggle.sql new file mode 100644 index 0000000..0869280 --- /dev/null +++ b/examples/light-dark-toggle/toggle.sql @@ -0,0 +1,5 @@ +SELECT 'cookie' AS component, + 'lightdarkstatus' AS name, + IIF(COALESCE(sqlpage.cookie('lightdarkstatus'),'') = '', 'dark', '') AS value; + +SELECT 'redirect' AS component, sqlpage.header('referer') AS link; diff --git a/examples/make a geographic data application using sqlite extensions/Dockerfile b/examples/make a geographic data application using sqlite extensions/Dockerfile new file mode 100644 index 0000000..5a3d226 --- /dev/null +++ b/examples/make a geographic data application using sqlite extensions/Dockerfile @@ -0,0 +1,10 @@ +FROM debian:stable-slim + +COPY --from=lovasoa/sqlpage:main /usr/local/bin/sqlpage /usr/local/bin/sqlpage + +RUN apt-get update && \ + apt-get -y install libsqlite3-mod-spatialite + +COPY . . + +CMD ["sqlpage"] diff --git a/examples/make a geographic data application using sqlite extensions/README.md b/examples/make a geographic data application using sqlite extensions/README.md new file mode 100644 index 0000000..aaae8a6 --- /dev/null +++ b/examples/make a geographic data application using sqlite extensions/README.md @@ -0,0 +1,25 @@ +# Using spatialite to build a geographic data application + +## Introduction + +This is a small application that uses [spatialite](https://www.gaia-gis.it/fossil/libspatialite/index) +to save data associated to greographic coordinates. + +If you are using a postgres database, see [this other example instead](../PostGIS%20-%20using%20sqlpage%20with%20geographic%20data/), +which implements the same application using the `PostGIS` extension. + +### Installation + +You need to install the `spatialite` extension for SQLite. On Debian, or Ubuntu, you can do it with: + +```bash +sudo apt install libsqlite3-mod-spatialite +``` + +Then you can run this application normally with SQLPage. + +Notice the `sqlite_extensions` configuration parameter in [`sqlpage/sqlpage.json`](./sqlpage/sqlpage.json). + +## Screenshots + +![](./screenshots/code.png) \ No newline at end of file diff --git a/examples/make a geographic data application using sqlite extensions/add_point.sql b/examples/make a geographic data application using sqlite extensions/add_point.sql new file mode 100644 index 0000000..daf385a --- /dev/null +++ b/examples/make a geographic data application using sqlite extensions/add_point.sql @@ -0,0 +1,11 @@ +INSERT INTO spatial_data (title, geom, description) +VALUES ( + :Title, + MakePoint( + CAST(:Longitude AS REAL), + CAST(:Latitude AS REAL + ), 4326), + :Text +) RETURNING + 'redirect' AS component, + 'index.sql' AS link; \ No newline at end of file diff --git a/examples/make a geographic data application using sqlite extensions/docker-compose.yml b/examples/make a geographic data application using sqlite extensions/docker-compose.yml new file mode 100644 index 0000000..8e46ffc --- /dev/null +++ b/examples/make a geographic data application using sqlite extensions/docker-compose.yml @@ -0,0 +1,5 @@ +services: + web: + build: . + ports: + - "8080:8080" diff --git a/examples/make a geographic data application using sqlite extensions/index.sql b/examples/make a geographic data application using sqlite extensions/index.sql new file mode 100644 index 0000000..7a1317c --- /dev/null +++ b/examples/make a geographic data application using sqlite extensions/index.sql @@ -0,0 +1,40 @@ +SELECT 'shell' as component, + 'Map' AS title, + '/' as link, + 'book' as icon; + +SELECT 'map' AS component, + ST_Y(geom) AS latitude, + ST_X(geom) AS longitude +FROM spatial_data +WHERE id = $id; + +SELECT 'map' as component, + 'Points of interest' as title, + 2 as zoom, + 700 as height; +SELECT title, + ST_Y(geom) AS latitude, + ST_X(geom) AS longitude, + 'point.sql?id=' || id as link, + 'red' as color, + 'world-pin' as icon +FROM spatial_data; + + +SELECT 'list' as component, + 'My points' as title; +SELECT title, + 'point.sql?id=' || id as link, + 'red' as color, + 'world-pin' as icon +FROM spatial_data; + +SELECT 'form' AS component, + 'Add a point' AS title, + 'add_point.sql' AS action; + +SELECT 'Title' AS name; +SELECT 'Latitude' AS name, 'number' AS type, 0.00000001 AS step; +SELECT 'Longitude' AS name, 'number' AS type, 0.00000001 AS step; +SELECT 'Text' AS name, 'textarea' AS type; \ No newline at end of file diff --git a/examples/make a geographic data application using sqlite extensions/point.sql b/examples/make a geographic data application using sqlite extensions/point.sql new file mode 100644 index 0000000..3e1dcde --- /dev/null +++ b/examples/make a geographic data application using sqlite extensions/point.sql @@ -0,0 +1,56 @@ +SELECT 'shell' as component, + title, + '/' as link, + 'index' as menu_item, + 'book' as icon +FROM spatial_data +WHERE id = $id; + +SELECT 'datagrid' as component, title FROM spatial_data WHERE id = $id; + +SELECT 'Latitude' as title, + ST_Y(geom) as description, + 'purple' as color, + 'world-latitude' as icon +FROM spatial_data WHERE id = $id; + +SELECT 'Longitude' as title, + ST_X(geom) as description, + 'purple' as color, + 'world-longitude' as icon +FROM spatial_data WHERE id = $id; + +SELECT 'Created at' as title, + created_at as description, + 'calendar' as icon, + 'Date and time of creation' as footer +FROM spatial_data WHERE id = $id; + +SELECT 'Label' as title, + title as description, + 'geo:' || ST_Y(geom) || ',' || ST_X(geom) || '?z=16' AS link, + 'blue' as color, + 'world' as icon, + 'User-generated point name' as footer +FROM spatial_data +WHERE id = $id; + +SELECT 'text' as component, + description as contents_md +FROM spatial_data +WHERE id = $id; + +SELECT 'list' as component, 'Closest points' as title; +SELECT to_label as title, + ROUND(CvtToKm(distance), 3) || ' km' as description, + 'point.sql?id=' || to_id as link, + 'red' as color, + 'world-pin' as icon +FROM distances +WHERE from_id = $id +ORDER BY distance +LIMIT 5; + +SELECT 'map' AS component, + ST_Y(geom) AS latitude, ST_X(geom) AS longitude +FROM spatial_data WHERE id = $id; \ No newline at end of file diff --git a/examples/make a geographic data application using sqlite extensions/screenshots/code.png b/examples/make a geographic data application using sqlite extensions/screenshots/code.png new file mode 100644 index 0000000..d22b849 Binary files /dev/null and b/examples/make a geographic data application using sqlite extensions/screenshots/code.png differ diff --git a/examples/make a geographic data application using sqlite extensions/sqlpage/migrations/0000_create_db.sql b/examples/make a geographic data application using sqlite extensions/sqlpage/migrations/0000_create_db.sql new file mode 100644 index 0000000..5f2bac4 --- /dev/null +++ b/examples/make a geographic data application using sqlite extensions/sqlpage/migrations/0000_create_db.sql @@ -0,0 +1,23 @@ +-- Create a spatialite-enabled database +CREATE TABLE spatial_data ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + geom POINT, + description TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +SELECT InitSpatialMetaData(); + +CREATE VIEW distances AS +SELECT from_point.id AS from_id, + from_point.title AS from_label, + to_point.id AS to_id, + to_point.title AS to_label, + ST_Distance( + from_point.geom, + to_point.geom, + TRUE + ) AS distance +FROM spatial_data AS from_point, spatial_data AS to_point +WHERE from_point.id != to_point.id; \ No newline at end of file diff --git a/examples/make a geographic data application using sqlite extensions/sqlpage/sqlpage.json b/examples/make a geographic data application using sqlite extensions/sqlpage/sqlpage.json new file mode 100644 index 0000000..7835c9b --- /dev/null +++ b/examples/make a geographic data application using sqlite extensions/sqlpage/sqlpage.json @@ -0,0 +1,3 @@ +{ + "sqlite_extensions": ["mod_spatialite"] +} diff --git a/examples/make a geographic data application using sqlite extensions/test.hurl b/examples/make a geographic data application using sqlite extensions/test.hurl new file mode 100644 index 0000000..21f617f --- /dev/null +++ b/examples/make a geographic data application using sqlite extensions/test.hurl @@ -0,0 +1,44 @@ +GET http://localhost:8080/ +HTTP 200 +[Asserts] +body contains "Points of interest" +body contains "Add a point" +body not contains "An error occurred" + +POST http://localhost:8080/add_point.sql +[FormParams] +Title: Hurl Paris +Latitude: 48.8566 +Longitude: 2.3522 +Text: First Hurl point +HTTP 302 +[Asserts] +header "Location" == "index.sql" + +POST http://localhost:8080/add_point.sql +[FormParams] +Title: Hurl Lyon +Latitude: 45.764 +Longitude: 4.8357 +Text: Second Hurl point +HTTP 302 + +GET http://localhost:8080/ +HTTP 200 +[Captures] +paris_id: xpath "substring-after(string(//a[normalize-space(.)='Hurl Paris']/@href), 'id=')" +[Asserts] +body contains "Hurl Paris" +body contains "Hurl Lyon" +body htmlUnescape contains "point.sql?id={{paris_id}}" +body not contains "An error occurred" + +GET http://localhost:8080/point.sql?id={{paris_id}} +HTTP 200 +[Asserts] +body contains "48.8566" +body contains "2.3522" +body contains "First Hurl point" +body contains "Closest points" +body contains "Hurl Lyon" +body not contains "An error occurred" diff --git a/examples/master-detail-forms/README.md b/examples/master-detail-forms/README.md new file mode 100644 index 0000000..a77181b --- /dev/null +++ b/examples/master-detail-forms/README.md @@ -0,0 +1,37 @@ +# Master-Detail Forms (Nested Forms) + +This example shows how to handle inserting data into multiple tables +with a one-to-many relationship. + +The example uses a **SQLite** database with the following schema: + +| Database Schema | SQLPage Form | +| --- | --- | +| ![db schema](./screenshots/db-schema.png) | ![user addition](./screenshots//user-add-screenshot.png) | + +A single master page features two forms based on two related tables or views. +Users insert data in the master form first to update information from the parent table. +Then, they can insert data in the detail forms to update information from the child tables. + +This example application contains a main form to create users, +and a second form to create their addresses. + +Once a user has been added, multiple addresses can be added to it. + +See https://github.com/sqlpage/SQLPage/discussions/16 for more details. + +The main idea is to create two separate forms. +In this example, we put both forms on the same page, in [`edit-user.sql`](./edit-user.sql). +The first one is an edition form for the already-existing user record, +and the second is a form to add an address to the user. + +When you initially load the user creation form, +we do not display the address form. +Only when the user has been created, +you are redirected to the user edition form that contains the address form. + + +## Screenshots + +![homepage](./screenshots/home-screenshot.png) +![user addition](./screenshots//user-add-screenshot.png) diff --git a/examples/master-detail-forms/docker-compose.yml b/examples/master-detail-forms/docker-compose.yml new file mode 100644 index 0000000..7305eda --- /dev/null +++ b/examples/master-detail-forms/docker-compose.yml @@ -0,0 +1,8 @@ +services: + web: + image: lovasoa/sqlpage:main + ports: + - "8080:8080" + volumes: + - .:/var/www + - ./sqlpage:/etc/sqlpage diff --git a/examples/master-detail-forms/edit_user.sql b/examples/master-detail-forms/edit_user.sql new file mode 100644 index 0000000..e00993b --- /dev/null +++ b/examples/master-detail-forms/edit_user.sql @@ -0,0 +1,25 @@ +SELECT 'shell' AS component, 'User Management App' AS title, 'user' AS icon, '/' AS link; + +SELECT 'form' AS component, + 'Edit user' AS title, + 'insert_user.sql' || COALESCE('?id=' || $id, '') AS action; + +SELECT 'First name' AS name, + TRUE AS required, + (SELECT first_name FROM "user" WHERE id = CAST($id AS INT)) AS value; + +SELECT 'Last name' AS name, + TRUE AS required, + (SELECT last_name FROM "user" WHERE id = CAST($id AS INT)) AS value; + +SELECT 'Email' AS name, + 'email' AS type, + (SELECT email FROM "user" WHERE id = CAST($id AS INT)) AS value; + +SELECT 'list' AS component, 'Addresses' AS title WHERE $id IS NOT NULL; +SELECT street || ', ' || city || ', ' || country AS title FROM address WHERE user_id = CAST($id AS INT); + +SELECT 'form' AS component, 'Add address' AS title, 'insert_address.sql?user_id=' || $id AS action WHERE $id IS NOT NULL; +SELECT 'Street' AS name, TRUE AS required WHERE $id IS NOT NULL; +SELECT 'City' AS name, TRUE AS required WHERE $id IS NOT NULL; +SELECT 'Country' AS name, TRUE AS required WHERE $id IS NOT NULL; \ No newline at end of file diff --git a/examples/master-detail-forms/index.sql b/examples/master-detail-forms/index.sql new file mode 100644 index 0000000..720d61c --- /dev/null +++ b/examples/master-detail-forms/index.sql @@ -0,0 +1,10 @@ +SELECT 'hero' as component, + 'SQLPage Form Demo' as title, + 'This application allows you to manage a list of users and their addresses' as description_md, + 'https://upload.wikimedia.org/wikipedia/commons/thumb/e/e4/Lac_de_Zoug.jpg/640px-Lac_de_Zoug.jpg' as image, + 'edit_user.sql' as link, + 'Create a new user' as link_text; + +SELECT 'list' AS component, 'Users' AS title; +SELECT first_name || ' ' || last_name AS title, email AS description, 'edit_user.sql?id=' || id AS link FROM "user"; +SELECT 'Add a new user' AS title, 'edit_user.sql' AS link, TRUE AS active; \ No newline at end of file diff --git a/examples/master-detail-forms/insert_address.sql b/examples/master-detail-forms/insert_address.sql new file mode 100644 index 0000000..9a49c05 --- /dev/null +++ b/examples/master-detail-forms/insert_address.sql @@ -0,0 +1,4 @@ +INSERT INTO address (user_id, street, city, country) VALUES ($user_id, :Street, :City, :Country) +RETURNING + 'redirect' AS component, + 'edit_user.sql?id=' || user_id AS link; \ No newline at end of file diff --git a/examples/master-detail-forms/insert_user.sql b/examples/master-detail-forms/insert_user.sql new file mode 100644 index 0000000..6c7e061 --- /dev/null +++ b/examples/master-detail-forms/insert_user.sql @@ -0,0 +1,6 @@ +INSERT INTO user (id, first_name, last_name, email) VALUES (CAST($id AS INT), :"First name", :"Last name", :Email) +ON CONFLICT (id) DO -- this syntax is PostgreSQL-specific. In SQLite, use ON CONFLICT IGNORE. +UPDATE SET first_name = excluded.first_name, last_name = excluded.last_name, email = excluded.email +RETURNING + 'redirect' AS component, + 'edit_user.sql?id=' || id AS link; \ No newline at end of file diff --git a/examples/master-detail-forms/screenshots/db-schema.png b/examples/master-detail-forms/screenshots/db-schema.png new file mode 100644 index 0000000..826b0ba Binary files /dev/null and b/examples/master-detail-forms/screenshots/db-schema.png differ diff --git a/examples/master-detail-forms/screenshots/home-screenshot.png b/examples/master-detail-forms/screenshots/home-screenshot.png new file mode 100644 index 0000000..aa2f9f8 Binary files /dev/null and b/examples/master-detail-forms/screenshots/home-screenshot.png differ diff --git a/examples/master-detail-forms/screenshots/user-add-screenshot.png b/examples/master-detail-forms/screenshots/user-add-screenshot.png new file mode 100644 index 0000000..95186a0 Binary files /dev/null and b/examples/master-detail-forms/screenshots/user-add-screenshot.png differ diff --git a/examples/master-detail-forms/sqlpage/migrations/0000_schema.sql b/examples/master-detail-forms/sqlpage/migrations/0000_schema.sql new file mode 100644 index 0000000..496b3fd --- /dev/null +++ b/examples/master-detail-forms/sqlpage/migrations/0000_schema.sql @@ -0,0 +1,14 @@ +CREATE TABLE "user"( + id INTEGER PRIMARY KEY, + first_name TEXT NOT NULL, + last_name TEXT NOT NULL, + email TEXT +); + +CREATE TABLE "address"( + id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES "user"(id), + street TEXT NOT NULL, + city TEXT NOT NULL, + country TEXT NOT NULL +); \ No newline at end of file diff --git a/examples/master-detail-forms/test.hurl b/examples/master-detail-forms/test.hurl new file mode 100644 index 0000000..16312cb --- /dev/null +++ b/examples/master-detail-forms/test.hurl @@ -0,0 +1,65 @@ +GET http://localhost:8080 +HTTP 200 +[Asserts] +header "Content-Type" contains "text/html" +body contains "SQLPage Form Demo" +body contains "Users" +body contains "Add a new user" +body not contains "An error occurred" + +POST http://localhost:8080/insert_user.sql?id=987654 +Content-Type: application/x-www-form-urlencoded +`First%20name=Hurl&Last%20name=Masterdetail&Email=hurl-masterdetail%40example.com` +HTTP 302 +[Asserts] +header "Location" == "edit_user.sql?id=987654" + +GET http://localhost:8080/edit_user.sql?id=987654 +HTTP 200 +[Asserts] +body contains "Edit user" +body contains "Hurl" +body contains "Masterdetail" +body contains "hurl-masterdetail@example.com" +body contains "Addresses" +body contains "Add address" +body not contains "An error occurred" + +POST http://localhost:8080/insert_address.sql?user_id=987654 +Content-Type: application/x-www-form-urlencoded +`Street=42%20Hurl%20Street&City=Testville&Country=Exampleland` +HTTP 302 +[Asserts] +header "Location" == "edit_user.sql?id=987654" + +GET http://localhost:8080/edit_user.sql?id=987654 +HTTP 200 +[Asserts] +body contains "Hurl" +body contains "Masterdetail" +body contains "42 Hurl Street, Testville, Exampleland" +body not contains "An error occurred" + +POST http://localhost:8080/insert_user.sql?id=987654 +Content-Type: application/x-www-form-urlencoded +`First%20name=Hurl&Last%20name=Updated&Email=hurl-updated-masterdetail%40example.com` +HTTP 302 +[Asserts] +header "Location" == "edit_user.sql?id=987654" + +GET http://localhost:8080/edit_user.sql?id=987654 +HTTP 200 +[Asserts] +body contains "Hurl" +body contains "Updated" +body contains "hurl-updated-masterdetail@example.com" +body contains "42 Hurl Street, Testville, Exampleland" +body not contains "Masterdetail" +body not contains "An error occurred" + +GET http://localhost:8080 +HTTP 200 +[Asserts] +body contains "Hurl Updated" +body contains "hurl-updated-masterdetail@example.com" +body not contains "An error occurred" diff --git a/examples/microsoft sql server advanced forms/README.md b/examples/microsoft sql server advanced forms/README.md new file mode 100644 index 0000000..7a16028 --- /dev/null +++ b/examples/microsoft sql server advanced forms/README.md @@ -0,0 +1,34 @@ +# Handling json data in Microsoft SQL Server + +This demonstrates both how to produce and read json data from a SQL query +in MS SQL Server (or Azure SQL Database), for creating advanced forms. + +This lets your user interact with your database with a simple web interface, +even when you have multiple tables, with one-to-many relationships. + +![](./screenshots/app.png) + +## Documentation + +SQLPage requires JSON to create multi-select input (dropdowns where an user can select multiple values). +The result of these multi-selects is a JSON array, which also needs to be read by SQL queries. + +This example demonstrates how to consume [JSON](https://en.wikipedia.org/wiki/JSON) data from a SQL Server database, +using the [`OPENJSON`](https://docs.microsoft.com/en-us/sql/t-sql/functions/openjson-transact-sql) +function to parse the JSON data into a table, +and [`FOR JSON PATH`](https://learn.microsoft.com/en-us/sql/relational-databases/json/format-query-results-as-json-with-for-json-sql-server) +to format query results as a JSON array. + + +This demonstrates an application designed for managing groups and users, allowing the creation of new groups, adding users, and assigning users to one or multiple groups. + +The application has the following sections: + +- **Create a New Group**: A form where users can enter the name of a new group. +- **Groups Display**: A list of existing groups. +- **Add a User**: A form where users can enter the name of a new user and select one or multiple groups to assign to this user. +- **Users Display**: A list of existing users and their associated group memberships. + +When users submit the form, their selections are packaged up and sent to the database server. The server receives these selections as a structured JSON array. + +The database then takes this list of selections and temporarily converts it into a format it can work with using the `OPENJSON` function, before saving the information permanently in the database tables. This allows the system to process multiple selections at once in an efficient way. diff --git a/examples/microsoft sql server advanced forms/docker-compose.yml b/examples/microsoft sql server advanced forms/docker-compose.yml new file mode 100644 index 0000000..652e32e --- /dev/null +++ b/examples/microsoft sql server advanced forms/docker-compose.yml @@ -0,0 +1,30 @@ +services: + web: + image: lovasoa/sqlpage:main + ports: + - "8080:8080" + volumes: + - .:/var/www + - ./sqlpage:/etc/sqlpage + depends_on: + - db + environment: + RUST_LOG: sqlpage=debug + DATABASE_URL: mssql://sa:YourStrong!Passw0rd@db:1433/ + db: + image: mcr.microsoft.com/mssql/server:2022-latest + volumes: + - ./sqlpage/mssql-migrations:/migrations + environment: + ACCEPT_EULA: Y + MSSQL_SA_PASSWORD: YourStrong!Passw0rd + MSSQL_PID: Express + command: > + bash -c " + /opt/mssql/bin/sqlservr & + until /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P YourStrong!Passw0rd -C -Q 'SELECT 1;'; do + echo 'Waiting for database...' + sleep 1 + done + /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P YourStrong!Passw0rd -C -i /migrations/0001_db_init.sql + tail -f /dev/null" diff --git a/examples/microsoft sql server advanced forms/index.sql b/examples/microsoft sql server advanced forms/index.sql new file mode 100644 index 0000000..b8b1a21 --- /dev/null +++ b/examples/microsoft sql server advanced forms/index.sql @@ -0,0 +1,37 @@ +select 'form' as component, 'Create a new Group' as title, 'Create' as validate; +select 'Name' as name; + +insert into groups(name) select :Name where :Name is not null; + +select 'list' as component, 'Groups' as title, 'No group yet' as empty_title; +select name as title from groups; + +select 'form' as component, 'Add a user' as title, 'Add' as validate; +select 'UserName' as name, 'Name' as label; +select + 'Memberships[]' as name, + 'Group memberships' as label, + 'select' as type, + 1 as multiple, + 'press ctrl to select multiple values' as description, + ( + SELECT name as label, id as value + FROM groups + FOR JSON PATH -- this builds a JSON array of objects + ) as options; + +insert into users(name) select :UserName where :UserName is not null; + +insert into group_members(group_id, user_id) +select json_elem.value, IDENT_CURRENT('users') +from openjson(:Memberships) as json_elem +where :Memberships is not null; + +select 'list' as component, 'Users' as title, 'No user yet' as empty_title; +select + users.name as title, + string_agg(groups.name, ', ') as description +from users +left join group_members on users.id = group_members.user_id +left join groups on groups.id = group_members.group_id +group by users.id, users.name; \ No newline at end of file diff --git a/examples/microsoft sql server advanced forms/screenshots/app.png b/examples/microsoft sql server advanced forms/screenshots/app.png new file mode 100644 index 0000000..6607745 Binary files /dev/null and b/examples/microsoft sql server advanced forms/screenshots/app.png differ diff --git a/examples/microsoft sql server advanced forms/sqlpage/mssql-migrations/0001_db_init.sql b/examples/microsoft sql server advanced forms/sqlpage/mssql-migrations/0001_db_init.sql new file mode 100644 index 0000000..8748b56 --- /dev/null +++ b/examples/microsoft sql server advanced forms/sqlpage/mssql-migrations/0001_db_init.sql @@ -0,0 +1,35 @@ +create table users ( + id int primary key IDENTITY(1,1), + name varchar(255) not null +); + +create table groups ( + id int primary key IDENTITY(1,1), + name varchar(255) not null +); + +create table group_members ( + group_id int not null, + user_id int not null, + constraint PK_group_members primary key (group_id, user_id), + constraint FK_group_members_groups foreign key (group_id) references groups (id), + constraint FK_group_members_users foreign key (user_id) references users (id) +); + +CREATE TABLE questions( + id INT PRIMARY KEY IDENTITY(1,1), + question_text TEXT +); + +CREATE TABLE survey_answers( + id INT PRIMARY KEY IDENTITY(1,1), + question_id INT, + answer TEXT, + timestamp DATETIME DEFAULT GETDATE(), + CONSTRAINT FK_survey_answers_questions FOREIGN KEY (question_id) REFERENCES questions(id) +); + +INSERT INTO questions(question_text) VALUES + ('What is your name?'), + ('What is your age?'), + ('What is your favorite color?'); diff --git a/examples/microsoft sql server advanced forms/sqlpage/mssql-migrations/README.md b/examples/microsoft sql server advanced forms/sqlpage/mssql-migrations/README.md new file mode 100644 index 0000000..0e1d5b5 --- /dev/null +++ b/examples/microsoft sql server advanced forms/sqlpage/mssql-migrations/README.md @@ -0,0 +1,11 @@ +# Migrations for Microsoft SQL Server + +This folder contains the migrations for the Microsoft SQL Server example. + +At the time of writing, SQLPage does not support applying migrations for Microsoft SQL Server +automatically, so we need to apply them manually. + +We write the migrations in a folder called `mssql-migrations`, instead of the usual `migrations` +folder, and we use the `sqlcmd` tool to apply them. + +See [how it is done in the docker-compose file](../../docker-compose.yml). diff --git a/examples/microsoft sql server advanced forms/survey.sql b/examples/microsoft sql server advanced forms/survey.sql new file mode 100644 index 0000000..e1406e1 --- /dev/null +++ b/examples/microsoft sql server advanced forms/survey.sql @@ -0,0 +1,27 @@ +SELECT 'form' as component, 'Survey' as title; +SELECT id as name, question_text as label, 'textarea' as type +FROM questions; + +-- Save all the answers to the database, whatever the number and id of the questions +INSERT INTO survey_answers (question_id, answer) +SELECT + question_id, + json_unquote( + json_extract( + sqlpage.variables('post'), + concat('$."', question_id, '"') + ) + ) +FROM json_table( + json_keys(sqlpage.variables('post')), + '$[*]' columns (question_id int path '$') +) as question_ids; + +-- Show the answers +select 'card' as component, 'Survey results' as title; +select + questions.question_text as title, + survey_answers.answer as description, + 'On ' || survey_answers.timestamp as footer +from survey_answers +inner join questions on questions.id = survey_answers.question_id; diff --git a/examples/microsoft sql server advanced forms/test.hurl b/examples/microsoft sql server advanced forms/test.hurl new file mode 120000 index 0000000..0dd017b --- /dev/null +++ b/examples/microsoft sql server advanced forms/test.hurl @@ -0,0 +1 @@ +../../test.hurl \ No newline at end of file diff --git a/examples/modeling a many to many relationship with a form/README.md b/examples/modeling a many to many relationship with a form/README.md new file mode 100644 index 0000000..1ccf60d --- /dev/null +++ b/examples/modeling a many to many relationship with a form/README.md @@ -0,0 +1,13 @@ +# A simple blog with topics and posts + +Users can create topics and posts. A topic can have many posts and a post can have many topics. + +See + - [`write.sql`](./write.sql) for the form definition, + - [`write_submit.sql`](./write_submit.sql) for the database insertion code. + +![](./screenshots/home.png) +![](./screenshots/topics.png) +![](./screenshots/topic.png) +![](./screenshots/post.png) +![](./screenshots/write.png) \ No newline at end of file diff --git a/examples/modeling a many to many relationship with a form/docker-compose.yml b/examples/modeling a many to many relationship with a form/docker-compose.yml new file mode 100644 index 0000000..7305eda --- /dev/null +++ b/examples/modeling a many to many relationship with a form/docker-compose.yml @@ -0,0 +1,8 @@ +services: + web: + image: lovasoa/sqlpage:main + ports: + - "8080:8080" + volumes: + - .:/var/www + - ./sqlpage:/etc/sqlpage diff --git a/examples/modeling a many to many relationship with a form/index.sql b/examples/modeling a many to many relationship with a form/index.sql new file mode 100644 index 0000000..26cd0c0 --- /dev/null +++ b/examples/modeling a many to many relationship with a form/index.sql @@ -0,0 +1,32 @@ +SELECT * FROM sqlpage_shell LIMIT 1; + +SELECT 'list' as component, + COALESCE( + (SELECT name FROM topic WHERE id = $topic), + 'Recent blog posts' + ) as title; + +SELECT post.title as title, + 'post.sql?id=' || post.id as link, + 'Published on ' || created_at as description, + CASE + WHEN created_at > date('now', '-2 days') THEN 'red' + ELSE NULL + END as color, + topic.icon as icon, + created_at > date('now', '-2 days') as active +FROM post + LEFT JOIN topic ON topic.id = post.main_topic_id +WHERE $topic IS NULL + OR topic.id = $topic + OR EXISTS ( + SELECT 1 + FROM topic_post + WHERE topic_post.topic_id = $topic + AND topic_post.post_id = post.id + ) +ORDER BY created_at DESC; + +SELECT 'text' AS component; +SELECT 'No blog post yet. ' AS contents WHERE NOT EXISTS (SELECT 1 FROM post); +SELECT 'Write a post !' AS contents, 'write.sql' AS link; \ No newline at end of file diff --git a/examples/modeling a many to many relationship with a form/post.sql b/examples/modeling a many to many relationship with a form/post.sql new file mode 100644 index 0000000..59eecde --- /dev/null +++ b/examples/modeling a many to many relationship with a form/post.sql @@ -0,0 +1,21 @@ +SELECT * +FROM sqlpage_shell +LIMIT 1; + +SELECT 'text' AS component; + +SELECT content || ' + +--- + +Published on: _' || created_at || '_ in category [' || topic.name || '](/?topic=' || topic.id || ')' || '. + +Other associated categories: ' || ( + SELECT group_concat('[' || name || '](/?topic=' || topic_id || ')', ', ') + FROM topic_post + INNER JOIN topic ON topic.id = topic_post.topic_id + WHERE post_id = $id + ) || '.' AS contents_md +FROM post + INNER JOIN topic ON topic.id = post.main_topic_id +WHERE post.id = $id; \ No newline at end of file diff --git a/examples/modeling a many to many relationship with a form/screenshots/home.png b/examples/modeling a many to many relationship with a form/screenshots/home.png new file mode 100644 index 0000000..2b34e62 Binary files /dev/null and b/examples/modeling a many to many relationship with a form/screenshots/home.png differ diff --git a/examples/modeling a many to many relationship with a form/screenshots/post.png b/examples/modeling a many to many relationship with a form/screenshots/post.png new file mode 100644 index 0000000..5ee8891 Binary files /dev/null and b/examples/modeling a many to many relationship with a form/screenshots/post.png differ diff --git a/examples/modeling a many to many relationship with a form/screenshots/topic.png b/examples/modeling a many to many relationship with a form/screenshots/topic.png new file mode 100644 index 0000000..de0b0bc Binary files /dev/null and b/examples/modeling a many to many relationship with a form/screenshots/topic.png differ diff --git a/examples/modeling a many to many relationship with a form/screenshots/topics.png b/examples/modeling a many to many relationship with a form/screenshots/topics.png new file mode 100644 index 0000000..4636f69 Binary files /dev/null and b/examples/modeling a many to many relationship with a form/screenshots/topics.png differ diff --git a/examples/modeling a many to many relationship with a form/screenshots/write.png b/examples/modeling a many to many relationship with a form/screenshots/write.png new file mode 100644 index 0000000..5b473c5 Binary files /dev/null and b/examples/modeling a many to many relationship with a form/screenshots/write.png differ diff --git a/examples/modeling a many to many relationship with a form/sqlpage/migrations/00_schema.sql b/examples/modeling a many to many relationship with a form/sqlpage/migrations/00_schema.sql new file mode 100644 index 0000000..dbd89d2 --- /dev/null +++ b/examples/modeling a many to many relationship with a form/sqlpage/migrations/00_schema.sql @@ -0,0 +1,37 @@ +-- A simple blog with topics and posts +-- Users can create topics and posts. A topic can have many posts and a post can have many topics. + +-- The first step is to create the tables in the database. We will use the following SQL queries: +CREATE TABLE topic ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + icon TEXT NOT NULL, + UNIQUE (name) +); + +CREATE TABLE post ( + id INTEGER PRIMARY KEY, + title TEXT NOT NULL, + content TEXT NOT NULL, -- The contents will be stored in markdown + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + main_topic_id INTEGER REFERENCES topic(id) +); + +CREATE TABLE topic_post ( + topic_id INTEGER NOT NULL REFERENCES topic(id), + post_id INTEGER NOT NULL REFERENCES post(id), + PRIMARY KEY (topic_id, post_id) +) WITHOUT ROWID; + +-- A view of the topics with the number of posts and most recent post +CREATE VIEW topic_with_stats AS +SELECT topic.id, + topic.name, + topic.icon, + COALESCE(count(topic_post.post_id), 0) as nb_posts, + max(post.created_at) as last_post +FROM topic + LEFT JOIN topic_post ON topic_post.topic_id = topic.id + LEFT JOIN post ON post.id = topic_post.post_id +GROUP BY topic.id +ORDER BY nb_posts DESC; \ No newline at end of file diff --git a/examples/modeling a many to many relationship with a form/sqlpage/migrations/01_sample_data.sql b/examples/modeling a many to many relationship with a form/sqlpage/migrations/01_sample_data.sql new file mode 100644 index 0000000..bf76249 --- /dev/null +++ b/examples/modeling a many to many relationship with a form/sqlpage/migrations/01_sample_data.sql @@ -0,0 +1,233 @@ +-- Insert topics +INSERT INTO topic (name, icon) VALUES + ('Technology', 'photo-code'), + ('Travel', 'plane'), + ('Food', 'pizza'), + ('Health', 'heartbeat'), + ('Entertainment', 'player-track-next-filled'), + ('Sports', 'ball-football'), + ('Nature', 'tree'), + ('Fashion', 'tie'), + ('Finance', 'cash'), + ('Education', 'book'), + ('Science', 'flask'), + ('Music', 'headphones'), + ('Art', 'palette'), + ('Fitness', 'barbell'), + ('Politics', 'globe'), + ('History', 'building-arch'), + ('Lifestyle', 'camera'), + ('Gaming', 'brand-apple-arcade'), + ('Business', 'briefcase'), + ('Psychology', 'brain'); + +-- Insert posts +-- Post 1 +INSERT INTO post (title, content, created_at, main_topic_id) VALUES ( + 'The Future of Artificial Intelligence', + '# The Future of Artificial Intelligence + +Artificial Intelligence (AI) has been a topic of great interest and speculation in recent years. With advancements in technology and the increasing availability of data, AI has the potential to revolutionize various industries and aspects of our lives. + +One of the key areas where AI is making significant strides is in machine learning. Machine learning algorithms enable computers to analyze large amounts of data and learn patterns and trends from it. This has applications in fields such as finance, healthcare, and marketing, among others. + +Another exciting development in AI is the emergence of deep learning. Deep learning is a subfield of machine learning that focuses on neural networks and their ability to learn and make decisions in a way similar to the human brain. Deep learning has shown promising results in image and speech recognition, natural language processing, and autonomous driving. + +While AI presents tremendous opportunities, it also raises important ethical and societal questions. Concerns about job displacement, privacy, and biases in AI algorithms need to be addressed to ensure responsible and beneficial use of AI. + +As we look ahead, it''s clear that AI will continue to shape the future. It has the potential to drive innovation, improve efficiency, and solve complex problems. However, it''s essential to strike a balance between technological advancement and ethical considerations to harness the full potential of AI for the benefit of society.', + '2022-08-10 12:00:00', + (SELECT id FROM topic WHERE name = 'Technology') +); +INSERT INTO topic_post (topic_id, post_id) VALUES + ((SELECT id FROM topic WHERE name = 'Business'), last_insert_rowid()), + ((SELECT id FROM topic WHERE name = 'Politics'), last_insert_rowid()); + +-- Post 2 +INSERT INTO post (title, content, created_at, main_topic_id) VALUES ( + 'Exploring the Hidden Gems of Europe', + '# Exploring the Hidden Gems of Europe + +Europe is a continent known for its rich history, diverse cultures, and breathtaking landscapes. While popular destinations like Paris, Rome, and Barcelona attract millions of tourists each year, there are also lesser-known places that offer unique experiences and a chance to discover the hidden gems of Europe. + +One such hidden gem is the village of Hallstatt in Austria. Nestled between the mountains and the Hallstätter See, this picturesque village is like something out of a fairytale. Its charming streets, traditional wooden houses, and stunning views make it a perfect destination for nature lovers and photographers. + +Another hidden gem is the Plitvice Lakes National Park in Croatia. With its cascading waterfalls, crystal clear lakes, and lush greenery, this national park is a paradise for hikers and nature enthusiasts. Exploring the park''s network of wooden walkways and taking a boat ride on the lakes is an unforgettable experience. + +In Portugal, the village of Monsaraz is a hidden gem that offers a glimpse into the country''s medieval past. Perched on a hilltop, the village is surrounded by fortified walls and offers panoramic views of the surrounding countryside. Its narrow streets, whitewashed houses, and historic castle create a magical atmosphere. + +These are just a few examples of the hidden gems that Europe has to offer. Exploring off-the-beaten-path destinations can be a rewarding experience, allowing you to discover the lesser-known treasures and immerse yourself in the local culture. + +So, the next time you plan a trip to Europe, consider venturing beyond the popular tourist destinations and embark on a journey to explore the hidden gems that await you.', + '2023-04-22 10:30:00', + (SELECT id FROM topic WHERE name = 'Travel') +); +INSERT INTO topic_post (topic_id, post_id) VALUES + ((SELECT id FROM topic WHERE name = 'History'), last_insert_rowid()), + ((SELECT id FROM topic WHERE name = 'Art'), last_insert_rowid()); + +-- Post 3 +INSERT INTO post (title, content, created_at, main_topic_id) VALUES ( + 'Delicious and Healthy Recipes for Every Foodie', + '# Delicious and Healthy Recipes for Every Foodie + +Are you a food lover who enjoys both delicious flavors and maintaining a healthy lifestyle? If so, you''re in luck! In this post, we''ll share some mouthwatering recipes that are not only incredibly tasty but also packed with nutritious ingredients. + +**1. Quinoa Salad with Roasted Vegetables** + +Ingredients: +- 1 cup quinoa +- Assorted vegetables (such as bell peppers, zucchini, and cherry tomatoes) +- Olive oil +- Balsamic vinegar +- Salt and pepper +- Fresh herbs (such as parsley or basil) + +Instructions: +- Cook the quinoa according to package instructions. +- Preheat the oven to 400°F (200°C). +- Chop the vegetables into bite-sized pieces and place them on a baking sheet. +- Drizzle with olive oil, balsamic vinegar, salt, and pepper. +- Roast in the oven for 20-25 minutes, or until the vegetables are tender and slightly charred. +- In a large bowl, combine the cooked quinoa and roasted vegetables. +- Drizzle with additional olive oil and vinegar if desired. +- Season with salt, pepper, and fresh herbs. + +**2. Grilled Salmon with Lemon and Dill** + +Ingredients: +- Salmon fillets +- Lemon slices +- Fresh dill +- Salt and pepper +- Olive oil + +Instructions: +- Preheat the grill to medium-high heat. +- Season the salmon fillets with salt, pepper, and a drizzle of olive oil. +- Place the salmon on the grill, skin-side down. +- Grill for about 4-5 minutes on each side, or until cooked to your desired level of doneness. +- During the last minute of grilling, place a few lemon slices and fresh dill on top of each salmon fillet. +- Remove from the grill and serve immediately. + +**3. Mango and Avocado Salsa** + +Ingredients: +- Ripe mango, diced +- Ripe avocado, diced +- Red onion, finely chopped +- Fresh cilantro, chopped +- Lime juice +- Salt and pepper + +Instructions: +- In a bowl, combine the diced mango, avocado, red onion, and cilantro. +- Squeeze fresh lime juice over the mixture and gently toss to combine. +- Season with salt and pepper to taste. +- Let the salsa sit for a few minutes to allow the flavors to meld together. +- Serve with tortilla chips or as a topping for grilled chicken or fish. + +These recipes are just a starting point for your culinary adventures. Feel free to modify them to suit your taste preferences and experiment with different ingredients. Enjoy the delicious and healthy creations you make in your kitchen! + +**Note**: Always consult your doctor or a qualified nutritionist before making any significant changes to your diet or if you have any specific dietary concerns or restrictions.', + '2022-12-05 15:15:00', + (SELECT id FROM topic WHERE name = 'Food') +); +INSERT INTO topic_post (topic_id, post_id) VALUES + ((SELECT id FROM topic WHERE name = 'Food'), last_insert_rowid()); + +-- Post 4 +INSERT INTO post (title, content, created_at, main_topic_id) VALUES ( + '10 Tips for a Healthier Lifestyle', + '# 10 Tips for a Healthier Lifestyle + +Maintaining a healthy lifestyle is essential for overall well-being and longevity. Small changes in your daily habits can make a significant difference in improving your health. Here are ten tips to help you lead a healthier life: + +**1. Eat a Balanced Diet**: Focus on consuming a variety of whole foods, including fruits, vegetables, lean proteins, whole grains, and healthy fats. Limit processed foods, sugary drinks, and excessive salt intake. + +**2. Stay Hydrated**: Drink plenty of water throughout the day to keep your body hydrated. Avoid sugary beverages and excessive alcohol consumption. + +**3. Get Regular Exercise**: Aim for at least 150 minutes of moderate-intensity aerobic activity or 75 minutes of vigorous-intensity aerobic activity each week. Include strength training exercises to build muscle and improve bone density. + +**4. Prioritize Sleep**: Make sleep a priority and aim for 7-9 hours of quality sleep each night. Establish a consistent sleep schedule and create a relaxing bedtime routine. + +**5. Manage Stress**: Find healthy ways to manage stress, such as practicing mindfulness meditation, deep breathing exercises, or engaging in hobbies that bring you joy. + +**6. Maintain a Healthy Weight**: Strive to achieve and maintain a healthy weight for your body. This can be accomplished through a combination of healthy eating and regular physical activity. + +**7. Practice Good Hygiene**: Follow proper hygiene practices, including regular handwashing, dental care, and getting vaccinated as recommended by healthcare professionals. + +**8. Limit Screen Time**: Reduce the amount of time spent in front of screens, including smartphones, tablets, and computers. Take regular breaks and engage in physical activities or social interactions. + +**9. Foster Healthy Relationships**: Cultivate positive relationships with family, friends, and a supportive community. Social connections contribute to overall well-being. + +**10. Take Time for Self-Care**: Prioritize self-care activities that promote relaxation, such as taking a bath, reading a book, or engaging in hobbies you enjoy. + +Remember, it''s essential to consult with healthcare professionals for personalized advice and guidance. By incorporating these tips into your daily routine, you can embark on a journey towards a healthier and more fulfilling life. + +**Note**: The information provided is for general educational purposes and should not replace professional medical advice.', + '2023-01-20 09:45:00', + (SELECT id FROM topic WHERE name = 'Health') +); +INSERT INTO topic_post (topic_id, post_id) VALUES + ((SELECT id FROM topic WHERE name = 'Fitness'), last_insert_rowid()), + ((SELECT id FROM topic WHERE name = 'Health'), last_insert_rowid()); + +-- Post 5 +INSERT INTO post (title, content, created_at, main_topic_id) VALUES ( + 'The Rise of Streaming Platforms', + '# The Rise of Streaming Platforms + +Streaming platforms have revolutionized the way we consume media and entertainment. With the advent of high-speed internet and advancements in technology, streaming has become increasingly popular and has disrupted traditional media channels. + +Gone are the days of waiting for your favorite TV show to air on a specific day and time. Streaming platforms offer on-demand access to a vast library of movies, TV shows, documentaries, and original content. Whether it''s Netflix, Amazon Prime Video, Disney+, or other streaming services, there''s something for everyone. + +One of the key advantages of streaming platforms is the ability to watch content anytime, anywhere, and on any device. With a stable internet connection, you can stream your favorite shows on your TV, computer, tablet, or smartphone. This convenience has transformed the way we consume entertainment, giving us more control over our viewing experience. + +Streaming platforms have also opened doors for diverse storytelling and content creation. They provide a platform for independent filmmakers, content creators, and artists to showcase their work and reach a global audience. This has led to the production of unique and innovative content that may not have found a place in traditional media channels. + +However, the rise of streaming platforms has not been without challenges. Issues such as content licensing, regional restrictions, and subscription costs have raised concerns among consumers. Additionally, the abundance of content can sometimes be overwhelming, making it difficult to choose what to watch. + +As streaming platforms continue to evolve, we can expect further advancements in technology, improved user experiences, and a more competitive landscape. The future of entertainment lies in the hands of streaming, and it''s an exciting time for both creators and consumers. + +So, grab your popcorn, find a comfortable spot on the couch, and immerse yourself in the world of streaming entertainment!', + '2022-09-17 18:20:00', + (SELECT id FROM topic WHERE name = 'Entertainment') +); +INSERT INTO topic_post (topic_id, post_id) VALUES + ((SELECT id FROM topic WHERE name = 'Gaming'), last_insert_rowid()); + +-- Post 6 +INSERT INTO post (title, content, created_at, main_topic_id) VALUES ( + 'The Thrilling World of Extreme Sports', + '# The Thrilling World of Extreme Sports + +If you are an adrenaline junkie and seek thrilling experiences, extreme sports might be your calling. These activities push the boundaries of what''s possible and offer an exhilarating rush that can''s be matched by anything else. + +**1. Skydiving**: Jumping out of a plane and freefalling through the sky is the epitome of an adrenaline rush. Whether you''re a first-time skydiver or an experienced jumper, the feeling of soaring through the air is unparalleled. + +**2. Bungee Jumping**: Plunging from a high platform or bridge with only a bungee cord attached to your feet is an extreme sport that requires courage and a love for heights. The thrill of the freefall and the rebounding sensation as the cord pulls you back up is an experience like no other. + +**3. Rock Climbing**: Scaling cliffs, mountains, or indoor walls requires physical strength, mental focus, and problem-solving skills. The sense of achievement when reaching the top and enjoying the breathtaking views is incredibly rewarding. + +**4. Whitewater Rafting**: Battling through turbulent rapids and navigating rivers is an adrenaline-pumping adventure. Paddling as a team and overcoming the challenges of the water adds an element of excitement and camaraderie. + +**5. Snowboarding**: Gliding down snow-covered slopes, performing tricks, and feeling the rush of speed is what snowboarding is all about. Whether you prefer freestyle or backcountry riding, the mountains offer endless possibilities for adrenaline-fueled fun. + +**6. Surfing**: Riding the waves and harnessing the power of the ocean is a thrilling experience for surfers. Whether you''re a beginner catching your first wave or an expert riding massive swells, the feeling of being in sync with the water is pure bliss. + +**7. Base Jumping**: BASE stands for Building, Antenna, Span, and Earth, representing the four types of fixed objects from which BASE jumpers leap. It''s an extreme sport that involves parachuting from fixed objects and requires specialized skills and training. + +These are just a few examples of extreme sports that offer excitement and adventure. However, it''s important to remember that these activities come with inherent risks, and proper training, equipment, and safety precautions should always be prioritized. + +If you''re craving an adrenaline rush and are willing to step outside your comfort zone, give extreme sports a try. Just be prepared to experience the thrill of a lifetime!', + '2023-06-07 14:10:00', + (SELECT id FROM topic WHERE name = 'Sports') +); +INSERT INTO topic_post (topic_id, post_id) VALUES + ((SELECT id FROM topic WHERE name = 'Fitness'), last_insert_rowid()), + ((SELECT id FROM topic WHERE name = 'Entertainment'), last_insert_rowid()), + ((SELECT id FROM topic WHERE name = 'Health'), last_insert_rowid()), + ((SELECT id FROM topic WHERE name = 'Lifestyle'), last_insert_rowid()), + ((SELECT id FROM topic WHERE name = 'Travel'), last_insert_rowid()), + ((SELECT id FROM topic WHERE name = 'Nature'), last_insert_rowid()); diff --git a/examples/modeling a many to many relationship with a form/sqlpage/migrations/03_sqlpage_shell.sql b/examples/modeling a many to many relationship with a form/sqlpage/migrations/03_sqlpage_shell.sql new file mode 100644 index 0000000..46c1697 --- /dev/null +++ b/examples/modeling a many to many relationship with a form/sqlpage/migrations/03_sqlpage_shell.sql @@ -0,0 +1,22 @@ +-- Instead of adding a long select at the top of all pages. +-- We want to be able to to a simple query like: +-- SELECT * FROM shell LIMIT 1; + +CREATE TABLE sqlpage_shell ( + component TEXT NOT NULL, + title TEXT NOT NULL, + link TEXT NOT NULL, + menu_item TEXT NOT NULL, + lang TEXT NOT NULL, + description TEXT NOT NULL, + font TEXT NOT NULL, + font_size INTEGER NOT NULL, + icon TEXT NOT NULL, + footer TEXT NOT NULL +); + +INSERT INTO sqlpage_shell ( +component, title, link, menu_item, lang, description, font, font_size, icon, footer +) VALUES ( +'shell', 'SQL Blog', '/', 'topics', 'en-US', 'A cool SQL-only blog', 'Playfair Display', 21, 'book', 'This blog is written entirely in SQL with [SQLPage](https://sql-page.com)' +); \ No newline at end of file diff --git a/examples/modeling a many to many relationship with a form/test.hurl b/examples/modeling a many to many relationship with a form/test.hurl new file mode 100644 index 0000000..1679565 --- /dev/null +++ b/examples/modeling a many to many relationship with a form/test.hurl @@ -0,0 +1,33 @@ +GET http://localhost:8080/ +HTTP 200 +[Asserts] +body contains "Recent blog posts" +body contains "Write a post !" +body not contains "An error occurred" + +GET http://localhost:8080/write.sql +HTTP 200 +[Asserts] +body contains "Title of the post" +body contains "Main Topic" +body contains "Technology" +body contains "Travel" +body not contains "An error occurred" + +POST http://localhost:8080/write_submit.sql +Content-Type: application/x-www-form-urlencoded +`Title=Hurl%20many-to-many%20post&Content=Hurl%20content%20for%20the%20many-to-many%20form.&Main%20Topic=1&Topics%5B%5D=2&Topics%5B%5D=3` +HTTP 302 +[Captures] +location: header "Location" +[Asserts] +header "Location" matches "^post\\.sql\\?id=\\d+$" + +GET http://localhost:8080/{{location}} +HTTP 200 +[Asserts] +body contains "Hurl content for the many-to-many form." +body contains "Technology" +body contains "Travel" +body contains "Food" +body not contains "An error occurred" diff --git a/examples/modeling a many to many relationship with a form/topics.sql b/examples/modeling a many to many relationship with a form/topics.sql new file mode 100644 index 0000000..3a8805e --- /dev/null +++ b/examples/modeling a many to many relationship with a form/topics.sql @@ -0,0 +1,15 @@ +SELECT * FROM sqlpage_shell LIMIT 1; + +SELECT 'list' as component, + 'Topics' as title; +SELECT name as title, + '/?topic=' || id as link, + nb_posts || ' posts. '|| + COALESCE('Last post on *' || last_post || '*', '') as description_md, + CASE + WHEN last_post > date('now', '-2 days') THEN 'red' + ELSE NULL + END as color, + icon, + last_post > date('now', '-2 days') as active +FROM topic_with_stats; \ No newline at end of file diff --git a/examples/modeling a many to many relationship with a form/write.sql b/examples/modeling a many to many relationship with a form/write.sql new file mode 100644 index 0000000..8335bf6 --- /dev/null +++ b/examples/modeling a many to many relationship with a form/write.sql @@ -0,0 +1,11 @@ +SELECT * FROM sqlpage_shell LIMIT 1; + +SELECT 'form' AS component, 'Publish !' AS validate, 'write_submit.sql' AS action; +SELECT 'Title' AS name, 'text' AS type, 'Title of the post. Write something that makes people want to click !' AS description, TRUE AS required; +SELECT 'Content' AS name, 'textarea' AS type, 'The content of the post. Write something exciting !' AS description, TRUE AS required; +SELECT 'Main Topic' AS name, + 'select' AS type, + 'The main topic of the post. This will be used to display the post in the main page.' AS description, + json_group_array(json_object('label', name, 'value', id)) AS options +FROM topic; +SELECT 'Topics[]' AS name, 'checkbox' AS type, 'Check if this post should also appear in the "' || topic.name || '" category.' AS description, topic.id AS value, topic.name AS label FROM topic; \ No newline at end of file diff --git a/examples/modeling a many to many relationship with a form/write_submit.sql b/examples/modeling a many to many relationship with a form/write_submit.sql new file mode 100644 index 0000000..9483c96 --- /dev/null +++ b/examples/modeling a many to many relationship with a form/write_submit.sql @@ -0,0 +1,10 @@ +INSERT INTO post (title, content, main_topic_id) +VALUES (:Title, :Content, :"Main Topic"); + +INSERT INTO topic_post (topic_id, post_id) +SELECT CAST(topic.value AS INTEGER), + last_insert_rowid() +FROM json_each(:Topics) AS topic -- we receive the value from checkboxes as a JSON array +WHERE topic.value IS NOT NULL; + +SELECT 'redirect' AS component, 'post.sql?id=' || last_insert_rowid() AS link; \ No newline at end of file diff --git a/examples/multiple-choice-question/README.md b/examples/multiple-choice-question/README.md new file mode 100644 index 0000000..767a16c --- /dev/null +++ b/examples/multiple-choice-question/README.md @@ -0,0 +1,24 @@ +# SQLPage multiple choice question example + +This is a very simple example of a website that stores a list of +possible answers to a multiple choice question in a database table, +and then displays the question and the possible answers to the user. + +When the user selects an answer, the website will save the user's +choice in the database and display other users' choices as well. + +## Screenshots + +| Question answering form | Results table | Question edition | +| --- | --- | --- | +| ![Question answering form](screenshots/main_form.png) | ![Results table](screenshots/results.png) | ![Question edition](screenshots/admin.png) | + +## How to run + +Just run the sqlpage binary (`./sqlpage.bin`) from this folder. + +## Interesting files + +[admin.sql](admin.sql) uses the [dynamic component](https://sql-page.com/documentation.sql?component=dynamic#component) to create a single page with one form per MCQ option. + +[website_header.json](website_header.json) contains the [shell](https://sql-page.com/documentation.sql?component=shell#component) that is then used in all pages using the `dynamic` component to create a consistent look and feel between pages. \ No newline at end of file diff --git a/examples/multiple-choice-question/admin.sql b/examples/multiple-choice-question/admin.sql new file mode 100644 index 0000000..37b6b24 --- /dev/null +++ b/examples/multiple-choice-question/admin.sql @@ -0,0 +1,36 @@ +select 'dynamic' as component, sqlpage.read_file_as_text('website_header.json') as properties; + +select 'alert' as component, 'Saved' as title, 'success' as color where $saved is not null; +select 'alert' as component, 'Deleted' as title, 'danger' as color where $deleted is not null; +select 'alert' as component, 'This option cannot be deleted' as title, 'danger' as color, 'If an option has already been chosen by at least one respondant, then it cannot be deleted' as description where $cannot_delete is not null; + +select 'dynamic' as component, + json_array( + json_object( + 'component', 'form', + 'title', CONCAT('Option ', id), + 'action', CONCAT('edit_option.sql?id=', id), + 'validate', '', + 'id', CONCAT('option', id) + ), + json_object( + 'type', 'text', + 'name', 'profile_description', + 'label', 'Profile description', + 'value', profile_description + ), + json_object( + 'type', 'number', + 'name', 'score', + 'min', 0, + 'label', 'Score', + 'value', score + ), + json_object('component', 'button', 'size', 'sm'), + json_object('title', 'Delete', 'outline', 'danger', 'icon', 'trash', 'link', CONCAT('delete_option.sql?id=', id)), + json_object('title', 'Save', 'outline', 'success', 'icon', 'device-floppy', 'form', CONCAT('option', id)) + ) as properties +from dog_lover_profiles; + +select 'button' as component, 'center' as justify; +select 'Create new question' as title, 'create_question.sql' as link; \ No newline at end of file diff --git a/examples/multiple-choice-question/create_question.sql b/examples/multiple-choice-question/create_question.sql new file mode 100644 index 0000000..9894f16 --- /dev/null +++ b/examples/multiple-choice-question/create_question.sql @@ -0,0 +1,4 @@ +insert into dog_lover_profiles(profile_description, score) values ('', 50) +returning + 'redirect' as component, + 'admin.sql' as link; \ No newline at end of file diff --git a/examples/multiple-choice-question/delete_option.sql b/examples/multiple-choice-question/delete_option.sql new file mode 100644 index 0000000..e6ecf4b --- /dev/null +++ b/examples/multiple-choice-question/delete_option.sql @@ -0,0 +1,7 @@ +select 'redirect' as component, 'admin.sql?cannot_delete' as link +where exists (select 1 from answers where profile_id = $id); + +delete from dog_lover_profiles where id = $id +returning + 'redirect' as component, + 'admin.sql?deleted' as link; \ No newline at end of file diff --git a/examples/multiple-choice-question/docker-compose.yml b/examples/multiple-choice-question/docker-compose.yml new file mode 100644 index 0000000..7305eda --- /dev/null +++ b/examples/multiple-choice-question/docker-compose.yml @@ -0,0 +1,8 @@ +services: + web: + image: lovasoa/sqlpage:main + ports: + - "8080:8080" + volumes: + - .:/var/www + - ./sqlpage:/etc/sqlpage diff --git a/examples/multiple-choice-question/edit_option.sql b/examples/multiple-choice-question/edit_option.sql new file mode 100644 index 0000000..bd70bee --- /dev/null +++ b/examples/multiple-choice-question/edit_option.sql @@ -0,0 +1,6 @@ +update dog_lover_profiles +set profile_description = :profile_description, score = :score +where id = $id +returning + 'redirect' as component, + 'admin.sql?saved' as link; \ No newline at end of file diff --git a/examples/multiple-choice-question/index.sql b/examples/multiple-choice-question/index.sql new file mode 100644 index 0000000..6049fd1 --- /dev/null +++ b/examples/multiple-choice-question/index.sql @@ -0,0 +1,9 @@ +select 'dynamic' as component, sqlpage.read_file_as_text('website_header.json') as properties; + +SELECT + 'form' AS component, + 'What dog lover are you ?' AS title, + 'process.sql' AS action; + +select 'radio' as type, 'profile' as name, id as value, profile_description as label +from dog_lover_profiles; \ No newline at end of file diff --git a/examples/multiple-choice-question/process.sql b/examples/multiple-choice-question/process.sql new file mode 100644 index 0000000..0fa81df --- /dev/null +++ b/examples/multiple-choice-question/process.sql @@ -0,0 +1,4 @@ +insert into answers(profile_id) +select CAST(:profile as integer) +where :profile is not null +returning 'redirect' as component, 'results.sql' as link; \ No newline at end of file diff --git a/examples/multiple-choice-question/results.sql b/examples/multiple-choice-question/results.sql new file mode 100644 index 0000000..917e77c --- /dev/null +++ b/examples/multiple-choice-question/results.sql @@ -0,0 +1,7 @@ +select 'dynamic' as component, sqlpage.read_file_as_text('website_header.json') as properties; + +select timestamp, profile_description, score from answers +inner join dog_lover_profiles on dog_lover_profiles.id = answers.profile_id; + +select 'csv' as component; +select * from answers; \ No newline at end of file diff --git a/examples/multiple-choice-question/screenshots/admin.png b/examples/multiple-choice-question/screenshots/admin.png new file mode 100644 index 0000000..36347a4 Binary files /dev/null and b/examples/multiple-choice-question/screenshots/admin.png differ diff --git a/examples/multiple-choice-question/screenshots/main_form.png b/examples/multiple-choice-question/screenshots/main_form.png new file mode 100644 index 0000000..3ccc792 Binary files /dev/null and b/examples/multiple-choice-question/screenshots/main_form.png differ diff --git a/examples/multiple-choice-question/screenshots/results.png b/examples/multiple-choice-question/screenshots/results.png new file mode 100644 index 0000000..57bdd0e Binary files /dev/null and b/examples/multiple-choice-question/screenshots/results.png differ diff --git a/examples/multiple-choice-question/sqlpage/migrations/0001_create_users_table.sql b/examples/multiple-choice-question/sqlpage/migrations/0001_create_users_table.sql new file mode 100644 index 0000000..6dd15e8 --- /dev/null +++ b/examples/multiple-choice-question/sqlpage/migrations/0001_create_users_table.sql @@ -0,0 +1,14 @@ +create table dog_lover_profiles( + id integer primary key, + profile_description text not null, + score integer not null +); + +insert into dog_lover_profiles(profile_description, score) + values ('I love dogs', 100), ('I hate them', 0); + +create table answers( + id integer primary key, + profile_id integer not null references dog_lover_profiles(id), + timestamp timestamp not null default current_timestamp +); \ No newline at end of file diff --git a/examples/multiple-choice-question/test.hurl b/examples/multiple-choice-question/test.hurl new file mode 100644 index 0000000..70d20bc --- /dev/null +++ b/examples/multiple-choice-question/test.hurl @@ -0,0 +1,24 @@ +GET http://localhost:8080 +HTTP 200 +[Asserts] +header "Content-Type" contains "text/html" +body contains "What dog lover are you ?" +body contains "process.sql" +body contains "I love dogs" +body contains "I hate them" +body not contains "An error occurred" + +POST http://localhost:8080/process.sql +[FormParams] +profile: 1 +HTTP 302 +[Asserts] +header "Location" == "results.sql" + +GET http://localhost:8080/results.sql +HTTP 200 +[Asserts] +header "Content-Type" contains "text/html" +body contains "I love dogs" +body contains "100" +body not contains "An error occurred" diff --git a/examples/multiple-choice-question/website_header.json b/examples/multiple-choice-question/website_header.json new file mode 100644 index 0000000..95e4088 --- /dev/null +++ b/examples/multiple-choice-question/website_header.json @@ -0,0 +1,7 @@ +{ + "component": "shell", + "title": "SQLPage Questions", + "icon": "help-hexagon", + "link": "/index.sql", + "menu_item": ["index", "results", "admin"] +} diff --git a/examples/mysql json handling/README.md b/examples/mysql json handling/README.md new file mode 100644 index 0000000..191c17c --- /dev/null +++ b/examples/mysql json handling/README.md @@ -0,0 +1,20 @@ +# Handling json data in MySQL + +This demonstrates both how to produce json data from a SQL query in MySQL +and how to consume json data from SQLPage. + +![](./screenshots/app.png) + +## Documentation + +This example demonstrates how to consume [JSON](https://en.wikipedia.org/wiki/JSON) data from a MySQL database, +using the [`JSON_TABLE`](https://dev.mysql.com/doc/refman/8.0/en/json-table-functions.html) +function to parse the JSON data into a table. + +The main page contains a form with a select input, in which the user can +select one or multiple values. + +The values are then sent to the server, and are accessible from SQL queries in the form of a JSON array. + +The SQL then uses the `JSON_TABLE` function to transform the JSON array into a temporary table, +which is then used to process the data and insert it into the database. \ No newline at end of file diff --git a/examples/mysql json handling/docker-compose.yml b/examples/mysql json handling/docker-compose.yml new file mode 100644 index 0000000..f9d9026 --- /dev/null +++ b/examples/mysql json handling/docker-compose.yml @@ -0,0 +1,17 @@ +services: + web: + image: lovasoa/sqlpage:main # main is cutting edge, use sqlpage/SQLPage:latest for the latest stable version + ports: + - "8080:8080" + volumes: + - .:/var/www + - ./sqlpage:/etc/sqlpage + depends_on: + - db + environment: + DATABASE_URL: mysql://root:secret@db/sqlpage + db: # The DB environment variable can be set to "mariadb" or "postgres" to test the code with different databases + image: mariadb:12 # support for json_table was added in mariadb 10.6 + environment: + MYSQL_ROOT_PASSWORD: secret + MYSQL_DATABASE: sqlpage diff --git a/examples/mysql json handling/index.sql b/examples/mysql json handling/index.sql new file mode 100644 index 0000000..4d15587 --- /dev/null +++ b/examples/mysql json handling/index.sql @@ -0,0 +1,35 @@ +select 'form' as component, 'Create a new Group' as title, 'Create' as validate; +select 'Name' as name; + +insert into groups(name) select :Name where :Name is not null; + +select 'list' as component, 'Groups' as title, 'No group yet' as empty_title; +select name as title from groups; + +select 'form' as component, 'Add a user' as title, 'Add' as validate; +select 'UserName' as name, 'Name' as label; +select + 'Memberships[]' as name, + 'Group memberships' as label, + 'select' as type, + TRUE as multiple, + 'press ctrl to select multiple values' as description, + json_arrayagg(json_object("label", name, "value", id)) as options +from groups; + +insert into users(name) select :UserName where :UserName is not null; +insert into group_members(group_id, user_id) +select group_name, last_insert_id() +from json_table(:Memberships, '$[*]' columns ( + group_name int path '$' +)) as json_elems +where :Memberships is not null; + +select 'list' as component, 'Users' as title, 'No user yet' as empty_title; +select + users.name as title, + group_concat(groups.name) as description +from users +left join group_members on users.id = group_members.user_id +left join groups on groups.id = group_members.group_id +group by users.id, users.name; \ No newline at end of file diff --git a/examples/mysql json handling/screenshots/app.png b/examples/mysql json handling/screenshots/app.png new file mode 100644 index 0000000..6607745 Binary files /dev/null and b/examples/mysql json handling/screenshots/app.png differ diff --git a/examples/mysql json handling/sqlpage/migrations/0001_users_and_groups.sql b/examples/mysql json handling/sqlpage/migrations/0001_users_and_groups.sql new file mode 100644 index 0000000..954872c --- /dev/null +++ b/examples/mysql json handling/sqlpage/migrations/0001_users_and_groups.sql @@ -0,0 +1,17 @@ +create table users ( + id int primary key auto_increment, + name varchar(255) not null +); + +create table groups ( + id int primary key auto_increment, + name varchar(255) not null +); + +create table group_members ( + group_id int not null, + user_id int not null, + primary key (group_id, user_id), + foreign key (group_id) references groups (id), + foreign key (user_id) references users (id) +); \ No newline at end of file diff --git a/examples/mysql json handling/sqlpage/migrations/0002_survey.sql b/examples/mysql json handling/sqlpage/migrations/0002_survey.sql new file mode 100644 index 0000000..d22eec0 --- /dev/null +++ b/examples/mysql json handling/sqlpage/migrations/0002_survey.sql @@ -0,0 +1,18 @@ +CREATE TABLE questions( + id INT PRIMARY KEY AUTO_INCREMENT, + question_text TEXT +); + +CREATE TABLE survey_answers( + id INT PRIMARY KEY AUTO_INCREMENT, + question_id INT, + answer TEXT, + timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (question_id) REFERENCES questions(id) +); + + +INSERT INTO questions(question_text) VALUES + ('What is your name?'), + ('What is your age?'), + ('What is your favorite color?'); diff --git a/examples/mysql json handling/survey.sql b/examples/mysql json handling/survey.sql new file mode 100644 index 0000000..e1406e1 --- /dev/null +++ b/examples/mysql json handling/survey.sql @@ -0,0 +1,27 @@ +SELECT 'form' as component, 'Survey' as title; +SELECT id as name, question_text as label, 'textarea' as type +FROM questions; + +-- Save all the answers to the database, whatever the number and id of the questions +INSERT INTO survey_answers (question_id, answer) +SELECT + question_id, + json_unquote( + json_extract( + sqlpage.variables('post'), + concat('$."', question_id, '"') + ) + ) +FROM json_table( + json_keys(sqlpage.variables('post')), + '$[*]' columns (question_id int path '$') +) as question_ids; + +-- Show the answers +select 'card' as component, 'Survey results' as title; +select + questions.question_text as title, + survey_answers.answer as description, + 'On ' || survey_answers.timestamp as footer +from survey_answers +inner join questions on questions.id = survey_answers.question_id; diff --git a/examples/mysql json handling/test.hurl b/examples/mysql json handling/test.hurl new file mode 100644 index 0000000..77ebbaf --- /dev/null +++ b/examples/mysql json handling/test.hurl @@ -0,0 +1,37 @@ +GET http://localhost:8080 +HTTP 200 +[Asserts] +header "Content-Type" contains "text/html" +xpath "//html" exists +xpath "//body" exists +body not contains "An error occurred" + +POST http://localhost:8080/ +Content-Type: application/x-www-form-urlencoded +[FormParams] +Name: Hurl Analysts +HTTP 200 +[Asserts] +body contains "Hurl Analysts" +body not contains "An error occurred" + +POST http://localhost:8080/ +Content-Type: application/x-www-form-urlencoded +[FormParams] +Name: Hurl Reviewers +HTTP 200 +[Captures] +analysts_id: xpath "string(//select[@name='Memberships[]']/option[normalize-space(.)='Hurl Analysts']/@value)" +reviewers_id: xpath "string(//select[@name='Memberships[]']/option[normalize-space(.)='Hurl Reviewers']/@value)" +[Asserts] +body contains "Hurl Reviewers" +body not contains "An error occurred" + +POST http://localhost:8080/ +Content-Type: application/x-www-form-urlencoded +`UserName=Hurl+User&Memberships%5B%5D={{analysts_id}}&Memberships%5B%5D={{reviewers_id}}` +HTTP 200 +[Asserts] +body contains "Hurl User" +body htmlUnescape contains "Hurl Analysts,Hurl Reviewers" +body not contains "An error occurred" diff --git a/examples/nginx/README.md b/examples/nginx/README.md new file mode 100644 index 0000000..46f38ee --- /dev/null +++ b/examples/nginx/README.md @@ -0,0 +1,169 @@ +# SQLPage with NGINX Example + +This example demonstrates how to set up SQLPage behind an NGINX reverse proxy using Docker Compose. It showcases various features such as rate limiting, URL rewriting, caching, and more. + +## Overview + +The setup consists of three main components: + +1. SQLPage: The main application server +2. NGINX: The reverse proxy +3. MySQL: The database + +## Getting Started + +1. Clone the repository and navigate to the `examples/nginx` directory. + +2. Start the services using Docker Compose: + + ```bash + docker compose up + ``` + +3. Access the application at `http://localhost`. + +## Docker Compose Configuration + +The `docker-compose.yml` file defines the services. + +### SQLPage Service + +The SQLPage service uses the latest SQLPage development image, sets up necessary volume mounts for configuration (on `/etc/sqlpage`) and website (on `/var/www`) files, and establishes a connection to the MySQL database. +It reads http requests from a Unix socket (instead of a TCP socket) for communication with NGINX. This removes the overhead of TCP/IP when nginx and sqlpage are running on the same machine. + +### NGINX Service + +The NGINX service uses the official Alpine-based image. It exposes port 80 and mounts the SQLPage socket and the [custom NGINX configuration file](nginx/nginx.conf). + +### MySQL Service + +This service sets up a MySQL database with predefined credentials and a persistent volume for data storage. + +## NGINX Configuration + +The `nginx.conf` file contains the NGINX configuration: + +### Streaming and compression + +SQLPage streams HTML as it is generated, so browsers can start rendering before the database finishes returning rows. NGINX enables `proxy_buffering` by default, which can delay those first bytes but stores responses for slow clients. Start with a modest buffer configuration and let the proxy handle compression: + +``` + proxy_buffering on; + proxy_buffer_size 16k; + proxy_buffers 4 16k; + + gzip on; + gzip_buffers 2 4k; + gzip_types text/html text/plain text/css application/javascript application/json; + + chunked_transfer_encoding on; +``` + +Keep buffering when you expect slow clients or longer SQLPage queries, increasing the buffer sizes only if responses overflow. When most users are on fast connections reading lightweight pages, consider reducing the buffer counts or flipping to `proxy_buffering off;` to minimise latency, accepting the extra load on SQLPage. See the [proxy buffering](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffering), [gzip](https://nginx.org/en/docs/http/ngx_http_gzip_module.html), and [chunked transfer](https://nginx.org/en/docs/http/ngx_http_core_module.html#chunked_transfer_encoding) directives for more guidance. + +When SQLPage runs behind a reverse proxy, set `compress_responses` to `false` in its configuration (documented [here](https://github.com/sqlpage/SQLPage/blob/main/configuration.md)) so that NGINX can perform compression once at the edge. + +### Rate Limiting + + +```nginx + limit_req_zone $binary_remote_addr zone=one:10m rate=1r/s; +``` + + +This line defines a rate limiting zone that allows 1 request per second per IP address. + +### Server Block + + +```nginx + server { + listen 80; + server_name localhost; + + location / { + limit_req zone=one burst=5; + + proxy_pass http://unix:/tmp/sqlpage/sqlpage.sock; + } + } +``` + + +The server block defines how NGINX handles incoming requests. + + +#### URL rewriting: + + +```nginx + rewrite ^/post/([0-9]+)$ /post.sql?id=$1 last; +``` + + +This line rewrites URLs like `/post/123` to `/post.sql?id=123`. + +#### Proxy configuration: + + +```nginx +proxy_pass http://unix:/tmp/sqlpage/sqlpage.sock; +``` + + + These lines configure NGINX to proxy requests to the SQLPage Unix socket. + +#### Caching: + + +```nginx + # Enable caching + proxy_cache_valid 200 60m; + proxy_cache_valid 404 10m; + proxy_cache_use_stale error timeout http_500 http_502 http_503 http_504; +``` + + + These lines enable caching of responses from SQLPage. + +#### Buffering: + + +```nginx + # Enable buffering + proxy_buffering on; + proxy_buffer_size 128k; + proxy_buffers 4 256k; + proxy_busy_buffers_size 256k; +``` + + + These lines configure response buffering for improved performance. + +#### SQLPage Configuration + +The SQLPage configuration is stored in `sqlpage_config/sqlpage.json`: + + +```json +{ + "max_database_pool_connections": 10, + "database_connection_idle_timeout_seconds": 1800, + "max_uploaded_file_size": 10485760, + "compress_responses": false, + "environment": "production" +} +``` + + +This configuration sets various SQLPage options, including the maximum number of database connections and the environment. + +## Application Structure + +The application consists of several SQL files in the `website` directory: + +1. `index.sql`: Displays a list of blog posts +2. `post.sql`: Shows details of a specific post and its comments +3. `add_comment.sql`: Handles adding new comments + +The database schema and initial data are defined in [`sqlpage_config/migrations/000_init.sql`](sqlpage_config/migrations/000_init.sql). \ No newline at end of file diff --git a/examples/nginx/docker-compose.yml b/examples/nginx/docker-compose.yml new file mode 100644 index 0000000..a506a09 --- /dev/null +++ b/examples/nginx/docker-compose.yml @@ -0,0 +1,51 @@ +services: + init-sqlpage-socket: + image: alpine + volumes: + - sqlpage_socket:/tmp/sqlpage + command: ["chown", "1000:1000", "/tmp/sqlpage"] + + sqlpage: + image: lovasoa/sqlpage:main + volumes: + - sqlpage_socket:/tmp/sqlpage + - ./sqlpage_config:/etc/sqlpage + - ./website:/var/www/ + environment: + - DATABASE_URL=mysql://sqlpage:sqlpage_password@mysql:3306/sqlpage_db + - SQLPAGE_UNIX_SOCKET=/tmp/sqlpage/sqlpage.sock + depends_on: + init-sqlpage-socket: + condition: service_completed_successfully + mysql: + condition: service_started + + nginx: + image: nginx:alpine + ports: + - "8080:80" + volumes: + - sqlpage_socket:/tmp/sqlpage:ro + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./website:/var/www:ro + depends_on: + - sqlpage + command: > + sh -c " + adduser -D -u 1000 sqlpage || true && + nginx -g 'daemon off;' + " + + mysql: + image: mysql:8 + environment: + - MYSQL_ROOT_PASSWORD=root_password + - MYSQL_DATABASE=sqlpage_db + - MYSQL_USER=sqlpage + - MYSQL_PASSWORD=sqlpage_password + volumes: + - mysql_data:/var/lib/mysql + +volumes: + mysql_data: + sqlpage_socket: diff --git a/examples/nginx/nginx/nginx.conf b/examples/nginx/nginx/nginx.conf new file mode 100644 index 0000000..7a13cca --- /dev/null +++ b/examples/nginx/nginx/nginx.conf @@ -0,0 +1,127 @@ +# Specify the user under which nginx will run. +# This enhances security by not running as root. +# In our case, we are using the sqlpage user (created in the docker-compose.yml file) +# so that the NGINX worker processes can access the SQLPage socket. +user sqlpage; + +# Set the number of worker processes. 'auto' detects the number of CPU cores. +# Alternative: Specific number like '4' for 4 worker processes. +worker_processes auto; + +# Define the file where error logs will be written. 'notice' sets the logging level. +# Alternative levels: debug, info, warn, error, crit, alert, emerg +error_log /var/log/nginx/error.log notice; + +# Specify the file where the main nginx process ID will be written +pid /var/run/nginx.pid; + +# Configuration for connection processing +events { + # Maximum number of simultaneous connections that can be opened by a worker process + # Can be increased for high traffic sites, but limited by system resources + worker_connections 1024; +} + +# Main HTTP server configuration block +# In a typical configuration, you would have one http block for all your applications +# and each application would be defined in a different file, in the /etc/nginx/sites-available/ directory +# and then enabled by creating a symlink to it in the /etc/nginx/sites-enabled/ directory. +http { + # This individual configuration files would start here, with only the contents + # from inside the http block. + + # Include MIME types definitions file + include /etc/nginx/mime.types; + + # Set the default MIME type if nginx can't determine it + default_type application/octet-stream; + + # Define the format of the access log entries + # This log format includes various details about each request + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + # Specify the file where access logs will be written, using the 'main' format defined above + access_log /var/log/nginx/access.log main; + + # Enable the use of sendfile() for serving static files, which can improve performance + sendfile on; + + # Set the timeout for keep-alive connections with the client + # Can be adjusted based on your application's needs + keepalive_timeout 65; + + # Define a rate limiting zone to protect against DDoS attacks + # $binary_remote_addr uses less memory than $remote_addr + # 10m defines the memory size for storing IP addresses + # 1r/s sets the maximum rate of requests per second from a single IP + limit_req_zone $binary_remote_addr zone=one:10m rate=1r/s; + + # Server block defining a virtual host + server { + # Listen on port 80 for HTTP connections + # If you want to listen on port 443 for HTTPS, you can use the certbot command to get a certificate + # and automatically configure NGINX to use it: + # sudo certbot --nginx -d yourdomain.com + listen 80; + + # Define the server name. 'localhost' is used here, but should be your domain in production + # server_name yourdomain.com; + server_name localhost; + + # Configuration for serving static files + # Note the trailing slash in the location block + # It is necessary because we want to serve files from /var/www/static/ + # and we want to allow users to request http://localhost/static/foo.js + # as well as http://localhost/static/dir/bar.js + location /static/ { + # Set the directory from which static files will be served + # This allows you to place static files in the `website/static/` directory + # and serve them at http://localhost:80/static/... + # This removes load from the SQLPage application that will only handle dynamic requests + alias /var/www/static/; + } + + # Configuration for proxying requests to SQLPage + location / { + # Apply rate limiting to this location + # burst=5 allows temporary bursts of requests + # This is useful to avoid DoS attacks + limit_req zone=one burst=5; + + # URL rewriting example for pretty URLs + # Rewrites /post/123 to /post.sql?id=123 + rewrite ^/post/([0-9]+)$ /post.sql?id=$1 last; + + # Proxy requests to a Unix socket where SQLPage is listening + proxy_pass http://unix:/tmp/sqlpage/sqlpage.sock; + + # Set headers for the proxied request + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Enable caching of proxied content + # Cache successful responses for 60 minutes and 404 responses for 10 minutes + proxy_cache_valid 200 60m; + proxy_cache_valid 404 10m; + + # Use stale cached content when upstream errors occur + proxy_cache_use_stale error timeout http_500 http_502 http_503 http_504; + + # Enable buffering of responses from the proxied server + proxy_buffering on; + + # Set the size of the buffer used for reading the first part of the response + proxy_buffer_size 128k; + + # Set the number and size of buffers used for reading a response + proxy_buffers 4 256k; + + # Limit the amount of data that can be stored in buffers while a response is being processed + proxy_busy_buffers_size 256k; + } + } +} \ No newline at end of file diff --git a/examples/nginx/sqlpage_config/migrations/000_init.sql b/examples/nginx/sqlpage_config/migrations/000_init.sql new file mode 100644 index 0000000..0096896 --- /dev/null +++ b/examples/nginx/sqlpage_config/migrations/000_init.sql @@ -0,0 +1,37 @@ +CREATE TABLE users ( + id INT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(50) UNIQUE NOT NULL, + email VARCHAR(100) UNIQUE NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE posts ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT, + title VARCHAR(255) NOT NULL, + content TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) +); + +CREATE TABLE comments ( + id INT AUTO_INCREMENT PRIMARY KEY, + post_id INT, + user_id INT, + content TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (post_id) REFERENCES posts(id), + FOREIGN KEY (user_id) REFERENCES users(id) +); + +INSERT INTO users (username, email) VALUES +('john_doe', 'john@example.com'), +('jane_smith', 'jane@example.com'); + +INSERT INTO posts (user_id, title, content) VALUES +(1, 'First Post', 'This is the content of the first post.'), +(2, 'Hello World', 'Hello everyone! This is my first post.'); + +INSERT INTO comments (post_id, user_id, content) VALUES +(1, 2, 'Great post!'), +(2, 1, 'Welcome to the community!'); diff --git a/examples/nginx/sqlpage_config/sqlpage.json b/examples/nginx/sqlpage_config/sqlpage.json new file mode 100644 index 0000000..1c56496 --- /dev/null +++ b/examples/nginx/sqlpage_config/sqlpage.json @@ -0,0 +1,7 @@ +{ + "max_database_pool_connections": 10, + "database_connection_idle_timeout_seconds": 1800, + "max_uploaded_file_size": 10485760, + "compress_responses": false, + "environment": "production" +} diff --git a/examples/nginx/test.hurl b/examples/nginx/test.hurl new file mode 100644 index 0000000..861a27a --- /dev/null +++ b/examples/nginx/test.hurl @@ -0,0 +1,23 @@ +# nginx proxies SQLPage dynamic pages and rewrites pretty post URLs. +GET http://localhost:8080/ +HTTP 200 +[Asserts] +header "Content-Type" contains "text/html" +body contains "Blog Posts" +body contains "First Post" +body contains "Hello World" + +GET http://localhost:8080/post/1 +HTTP 200 +[Asserts] +header "Content-Type" contains "text/html" +body contains "Post Details" +body contains "This is the content of the first post." +body contains "Great post!" +body contains "Add a comment" + +# /static/ is served by nginx directly, not proxied to SQLPage. +GET http://localhost:8080/static/index.sql +HTTP 404 +[Asserts] +body contains "nginx" diff --git a/examples/nginx/website/add_comment.sql b/examples/nginx/website/add_comment.sql new file mode 100644 index 0000000..2d7db56 --- /dev/null +++ b/examples/nginx/website/add_comment.sql @@ -0,0 +1,2 @@ +INSERT INTO comments (post_id, user_id, content) VALUES ($id, 1, :content); +SELECT 'redirect' as component, '/post/' || $id AS link; \ No newline at end of file diff --git a/examples/nginx/website/index.sql b/examples/nginx/website/index.sql new file mode 100644 index 0000000..42711a5 --- /dev/null +++ b/examples/nginx/website/index.sql @@ -0,0 +1,10 @@ +SELECT 'list' AS component, 'Blog Posts' AS title; + +SELECT + p.title, + u.username AS description, + 'user' AS icon, + '/post/' || p.id AS link +FROM posts p +JOIN users u ON p.user_id = u.id +ORDER BY p.created_at DESC; \ No newline at end of file diff --git a/examples/nginx/website/post.sql b/examples/nginx/website/post.sql new file mode 100644 index 0000000..97e7dba --- /dev/null +++ b/examples/nginx/website/post.sql @@ -0,0 +1,46 @@ +-- Display the post content using the card component +SELECT 'card' as component, + 'Post Details' as title, + 1 as columns; +SELECT p.title as title, + u.username as subtitle, + p.content as description, + p.created_at as footer +FROM posts p +JOIN users u ON p.user_id = u.id +WHERE p.id = $id; + +-- Add a divider +SELECT 'divider' as component; + +-- Display comments using the list component +SELECT 'list' as component, + 'Comments' as title; +SELECT u.username as title, + c.content as description, + c.created_at as subtitle, + 'user' as icon, + CASE + WHEN c.user_id = p.user_id THEN 'blue' + ELSE 'gray' + END as color +FROM comments c +JOIN users u ON c.user_id = u.id +JOIN posts p ON c.post_id = p.id +WHERE c.post_id = $id +ORDER BY c.created_at DESC; + +-- Add a divider +SELECT 'divider' as component; + +-- Add a comment form +SELECT 'form' as component, + 'Add a comment' as title, + 'Post comment' as validate, + '/add_comment.sql?id=' || $id as action; + +SELECT 'textarea' as type, + 'content' as name, + 'Your comment' as label, + 'Write your comment here' as placeholder, + true as required; diff --git a/examples/official-site/404.sql b/examples/official-site/404.sql new file mode 100644 index 0000000..a788a90 --- /dev/null +++ b/examples/official-site/404.sql @@ -0,0 +1,8 @@ +select 'status_code' as component, 404 as status; +select 'http_header' as component, 'no-store, max-age=0' as "Cache-Control"; +select 'dynamic' as component, properties FROM example WHERE component = 'shell' LIMIT 1; + +select 'hero' as component, + 'Not Found' as title, + 'Sorry, we couldn''t find the page you were looking for.' as description_md, + '/your-first-sql-website/not_found.jpg' as image; diff --git a/examples/official-site/Dockerfile b/examples/official-site/Dockerfile new file mode 100644 index 0000000..e7d8eb0 --- /dev/null +++ b/examples/official-site/Dockerfile @@ -0,0 +1,4 @@ +FROM lovasoa/sqlpage:main + +COPY ./sqlpage /etc/sqlpage +COPY . /var/www \ No newline at end of file diff --git a/examples/official-site/README.md b/examples/official-site/README.md new file mode 100644 index 0000000..5125588 --- /dev/null +++ b/examples/official-site/README.md @@ -0,0 +1,12 @@ +# Official SQLPage site + +The SQLPage website is of course built with SQLPage ! + +Things that you may be interested to look at: + - The [custom component we use for our stylish home page](./sqlpage/templates/shell-home.handlebars) + - The [migrations](./sqlpage/migrations) that populate the database with the documentation for all components + - The [advanced multistep form example](./examples/multistep-form/) + +It is hosted as a simple Docker container on [sql-page.com](https://sql-page.com). + +Feel free to [open a pull request](https://github.com/lovasoa/sqlpage/pulls) if you would like to add or change anything ! \ No newline at end of file diff --git a/examples/official-site/assets/db-bigquery.svg b/examples/official-site/assets/db-bigquery.svg new file mode 100644 index 0000000..21fbe25 --- /dev/null +++ b/examples/official-site/assets/db-bigquery.svg @@ -0,0 +1 @@ +Google BigQuery diff --git a/examples/official-site/assets/db-clickhouse.svg b/examples/official-site/assets/db-clickhouse.svg new file mode 100644 index 0000000..82d7d98 --- /dev/null +++ b/examples/official-site/assets/db-clickhouse.svg @@ -0,0 +1 @@ +ClickHouse diff --git a/examples/official-site/assets/db-databricks.svg b/examples/official-site/assets/db-databricks.svg new file mode 100644 index 0000000..129ea2a --- /dev/null +++ b/examples/official-site/assets/db-databricks.svg @@ -0,0 +1 @@ +Databricks diff --git a/examples/official-site/assets/db-db2.svg b/examples/official-site/assets/db-db2.svg new file mode 100644 index 0000000..0281139 --- /dev/null +++ b/examples/official-site/assets/db-db2.svg @@ -0,0 +1 @@ +IBM \ No newline at end of file diff --git a/examples/official-site/assets/db-duckdb.svg b/examples/official-site/assets/db-duckdb.svg new file mode 100644 index 0000000..7e59087 --- /dev/null +++ b/examples/official-site/assets/db-duckdb.svg @@ -0,0 +1 @@ +DuckDB diff --git a/examples/official-site/assets/db-mysql.svg b/examples/official-site/assets/db-mysql.svg new file mode 100644 index 0000000..e1606ff --- /dev/null +++ b/examples/official-site/assets/db-mysql.svg @@ -0,0 +1 @@ +MySQL diff --git a/examples/official-site/assets/db-odbc.svg b/examples/official-site/assets/db-odbc.svg new file mode 100644 index 0000000..b364b5a --- /dev/null +++ b/examples/official-site/assets/db-odbc.svg @@ -0,0 +1 @@ +ODBCODBC diff --git a/examples/official-site/assets/db-oracle.svg b/examples/official-site/assets/db-oracle.svg new file mode 100644 index 0000000..1e41072 --- /dev/null +++ b/examples/official-site/assets/db-oracle.svg @@ -0,0 +1 @@ +Oracle \ No newline at end of file diff --git a/examples/official-site/assets/db-postgres.svg b/examples/official-site/assets/db-postgres.svg new file mode 100644 index 0000000..d7ccd9e --- /dev/null +++ b/examples/official-site/assets/db-postgres.svg @@ -0,0 +1 @@ +PostgreSQL diff --git a/examples/official-site/assets/db-snowflake.svg b/examples/official-site/assets/db-snowflake.svg new file mode 100644 index 0000000..b62af54 --- /dev/null +++ b/examples/official-site/assets/db-snowflake.svg @@ -0,0 +1 @@ +Snowflake diff --git a/examples/official-site/assets/db-sqlite.svg b/examples/official-site/assets/db-sqlite.svg new file mode 100644 index 0000000..e6e7790 --- /dev/null +++ b/examples/official-site/assets/db-sqlite.svg @@ -0,0 +1 @@ +SQLite diff --git a/examples/official-site/assets/db-sqlserver.svg b/examples/official-site/assets/db-sqlserver.svg new file mode 100644 index 0000000..ecb4c22 --- /dev/null +++ b/examples/official-site/assets/db-sqlserver.svg @@ -0,0 +1 @@ +Microsoft SQL Server \ No newline at end of file diff --git a/examples/official-site/assets/highlightjs-and-tabler-theme.css b/examples/official-site/assets/highlightjs-and-tabler-theme.css new file mode 100644 index 0000000..e7d72eb --- /dev/null +++ b/examples/official-site/assets/highlightjs-and-tabler-theme.css @@ -0,0 +1,247 @@ +@charset "utf-8"; + +:root, +.layout-boxed[data-bs-theme="dark"] { + color-scheme: dark; + + /* Core colors refined for better contrast */ + --tblr-body-color: hsl(225deg 35% 86%); + --tblr-secondary-color: hsl(225, 15%, 80%); + --tblr-muted-color: hsla(225, 15%, 75%, 0.8); + + /* Background system */ + --tblr-body-bg: hsl(225deg 44% 9%); + --tblr-bg-surface: hsl(225, 47%, 10%); + --tblr-bg-surface-secondary: hsl(225, 47%, 12%); + --tblr-bg-surface-tertiary: hsl(225, 47%, 14%); + + /* Border colors */ + --tblr-border-color: hsl(225deg, 26%, 19%); + --tblr-border-color-translucent: hsla(225deg 27% 19% / 0.7); + + /* Text secondary RGB */ + --tblr-text-secondary-rgb: + 204, 209, 217; /* RGB equivalent of hsl(225, 15%, 80%) */ + + /* Code colors */ + --tblr-code-color: hsl( + 225deg 45.4% 76.93% + ); /* Light code text for dark theme */ + --tblr-code-bg: hsla(225, 47%, 15%, 0.5); /* Subtle dark background */ + --tblr-active-bg: var(--tblr-code-bg); + + /* Ethereal accent colors */ + --tblr-blue-rgb: 84, 151, 213; + --tblr-blue: rgb(var(--tblr-blue-rgb)); + --tblr-blue-lt-rgb: 21, 31, 53; + --tblr-blue-lt: rgb(var(--tblr-blue-lt-rgb)); + --tblr-primary-rgb: 95, 132, 169; + --tblr-primary: rgb(var(--tblr-primary-rgb)); + --tblr-secondary: hsla(247, 60%, 94%, 0.7); /* Nebula purple */ + + /* Status + accent colors aligned with the brand palette */ + --tblr-success-rgb: 16, 132, 86; /* Deep green for white text */ + --tblr-success: rgb(var(--tblr-success-rgb)); + --tblr-warning-rgb: 197, 124, 0; /* Deep amber */ + --tblr-warning: rgb(var(--tblr-warning-rgb)); + --tblr-danger-rgb: 196, 68, 68; /* Deep crimson */ + --tblr-danger: rgb(var(--tblr-danger-rgb)); + --tblr-purple-rgb: 118, 82, 200; /* Deep violet */ + --tblr-purple: rgb(var(--tblr-purple-rgb)); + --tblr-cyan-rgb: 0, 149, 168; /* Deep cyan */ + --tblr-cyan: rgb(var(--tblr-cyan-rgb)); + --tblr-indigo-rgb: 96, 113, 215; /* Deep indigo */ + --tblr-indigo: rgb(var(--tblr-indigo-rgb)); + --tblr-teal: #267d63; + --tblr-teal-fg: #20050b; + + --tblr-green: var(--tblr-success); + --tblr-yellow: var(--tblr-warning); + --tblr-red: var(--tblr-danger); + + /* Luminous links */ + --tblr-link-color: hsl(212, 70%, 75%) !important; /* Star glow */ + --tblr-link-hover-color: hsl(212, 70%, 85%) !important; /* Supernova */ + --tblr-carousel-caption-color: var(--tblr-muted-color); + + /* Ethereal shadows */ + --tblr-box-shadow: + 0 2px 4px rgba(0, 0, 0, 0.2), 0 0 15px rgba(66, 153, 225, 0.15); + --tblr-box-shadow-dropdown: + 0px 8px 24px rgba(0, 0, 0, 0.25), 1px 9px 20px rgba(174, 62, 201, 0.15); + + /* Pure white emphasis */ + --tblr-emphasis-color: #ffffff; + --tblr-heading-color: #ffffff; + + /* Syntax highlighting colors */ + --hljs-cosmic-comment: hsl(290, 10%, 60%); /* Distant star */ + --hljs-cosmic-punctuation: hsl(60, 40%, 85%); /* Stardust */ + --hljs-cosmic-property: hsl(340, 60%, 65%); /* Red giant */ + --hljs-cosmic-number: hsl(0, 50%, 75%); /* Solar flare */ + --hljs-cosmic-boolean: hsl(270, 100%, 75%); /* Purple nebula */ + --hljs-cosmic-string: hsl(120, 40%, 75%); /* Green aurora */ + --hljs-cosmic-operator: hsl(0, 0%, 95%); /* White dwarf */ + --hljs-cosmic-keyword: hsl(210, 100%, 75%); /* Blue giant */ +} + +.btn-primary { + --tblr-btn-border-color: var(--tblr-secondary); + --tblr-btn-active-border-color: transparent; + --tblr-btn-color: var(--tblr-primary-fg, #ffffff); + --tblr-btn-bg: transparent; + --tblr-btn-hover-color: var(--tblr-primary-fg); + --tblr-btn-hover-bg: transparent; + --tblr-btn-active-color: var(--tblr-primary-fg); + --tblr-btn-active-bg: transparent; + --tblr-btn-disabled-bg: var(--tblr-primary); + --tblr-btn-disabled-color: var(--tblr-primary-fg); + --tblr-btn-box-shadow: 0.1em 0.1em 0.1em var(--tblr-primary); + --tblr-btn-hover-border-color: var(--tblr-primary); + --tblr-btn-active-shadow: -0.1em -0.1em 0.1em var(--tblr-primary); +} + +.dropdown-menu { + --tblr-dropdown-link-active-color: rgb(166, 211, 255); +} + +@media (min-width: 768px) { + .layout-boxed { + background: #07020ff5 + linear-gradient(70deg, rgba(23, 17, 39, 0.4), transparent) fixed; + } +} + +/* Comments, Prolog, Doctype, and Cdata */ +.hljs-comment, +.hljs-prolog, +.hljs-meta, +.hljs-cdata { + color: var(--tblr-secondary-color); +} + +/* Punctuation */ +.hljs-template-variable, +.hljs-punctuation { + color: var(--hljs-cosmic-punctuation); +} + +/* Property and Tag */ +.hljs-property { + color: var(--hljs-cosmic-property); +} + +/* Number */ +.hljs-number { + color: var(--hljs-cosmic-number); +} + +/* Boolean */ +.hljs-literal { + color: var(--hljs-cosmic-boolean); +} + +/* String */ +.hljs-selector-tag, +.hljs-string { + color: var(--hljs-cosmic-string); +} + +/* Operator */ +.hljs-operator, +.hljs-symbol, +.hljs-link, +.language-css .hljs-string, +.style .hljs-string { + color: var(--hljs-cosmic-operator); +} + +/* Keyword */ +.hljs-template-tag, +.hljs-keyword { + color: var(--hljs-cosmic-keyword); +} + +/* Namespace */ +.hljs-namespace { + opacity: 0.7; +} + +/* Selector, Attr-name, and String */ +.hljs-attr { + color: #fcfce5; +} + +.hljs-name { + color: #e4faf6; +} + +/* Operator, Entity, URL, CSS String, and Style String */ +.hljs-operator, +.hljs-symbol, +.hljs-link, +.language-css .hljs-string, +.style .hljs-string { + color: #f8f8f2; +} + +/* At-rule and Attr-value */ +.hljs-tag, +.hljs-keyword, +.hljs-attribute-value { + color: #e6db74; +} + +/* Regex and Important */ +.hljs-regexp, +.hljs-important { + color: var(--tblr-yellow); +} + +/* Important */ +.hljs-important { + font-weight: bold; +} + +/* Entity */ +.hljs-symbol { + cursor: help; +} + +/* Token transition */ +.hljs { + transition: 0.3s; +} + +/* Code selection */ +code::selection, +code ::selection { + background: var(--tblr-yellow); + color: var(--tblr-gray-900); + border-radius: 0.1em; +} + +code .hljs-keyword::selection, +code .hljs-punctuation::selection { + color: var(--tblr-gray-800); +} + +/* Pre code padding */ +pre code { + padding: 0; +} + +/* Limit height and add inset shadow to code blocks */ +pre:has(code) { + max-height: 33vh; /* Limit height */ + overflow: auto; + box-shadow: inset 0 -1px 20px hsla(207.7, 39.4%, 6.5%, 0.64); + border-radius: 0.5rem; +} + +@media print { + pre:has(code) { + max-height: none !important; + box-shadow: none !important; + } +} diff --git a/examples/official-site/assets/highlightjs-launch.js b/examples/official-site/assets/highlightjs-launch.js new file mode 100644 index 0000000..66ce477 --- /dev/null +++ b/examples/official-site/assets/highlightjs-launch.js @@ -0,0 +1 @@ +hljs.highlightAll(); diff --git a/examples/official-site/assets/icon.webp b/examples/official-site/assets/icon.webp new file mode 100644 index 0000000..b70cc05 Binary files /dev/null and b/examples/official-site/assets/icon.webp differ diff --git a/examples/official-site/assets/screenshots/big_tables.webm b/examples/official-site/assets/screenshots/big_tables.webm new file mode 100644 index 0000000..5a0a5f3 Binary files /dev/null and b/examples/official-site/assets/screenshots/big_tables.webm differ diff --git a/examples/official-site/assets/screenshots/plot.svg b/examples/official-site/assets/screenshots/plot.svg new file mode 100644 index 0000000..9c7ed22 --- /dev/null +++ b/examples/official-site/assets/screenshots/plot.svg @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 120 + 100 + 80 + 60 + 40 + 20 + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Jan 25 + Apr 25 + Jul 25 + Oct 25 + 2026 + Apr 26 + + + select 'chart' as component;select date as x, revenue as yfrom sales;-- The end + diff --git a/examples/official-site/assets/screenshots/user-creation-form.png b/examples/official-site/assets/screenshots/user-creation-form.png new file mode 100644 index 0000000..93a3012 Binary files /dev/null and b/examples/official-site/assets/screenshots/user-creation-form.png differ diff --git a/examples/official-site/blog.sql b/examples/official-site/blog.sql new file mode 100644 index 0000000..4ed924e --- /dev/null +++ b/examples/official-site/blog.sql @@ -0,0 +1,25 @@ +select 'redirect' as component, '/blog.sql' as link +where ($post IS NULL AND sqlpage.path() <> '/blog.sql') OR ($post IS NOT NULL AND NOT EXISTS (SELECT 1 FROM blog_posts WHERE title = $post)); + +select 'dynamic' as component, json_patch(json_extract(properties, '$[0]'), json_object( + 'title', coalesce($post || ' - ', '') || 'SQLPage Blog' +)) as properties +FROM example WHERE component = 'shell' LIMIT 1; + +SELECT 'text' AS component, + true as article, + content AS contents_md +FROM blog_posts +WHERE title = $post; + +SELECT 'list' AS component, + 'SQLPage blog' AS title; +SELECT title, + description, + icon, + sqlpage.link( + COALESCE(external_url, ''), + CASE WHEN external_url IS NULL THEN json_object('post', title) ELSE NULL END + ) AS link +FROM blog_posts +ORDER BY created_at DESC; \ No newline at end of file diff --git a/examples/official-site/blog/pagination.png b/examples/official-site/blog/pagination.png new file mode 100644 index 0000000..4c4137e Binary files /dev/null and b/examples/official-site/blog/pagination.png differ diff --git a/examples/official-site/blog/three-layers.svg b/examples/official-site/blog/three-layers.svg new file mode 100644 index 0000000..14a766a --- /dev/null +++ b/examples/official-site/blog/three-layers.svg @@ -0,0 +1,17 @@ + + + + + + + + FrontendTypeScriptinterfacesBackendPython/Ruby/JavaclassesDatabaseSQLDB SchemaUI logicBusiness logicDuplicationORMAPIfetch, io-ts, apollo, hooks, ... \ No newline at end of file diff --git a/examples/official-site/colors.sql b/examples/official-site/colors.sql new file mode 100644 index 0000000..480ac89 --- /dev/null +++ b/examples/official-site/colors.sql @@ -0,0 +1,92 @@ +set theme = coalesce($theme, 'custom'); + +select 'dynamic' as component, json_patch(json_extract(properties, '$[0]'), json_object( + 'title', $theme || ' SQLPage Colors', + 'css', case $theme when 'custom' then '/assets/highlightjs-and-tabler-theme.css' end, + 'theme', case $theme when 'default' then 'light' else 'dark' end +)) as properties +FROM example WHERE component = 'shell' LIMIT 1; + +create temporary table if not exists colors as select column1 as color, column2 as hex from (values + ('blue', '#0054a6'), ('azure', '#4299e1'), ('indigo', '#4263eb'), ('purple', '#ae3ec9'), ('pink', '#d6336c'), ('red', '#d63939'), ('orange', '#f76707'), ('yellow', '#f59f00'), ('lime', '#74b816'), ('green', '#2fb344'), ('teal', '#0ca678'), ('cyan', '#17a2b8'), + ('blue-lt', '#e9f0f9'), ('azure-lt', '#ecf5fc'), ('indigo-lt', '#eceffd'), ('purple-lt', '#f7ecfa'), ('pink-lt', '#fbebf0'), ('red-lt', '#fbebeb'), ('orange-lt', '#fef0e6'), ('yellow-lt', '#fef5e6'), ('lime-lt', '#f1f8e8'), ('green-lt', '#eaf7ec'), ('teal-lt', '#e7f6f2'), ('cyan-lt', '#e8f6f8'), + ('gray-50', '#f8fafc'), ('gray-100', '#f1f5f9'), ('gray-200', '#e2e8f0'), ('gray-300', '#c8d3e1'), ('gray-400', '#9ba9be'), ('gray-500', '#6c7a91'), ('gray-600', '#49566c'), ('gray-700', '#313c52'), ('gray-800', '#1d273b'), ('gray-900', '#0f172a'), + ('facebook', '#1877F2'), ('twitter', '#1da1f2'), ('linkedin', '#0a66c2'), ('google', '#dc4e41'), ('youtube', '#ff0000'), ('vimeo', '#1ab7ea'), ('dribbble', '#ea4c89'), ('github', '#181717'), ('instagram', '#e4405f'), ('pinterest', '#bd081c'), ('vk', '#6383a8'), ('rss', '#ffa500'), ('flickr', '#0063dc'), ('bitbucket', '#0052cc'), ('tabler', '#0054a6'), + ('black', '#000000'), ('white', '#ffffff'), ('gray', '#808080'), + ('primary', '#0054a6'), ('secondary', '#49566c'), ('success', '#2fb344'), ('info', '#17a2b8'), ('warning', '#f59f00'), ('danger', '#d63939'), ('light', '#f1f5f9'), ('dark', '#0f172a') +); + +select 'tab' as component; +select 'Default theme' as title, '?theme=default' as link, 'Default theme' as description, case $theme when 'default' then 'primary' end as color, $theme = 'default' as disabled; +select 'Custom theme' as title, '?theme=custom' as link, 'Custom theme' as description, case $theme when 'custom' then 'primary' end as color, $theme = 'custom' as disabled; + + +select 'card' as component, 'Colors' as title; +select color as title, hex as description, color as background_color +from colors; + + +select 'text' as component, ' +The colors above are from the [official site custom theme](https://github.com/sqlpage/SQLPage/blob/main/examples/official-site/assets/highlightjs-and-tabler-theme.css). +View [this page with the default theme](?theme=default) to see the colors that are used by default. +' as contents_md where $theme = 'custom'; + +select 'text' as component, ' +### Customization and theming + +SQLPage is designed to be easily customizable and themable. +You cannot pass arbitrary color codes to components from your SQL queries, +but you can customize which exact color is associated to each color name. + +#### Creating a custom theme + +To create a custom theme, you can create a CSS file and use the [shell component](/component.sql?component=shell) to include it. + +##### `index.sql` + +```sql +select ''shell'' as component, ''custom_theme.css'' as css, ''custom_theme'' as theme; +``` + +##### `custom_theme.css` + +```css +:root, +.layout-boxed[data-bs-theme="custom_theme"] { + color-scheme: light; + + /* Base text colors */ + --tblr-body-color: #cfd5e6; + --tblr-text-secondary-rgb: 204, 209, 217; + --tblr-secondary-color: #cccccc; + --tblr-muted-color: rgba(191, 191, 191, 0.8); + + /* Background colors */ + --tblr-body-bg: #0f1426; + --tblr-bg-surface: #111629; + --tblr-bg-surface-secondary: #151a2e; + --tblr-bg-surface-tertiary: #191f33; + + /* Primary and secondary colors */ + --tblr-primary-rgb: 95, 132, 169; + --tblr-primary: rgb(var(--tblr-primary-rgb)); + --tblr-secondary-rgb: 235, 232, 255; + --tblr-secondary: rgb(var(--tblr-secondary-rgb)); + + /* Border colors */ + --tblr-border-color: #151926; + --tblr-border-color-translucent: #404d73b3; + + /* Theme colors. All sqlpage colors can be customized in the same way. */ + --tblr-blue-rgb: 84, 151, 213; /* To convert between #RRGGBB color codes to decimal RGB values, you can use https://www.rapidtables.com/web/color/RGB_Color.html */ + --tblr-blue: rgb(var(--tblr-blue-rgb)); + + --tblr-red-rgb: 229, 62, 62; + --tblr-red: rgb(var(--tblr-red-rgb)); + + --tblr-green-rgb: 72, 187, 120; + --tblr-green: rgb(var(--tblr-green-rgb)); +} +``` +' as contents_md; + diff --git a/examples/official-site/component.sql b/examples/official-site/component.sql new file mode 100644 index 0000000..c10943f --- /dev/null +++ b/examples/official-site/component.sql @@ -0,0 +1,154 @@ +-- ensure that the component exists and do not render this page if it does not +select 'redirect' as component, + 'component_not_found.sql' || coalesce('?component=' || sqlpage.url_encode($component), '') as link +where not exists (select 1 from component where name = $component); + +-- This line, at the top of the page, tells web browsers to keep the page locally in cache once they have it. +select 'http_header' as component, + 'public, max-age=600, stale-while-revalidate=3600, stale-if-error=86400' as "Cache-Control", + printf('<%s>; rel="canonical"', sqlpage.link('component', json_object('component', $component))) as "Link"; + +select 'dynamic' as component, json_patch(json_extract(properties, '$[0]'), json_object( + 'title', coalesce($component || ' - ', '') || 'SQLPage Documentation' +)) as properties +FROM example WHERE component = 'shell' LIMIT 1; + +select 'breadcrumb' as component; +select 'SQLPage' as title, '/' as link, 'Home page' as description; +select 'Components' as title, '/documentation.sql' as link, 'List of all components' as description; +select $component as title, '/component.sql?component=' || sqlpage.url_encode($component) as link; + +select 'text' as component, 'component' as id, true as article, + format('# The **%s** component + +%s', $component, description) as contents_md +from component where name = $component; + +select format('Introduced in SQLPage v%s.', introduced_in_version) as contents, 1 as size +from component +where name = $component and introduced_in_version IS NOT NULL; + +select 'title' as component, 3 as level, 'Top-level parameters' as contents where $component IS NOT NULL; +select 'table' as component, true as striped, true as hoverable, true as freeze_columns, + 'type' as markdown + where $component IS NOT NULL; +select + name, + CASE WHEN optional THEN '' ELSE 'REQUIRED' END as required, + CASE type + WHEN 'COLOR' THEN printf('[%s](/colors.sql)', type) + WHEN 'ICON' THEN printf('[%s](https://tabler-icons.io/?ref=sqlpage)', type) + ELSE type + END AS type, + description +from parameter where component = $component AND top_level +ORDER BY optional, name; + + +select 'title' as component, 3 as level, 'Row-level parameters' as contents +WHERE $component IS NOT NULL AND EXISTS (SELECT 1 from parameter where component = $component AND NOT top_level); +select 'table' as component, true as striped, true as hoverable, true as freeze_columns, + 'type' as markdown + where $component IS NOT NULL; +select + name, + CASE WHEN optional THEN '' ELSE 'REQUIRED' END as required, + CASE type + WHEN 'COLOR' THEN printf('[%s](/colors.sql)', type) + WHEN 'ICON' THEN printf('[%s](https://tabler-icons.io/?ref=sqlpage)', type) + ELSE type + END AS type, + description +from parameter where component = $component AND NOT top_level +ORDER BY optional, name; + +select + 'dynamic' as component, + '[ + {"component": "code"}, + { + "title": "Example ' || (row_number() OVER ()) || '", + "description_md": ' || json_quote(description) || ', + "language": "sql", + "contents": ' || json_quote(( + select + group_concat( + 'select ' || char(10) || + ( + with t as ( + select *, + case type + when 'array' then json_array_length(value)>1 + else false + end as is_arr + from json_tree(top.value) + ), + key_val as (select + CASE t.type + WHEN 'integer' THEN t.atom + WHEN 'real' THEN t.atom + WHEN 'true' THEN 'TRUE' + WHEN 'false' THEN 'FALSE' + WHEN 'null' THEN 'NULL' + WHEN 'object' THEN 'JSON(' || quote(t.value) || ')' + WHEN 'array' THEN 'JSON(' || quote(t.value) || ')' + ELSE quote(t.value) + END as val, + CASE parent.fullkey + WHEN '$' THEN t.key + ELSE parent.key + END as key + from t inner join t parent on parent.id = t.parent + where ((parent.fullkey = '$' and not t.is_arr) + or (parent.path = '$' and parent.is_arr)) + ), + key_val_padding as (select + CASE + WHEN (key LIKE '% %') or (key LIKE '%-%') THEN + format('"%w"', key) + ELSE + key + END as key, + val, + 1 + max(0, max(case when length(val) < 30 then length(val) else 0 end) over () - length(val)) as padding + from key_val + ) + select group_concat( + format(' %s%.*cas %s', val, padding, ' ', key), + ',' || char(10) + ) from key_val_padding + ) || ';', + char(10) + ) + from json_each(properties) AS top + )) || ' + }, '|| + CASE component + WHEN 'shell' THEN '{"component": "text", "contents": ""}' + WHEN 'http_header' THEN '{ "component": "text", "contents": "" }' + ELSE ' + {"component": "title", "level": 3, "contents": "Result"}, + {"component": "dynamic", "properties": ' || properties ||' } + ' + END || ' + ] + ' as properties +from example where component = $component AND properties IS NOT NULL; + +SELECT 'title' AS component, 3 AS level, 'Examples' AS contents +WHERE EXISTS (SELECT 1 FROM example WHERE component = $component AND properties IS NULL); +SELECT 'text' AS component, description AS contents_md +FROM example WHERE component = $component AND properties IS NULL; + + +select 'title' as component, 2 as level, 'See also: other components' as contents; +select + 'button' as component, + 'sm' as size, + 'pill' as shape; +select + name as title, + icon, + sqlpage.set_variable('component', name) as link +from component +order by name; \ No newline at end of file diff --git a/examples/official-site/component_not_found.sql b/examples/official-site/component_not_found.sql new file mode 100644 index 0000000..e4b9656 --- /dev/null +++ b/examples/official-site/component_not_found.sql @@ -0,0 +1,34 @@ +select 'http_header' as component, 'noindex' as "X-Robots-Tag"; + +select 'dynamic' as component, properties FROM example WHERE component = 'shell' LIMIT 1; + +select + 'hero' as component, + 'Not found' as title, + 'Sorry, the component you were looking for does not exist.' as description_md, + 'https://upload.wikimedia.org/wikipedia/commons/thumb/2/2b/Sad_clown.jpg/640px-Sad_clown.jpg' as image, + '/documentation.sql' as link, + 'Back to the documentation' as link_text; + +-- Friendly message after an XSS or SQL injection attempt +set attack = CASE WHEN + $component LIKE '%<%' or $component LIKE '%>%' or $component LIKE '%/%' or $component LIKE '%;%' + or $component LIKE '%--%' or $component LIKE '%''%' or $component LIKE '%(%' +THEN 'attacked' END; + +select + 'alert' as component, + 'A note about security' as title, + 'alert-triangle' as icon, + 'teal' as color, + TRUE as important, + 'SQLPage takes secutity very seriously. +Fiddling with the URL to try to access data you are not supposed to see, or to +trigger a SQL or javacript injection, should never work. + +However, if you think you have found a security issue, please +report it and we will fix it as soon as possible. +' as description +where $attack = 'attacked'; +select 'safety.sql' as link, 'More about SQLPage security' as title where $attack='attacked'; +select 'https://github.com/sqlpage/SQLPage/security' as link, 'Report a vulnerability' as title where $attack='attacked'; \ No newline at end of file diff --git a/examples/official-site/components.sql b/examples/official-site/components.sql new file mode 100644 index 0000000..dc9bbcd --- /dev/null +++ b/examples/official-site/components.sql @@ -0,0 +1 @@ +SELECT 'redirect' as component, 'documentation.sql' as link; diff --git a/examples/official-site/custom_components.sql b/examples/official-site/custom_components.sql new file mode 100644 index 0000000..ff2902a --- /dev/null +++ b/examples/official-site/custom_components.sql @@ -0,0 +1,186 @@ +select 'dynamic' as component, properties FROM example WHERE component = 'shell' LIMIT 1; + +select 'text' as component, ' + +# Creating your own SQLPage components + + +If you have some frontend development experience, you can create your own components, by placing +[`.handlebars`](https://handlebarsjs.com/guide/) files in a folder called `sqlpage/templates` at the root of your server. + +## Web page structure + +### The [`shell`](./documentation.sql?component=shell#component) component + +Each page in SQLPage is composed of a `shell` component, +which contains the page title and the navigation bar, +and a series of normal components that display the data. + +The `shell` component is always present unless explicitly skipped via the `?_sqlpage_embed` query parameter. +If you don''t call it explicitly, it will be invoked with the default parameters automatically before your first component +invocation that tries to render data on the page. + +#### Custom `shell` components + +You can override the default `shell` component by creating a file called `shell.handlebars` in the `sqlpage/templates` folder. + +If you want to keep the default `shell` component on most of your pages, but want to create a custom `shell` component for a specific page, +you can create a file called `shell-custom.handlebars` (or any other name starting with `shell`) in the `sqlpage/templates` folder. +Here is an example for a minimal custom `shell` component: + + +```handlebars + + + + {{default title "SQLPage"}} + + + +{{~#each_row~}}{{~/each_row~}} + + +``` + +Since you have full control over the `shell` component, you can use it to generate non-HTML content. +For instance, you can write an XML shell to create a custom RSS feed. + +## Component template syntax + +Components are written in [handlebars](https://handlebarsjs.com/guide/), +which is a simple templating language that allows you to insert data in your HTML. + +Here is a simple example of a component that displays a list of items: + +```handlebars +

{{title}}

+ +
    +{{#each_row}} +
  • {{my_property}} {{other_property}}
  • +{{/each_row}} +
+``` + +If you save this file as `sqlpage/templates/my_list.handlebars`, you can use it in your SQL queries +by calling the `my_list` component: + +```sql +SELECT ''my_list'' AS component, ''My list'' AS title; +SELECT first_name AS my_property, last_name AS other_property FROM clients; +``` + +### Styling + +SQLPage uses [tabler](https://tabler.io/) for its default styling. +You can include any of the tabler classes in your components to style them. +Since tabler inherits from [bootstrap](https://getbootstrap.com/), you can also use bootstrap classes. + +For instance, you can easily create a multi-column layout with the following code: + +```handlebars +
+{{#each_row}} +
+ {{my_property}} +
+{{/each_row}} +
+``` + +For custom styling, you can write your own CSS files +and include them in your page header. +You can use the `css` parameter of the default [`shell`](./documentation.sql?component=shell#component) component, +or create your own custom `shell` component with a `` tag. + +### Helpers + +Handlebars has a concept of [helpers](https://handlebarsjs.com/guide/expressions.html#helpers), +which are functions that you can call from your templates to perform some operations. + +Handlebars comes with [a few built-in helpers](https://handlebarsjs.com/guide/builtin-helpers.html), +and SQLPage adds a few more: + +- `eq`, `ne`: compares two values for equality (equal, not equal) +- `gt`, `gte`, `lt`, `lte`: compares two values (greater than, greater than or equal, less than, less than or equal) +- `or`, `and`: combines two boolean values (logical operators) +- `not`: negates a boolean value (logical operator) +- `len`: returns the length of a list or string, or the number of keys in an object +- `stringify`: converts a value to its json string representation, useful to pass parameters from the database to javascript functions +- `parse_json`: parses a json string into a value, useful to accept complex parameters from databases that don''t have a native json type +- `default`: returns the first argument if it is not null, otherwise returns the second argument. For instance: `{{default my_value ''default value''}}`. +- `entries`: returns the entries of an object as a list of `{key, value}` objects. +- `delay` and `flush_delayed`: temporarily saves a value to memory, and outputs it later. For instance: + - ```handlebars + {{#if complex_condition}} + + {{#delay}} + + {{/delay}} + {{/if}} + ... + {{flush_delayed}} + ``` +- `sort`: sorts a list of values +- `plus`, `minus`, `sum`: mathematical operators +- `starts_with`: returns true if a string starts with another string +- `to_array`: useful to accept parameters that can optionally be repeated: + - if the argument is a list, returns it unchanged, + - if the argument is a string containing a valid json list, returns the parsed list, + - otherwise returns a list containing only the argument +- `array_contains`: returns true if a list contains a value +- `static_path`: returns the path to one of the static files bundled with SQLPage. Accepts arguments like `sqlpage.js`, `sqlpage.css`, `apexcharts.js`, etc. +- `app_config`: returns the value of a configuration parameter from sqlpage''s configuration file, such as `max_uploaded_file_size`, `site_prefix`, etc. +- `icon_img`: generate an svg icon from a *tabler* icon name +- `markdown`: renders markdown text. Accepts an optional 2nd argument `''allow_unsafe''` that will render embedded html blocks: use only on trusted content. See the [Commonmark spec](https://spec.commonmark.org/0.31.2/#html-blocks) for more info. +- `each_row`: iterates over the rows of a query result +- `typeof`: returns the type of a value (`string`, `number`, `boolean`, `object`, `array`, `null`) +- `rfc2822_date`: formats a date as a string in the [RFC 2822](https://tools.ietf.org/html/rfc2822#section-3.3) format, that is, `Thu, 21 Dec 2000 16:01:07 +0200` +- `url_encode`: percent-encodes a string for use in a URL. For instance, `{{url_encode "hello world"}}` returns `hello%20world`. + +### Attributes + +In addition to the parameters you pass to your components in your SQL queries, +SQLPage adds the following attributes to the context of your components: + + - `@component_index` : the index of the current component in the page. Useful to generate unique ids or classes. + - `@row_index` : the index of the current row in the current component. Useful to implement special behavior on the first row, for instance. + - `@csp_nonce` : a random nonce that you must use as the `nonce` attribute of your ` +``` + +## Overwriting the default components + +You can overwrite the default components, including the `shell` component, + by creating a file with the same name in the `sqlpage/templates` folder. + +For example, if you want to change the appearance of the `shell` component, +you can create a file called `sqlpage/templates/shell.handlebars` and write your own HTML in it. +If you don''t want to start from scratch, you can copy the default `shell` component +[from the SQLPage source code](https://github.com/sqlpage/SQLPage/blob/main/sqlpage/templates/shell.handlebars). + +## Examples + +All the default components are written in handlebars, and you can read their source code to learn how to write your own. +[See the default components source code](https://github.com/sqlpage/SQLPage/blob/main/sqlpage/templates). + +Some interesting examples are: + + - [The `shell` component](https://github.com/sqlpage/SQLPage/blob/main/sqlpage/templates/shell.handlebars) + - [The `card` component](https://github.com/sqlpage/SQLPage/blob/main/sqlpage/templates/card.handlebars): simple yet complete example of a component that displays a list of items. + - [The `table` component](https://github.com/sqlpage/SQLPage/blob/main/sqlpage/templates/table.handlebars): more complex example of a component that uses + - the `eq`, `or`, and `sort` handlebars helpers, + - the `../` syntax to access the parent context, + - and the `@key` to work with objects whose keys are not known in advance. + +' as contents_md; diff --git a/examples/official-site/docker-compose.yml b/examples/official-site/docker-compose.yml new file mode 100644 index 0000000..8e46ffc --- /dev/null +++ b/examples/official-site/docker-compose.yml @@ -0,0 +1,5 @@ +services: + web: + build: . + ports: + - "8080:8080" diff --git a/examples/official-site/documentation.sql b/examples/official-site/documentation.sql new file mode 100644 index 0000000..e848f35 --- /dev/null +++ b/examples/official-site/documentation.sql @@ -0,0 +1,57 @@ +-- ensure that the component exists and do not render this page if it does not +select 'redirect' as component, sqlpage.link('component.sql', json_object('component', $component)) as link +where $component is not null; + +-- This line, at the top of the page, tells web browsers to keep the page locally in cache once they have it. +select 'http_header' as component, 'public, max-age=600, stale-while-revalidate=3600, stale-if-error=86400' as "Cache-Control"; + +select 'dynamic' as component, json_patch(json_extract(properties, '$[0]'), json_object( + 'title', coalesce($component || ' - ', '') || 'SQLPage Documentation' +)) as properties +FROM example WHERE component = 'shell' LIMIT 1; + +select 'text' as component, format('SQLPage v%s documentation', sqlpage.version()) as title; +select ' +If you are completely new to SQLPage, you should start by reading the [get started tutorial](/your-first-sql-website/), +which will guide you through the process of creating your first SQLPage application. + +Building an application with SQLPage is quite simple. +To create a new web page, just create a new SQL file. +For each SELECT statement that you write, the data it returns will be analyzed and rendered to the user. +The two most important concepts in SQLPage are **components** and **parameters**. + + - **components** are small user interface elements that you can use to display your data in a certain way. + - *top-level* **parameters** are the properties of these components, allowing you to customize their appearance and behavior. + - *row-level* **parameters** constitute the data that you want to display in the components. + +To select a component and set its top-level properties, you write the following SQL statement: + +```sql +SELECT ''component_name'' AS component, ''my value'' AS top_level_parameter_1; +``` + +Then, you can set its row-level parameters by writing a second SELECT statement: + +```sql +SELECT my_column_1 AS row_level_parameter_1, my_column_2 AS row_level_parameter_2 FROM my_table; +``` + +This page documents all the components provided by default in SQLPage and their parameters. +Use this as a reference when building your SQL application. +For more information about SQLPage variables and [SQLPage functions](/functions), +read about [the SQLPage data model](/extensions-to-sql). + +If at any point you need help, you can ask for it on the [SQLPage forum](https://github.com/sqlpage/SQLPage/discussions). + +If you know some [HTML](https://developer.mozilla.org/en-US/docs/Learn/Getting_started_with_the_web/HTML_basics), +you can also easily [create your own components for your application](./custom_components.sql). +' as contents_md; + +select 'list' as component, 'components' as title; +select + name as title, + description, + icon, + sqlpage.link('component.sql', json_object('component', name)) as link +from component +order by name; diff --git a/examples/official-site/examples/authentication/basic_auth.sql b/examples/official-site/examples/authentication/basic_auth.sql new file mode 100644 index 0000000..5eed407 --- /dev/null +++ b/examples/official-site/examples/authentication/basic_auth.sql @@ -0,0 +1,20 @@ +select 'http_header' as component, 'noindex' as "X-Robots-Tag"; + +SELECT 'authentication' AS component, + case sqlpage.basic_auth_username() + when 'admin' + then '$argon2i$v=19$m=8,t=1,p=1$YWFhYWFhYWE$oKBq5E8XFTHO2w' -- the password is 'password' + when 'user' + then '$argon2i$v=19$m=8,t=1,p=1$YWFhYWFhYWE$qsrWdjgl96ooYw' -- the password is 'user' + end AS password_hash, -- this is a hash of the password 'password' + sqlpage.basic_auth_password() AS password; -- this is the password that the user entered in the browser popup + +select 'dynamic' as component, properties FROM example WHERE component = 'shell' LIMIT 1; + +select 'text' as component, ' +# Authentication + +Read the [source code](//github.com/sqlpage/SQLPage/blob/main/examples/official-site/examples/authentication/basic_auth.sql) for this demo. +' as contents_md; + +select 'alert' as component, 'info' as color, CONCAT('You are logged in as ', sqlpage.basic_auth_username()) as title; diff --git a/examples/official-site/examples/authentication/create_session_token.sql b/examples/official-site/examples/authentication/create_session_token.sql new file mode 100644 index 0000000..8ea8cd1 --- /dev/null +++ b/examples/official-site/examples/authentication/create_session_token.sql @@ -0,0 +1,16 @@ +-- delete expired sessions +delete from user_sessions where created_at < datetime('now', '-1 day'); + +-- check that the +SELECT 'authentication' AS component, + 'login.sql?failed' AS link, -- redirect to the login page on error + (SELECT password_hash FROM users WHERE username = :username) AS password_hash, -- this is a hash of the password 'admin' + :password AS password; -- this is the password that the user sent through our form in 'index.sql' + +-- if we haven't been redirected, then the password is correct +-- create a new session +insert into user_sessions (session_token, username) values (sqlpage.random_string(32), :username) +returning 'cookie' as component, 'session_token' as name, session_token as value; + +-- redirect to the authentication example home page +select 'redirect' as component, '/examples/authentication' as link; \ No newline at end of file diff --git a/examples/official-site/examples/authentication/index.sql b/examples/official-site/examples/authentication/index.sql new file mode 100644 index 0000000..32d2b6d --- /dev/null +++ b/examples/official-site/examples/authentication/index.sql @@ -0,0 +1,18 @@ +-- redirect the user to the login page if they are not logged in +-- this query should be present at the top of every page that requires authentication +set user_role = (select role from users natural join user_sessions where session_token = sqlpage.cookie('session_token')); +select 'redirect' as component, 'login.sql' as link where $user_role is null; + +select 'dynamic' as component, + json_insert(properties, '$[0].menu_item[#]', 'logout') as properties +FROM example WHERE component = 'shell' LIMIT 1; + +select 'alert' as component, 'info' as color, CONCAT('You are logged in as ', $user_role) as title; + +select 'text' as component, ' +# Authentication + +Read the [source code](//github.com/sqlpage/SQLPage/blob/main/examples/official-site/examples/authentication/) for this demo. + +[Log out](logout.sql) +' as contents_md; \ No newline at end of file diff --git a/examples/official-site/examples/authentication/login.sql b/examples/official-site/examples/authentication/login.sql new file mode 100644 index 0000000..b0a39f5 --- /dev/null +++ b/examples/official-site/examples/authentication/login.sql @@ -0,0 +1,29 @@ +select 'dynamic' as component, properties FROM example WHERE component = 'shell' LIMIT 1; + +select + 'login' as component, + 'create_session_token.sql' as action, + '/assets/icon.webp' as image, + 'Demo Login Form' as title, + 'Username' as username, + 'Password' as password, + case when $failed is not null then 'Invalid username or password. In this demo, you can log in with admin / admin.' end as error_message, + 'In this demo, the username is "admin" and the password is "admin".' as footer_md, + 'Log in' as validate; + +select 'text' as component, ' + +# Authentication + +This is a simple example of an authentication form. +It uses + - the [`login`](/documentation.sql?component=login#component) component to create a login form + - the [`authentication`](/documentation.sql?component=authentication#component) component to check the user password + - the [`cookie`](/documentation.sql?component=cookie#component) component to store a unique session token in the user browser + - the [`redirect`](/documentation.sql?component=redirect#component) component to redirect the user to the login page if they are not logged in + +## Example credentials + + - Username: `admin`, Password: `admin` + - Username: `user`, Password: `user` +' as contents_md; \ No newline at end of file diff --git a/examples/official-site/examples/authentication/logout.sql b/examples/official-site/examples/authentication/logout.sql new file mode 100644 index 0000000..90d2699 --- /dev/null +++ b/examples/official-site/examples/authentication/logout.sql @@ -0,0 +1,4 @@ +delete from user_sessions +where session_token = sqlpage.cookie('session_token'); + +select 'redirect' as component, 'login.sql' as link; \ No newline at end of file diff --git a/examples/official-site/examples/big_chart.sql b/examples/official-site/examples/big_chart.sql new file mode 100644 index 0000000..c9a2ba7 --- /dev/null +++ b/examples/official-site/examples/big_chart.sql @@ -0,0 +1,18 @@ +select + 'chart' as component, + 'bar' as type, + TRUE as toolbar, + TRUE as time; + +with recursive integers(i) as ( + select 0 as i + union all + select i + 1 + from integers + where i + 1 < 100 +) +select + 'S' || (i%10) as series, + format('%d-01-01', 2010 + (i/10)) as x, + abs(random() % 10) as value +from integers; diff --git a/examples/official-site/examples/chart.sql b/examples/official-site/examples/chart.sql new file mode 100644 index 0000000..3fb18af --- /dev/null +++ b/examples/official-site/examples/chart.sql @@ -0,0 +1,21 @@ +set n=coalesce($n, 1); + +select + 'chart' as component, + 'Syracuse Sequence' as title, + coalesce($type, 'area') as type, + coalesce($color, 'indigo') as color, + 5 as marker, + 0 as ymin; +with recursive seq(x, y) as ( + select 0, CAST($n as integer) + union all + select x+1, case + when y % 2 = 0 then y/2 + else 3*y+1 + end + from seq + where x<10 +) +select x, y from seq; + diff --git a/examples/official-site/examples/csv_download.sql b/examples/official-site/examples/csv_download.sql new file mode 100644 index 0000000..14dbd9b --- /dev/null +++ b/examples/official-site/examples/csv_download.sql @@ -0,0 +1,2 @@ +select 'csv' as component, 'example.csv' as filename; +select * from component; diff --git a/examples/official-site/examples/dynamic_shell.sql b/examples/official-site/examples/dynamic_shell.sql new file mode 100644 index 0000000..9a76a33 --- /dev/null +++ b/examples/official-site/examples/dynamic_shell.sql @@ -0,0 +1,129 @@ +SELECT 'dynamic' AS component, json_object( + 'component', 'shell', + 'title', 'SQLPage documentation', + 'link', '/', + 'menu_item', json_array( + json_object( + 'link', '/', + 'title', 'Home' + ), + json_object( + 'title', 'Community', + 'submenu', json_array( + json_object( + 'link', '/blog.sql', + 'title', 'Blog' + ), + json_object( + 'link', 'https://github.com/sqlpage/SQLPage/issues', + 'title', 'Issues' + ), + json_object( + 'link', 'https://github.com/sqlpage/SQLPage/discussions', + 'title', 'Discussions' + ), + json_object( + 'link', 'https://github.com/sqlpage/SQLPage', + 'title', 'Github' + ) + ) + ) + ), + 'javascript', json_array( + 'https://cdn.jsdelivr.net/npm/prismjs@1/components/prism-core.min.js', + 'https://cdn.jsdelivr.net/npm/prismjs@1/plugins/autoloader/prism-autoloader.min.js' + ), + 'css', '/prism-tabler-theme.css' +) AS properties; + +select 'text' as component, +'## Dynamic properties + +The menu of this page is generated using the `dynamic` component with the following SQL query: + +```sql +SELECT ''dynamic'' AS component, json_object( + ''component'', ''shell'', + ''title'', ''SQLPage documentation'', + ''link'', ''/'', + ''menu_item'', json_array( + json_object( + ''link'', ''/'', + ''title'', ''Home'' + ), + json_object( + ''title'', ''Community'', + ''submenu'', json_array( + json_object( + ''link'', ''/blog.sql'', + ''title'', ''Blog'' + ), + json_object( + ''link'', ''https://github.com/sqlpage/SQLPage/issues'', + ''title'', ''Issues'' + ), + json_object( + ''link'', ''https://github.com/sqlpage/SQLPage/discussions'', + ''title'', ''Discussions'' + ), + json_object( + ''link'', ''https://github.com/sqlpage/SQLPage'', + ''title'', ''Github'' + ) + ) + ) + ) +) AS properties +``` + +## Dynamic properties from the database + +One could also store the menu items in the database, +using a structure like this: + +```sql +CREATE TABLE menu_items ( + id INTEGER PRIMARY KEY, + title TEXT, + link TEXT, + parent_id INTEGER REFERENCES menu_items(id) +); + +INSERT INTO menu_items (id, title, link, parent_id) VALUES + (1, ''Home'', ''/'', NULL), + (2, ''Community'', NULL, NULL), + (3, ''Blog'', ''blog.sql'', 2), + (4, ''Issues'', ''https://github.com/sqlpage/SQLPage/issues'', 2), + (5, ''Discussions'', ''https://github.com/sqlpage/SQLPage/discussions'', 2), + (6, ''Github'', ''https://github.com/sqlpage/SQLPage'', 2); +``` + +Then, one could use the following SQL query to fetch +and generate the menu from the database: + +```sql +SELECT ''dynamic'' AS component, json_object( + ''component'', ''shell'', + ''title'', ''SQLPage documentation'', + ''link'', ''/'', + ''menu_item'', json_group_array( + json_object( + ''link'', link, + ''title'', title, + ''submenu'', ( + select json_group_array( + json_object( + ''link'', submenu.link, + ''title'', submenu.title + ) + ) FROM menu_items AS submenu + WHERE menu_items.id = submenu.parent_id + HAVING COUNT(*) > 0 + ) + ) + ) +) AS properties +FROM menu_items +WHERE parent_id IS NULL +``` +' as contents_md; \ No newline at end of file diff --git a/examples/official-site/examples/form.sql b/examples/official-site/examples/form.sql new file mode 100644 index 0000000..913074e --- /dev/null +++ b/examples/official-site/examples/form.sql @@ -0,0 +1,92 @@ +select 'shell' as component, 'dark' as theme, '[View source on Github](https://github.com/sqlpage/SQLPage/blob/main/examples/official-site/examples/form.sql)' as footer; + +SELECT 'form' AS component, 'Complete Input Types Reference' AS title, '/examples/show_variables.sql' as action; + +SELECT 'header' AS type, 'Text Input Types' AS label; + +SELECT 'username' AS name, 'text' AS type, 'Enter your username' AS placeholder, + '**Text** - Default single-line text input. Use for short text like names, usernames, titles. Supports `minlength`, `maxlength`, `pattern` for validation.' AS description_md; + +SELECT 'password' AS name, 'password' AS type, '^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$' AS pattern, + '**Password** - Masked text input that hides characters. Use for passwords and sensitive data. Combine with `pattern` attribute for password strength requirements.' AS description_md; + +SELECT 'search_query' AS name, 'search' AS type, 'Search...' AS placeholder, + '**Search** - Search input field, may display a clear button. Use for search boxes. Mobile browsers may show optimized keyboard.' AS description_md; + +SELECT 'bio' AS name, 'textarea' AS type, 5 AS rows, 'Tell us about yourself...' AS placeholder, + '**Textarea** (SQLPage custom) - Multi-line text input. Use for long text like comments, descriptions, articles. Set `rows` to control initial height.' AS description_md; + +SELECT 'header' AS type, 'Numeric Input Types' AS label; + +SELECT 'age' AS name, 'number' AS type, 0 AS min, 120 AS max, 1 AS step, + '**Number** - Numeric input with up/down arrows. Use for quantities, ages, counts. Supports `min`, `max`, `step`. Mobile shows numeric keyboard.' AS description_md; + +SELECT 'price' AS name, 'number' AS type, 0.01 AS step, '$' AS prefix, + '**Number with decimals** - Set `step="0.01"` for currency. Use `prefix`/`suffix` for units. Great for prices, measurements, percentages.' AS description_md; + +SELECT 'volume' AS name, 'range' AS type, 0 AS min, 100 AS max, 50 AS value, 1 AS step, + '**Range** - Slider control for selecting a value. Use for volume, brightness, ratings, or any bounded numeric value where precision isn''t critical.' AS description_md; + +SELECT 'header' AS type, 'Date and Time Types' AS label; + +SELECT 'birth_date' AS name, 'date' AS type, + '**Date** - Date picker (year, month, day). Use for birthdays, deadlines, event dates. Most browsers show a calendar widget. Supports `min` and `max` for date ranges.' AS description_md; + +SELECT 'appointment_time' AS name, 'time' AS type, + '**Time** - Time picker (hours and minutes). Use for appointment times, opening hours, alarms. Shows time selector in supported browsers.' AS description_md; + +SELECT 'meeting_datetime' AS name, 'datetime-local' AS type, + '**Datetime-local** - Date and time picker without timezone. Use for scheduling events, booking appointments, logging timestamps in local time.' AS description_md; + +SELECT 'birth_month' AS name, 'month' AS type, + '**Month** - Month and year picker. Use for credit card expiration dates, monthly reports, subscription periods.' AS description_md; + +SELECT 'vacation_week' AS name, 'week' AS type, + '**Week** - Week and year picker. Use for week-based scheduling, timesheet entry, weekly reports.' AS description_md; + +SELECT 'header' AS type, 'Contact Information Types' AS label; + +SELECT 'user_email' AS name, 'email' AS type, 'user@example.com' AS placeholder, + '**Email** - Email address input with built-in validation. Use for email fields. Browser validates format automatically. Mobile shows @ key on keyboard.' AS description_md; + +SELECT 'phone' AS name, 'tel' AS type, '+1 (555) 123-4567' AS placeholder, + '**Tel** - Telephone number input. Use for phone numbers. Mobile browsers show numeric keyboard with phone symbols. No automatic validation - use `pattern` if needed.' AS description_md; + +SELECT 'website' AS name, 'url' AS type, 'https://example.com' AS placeholder, + '**URL** - URL input with validation. Use for website addresses, links. Browser validates URL format. Mobile may show .com key on keyboard.' AS description_md; + +SELECT 'header' AS type, 'Selection Types' AS label; + +SELECT 'country' AS name, 'select' AS type, + '[{"label": "United States", "value": "US"}, {"label": "Canada", "value": "CA"}, {"label": "United Kingdom", "value": "GB"}]' AS options, + '**Select** (SQLPage custom) - Dropdown menu. Use for single choice from many options. Add `multiple` for multi-select. Use `searchable` for long lists. Set `dropdown` for enhanced UI.' AS description_md; + +SELECT 'gender' AS name, 'radio' AS type, 'Male' AS value, 'Male' AS label, + '**Radio** - Radio button for mutually exclusive choices. Create multiple rows with same `name` for a radio group. One option can be selected. Use for 2-5 options.' AS description_md; + +SELECT 'gender' AS name, 'radio' AS type, 'Female' AS value, 'Female' AS label; + +SELECT 'gender' AS name, 'radio' AS type, 'Other' AS value, 'Other' AS label; + +SELECT 'interests' AS name, 'checkbox' AS type, 'Technology' AS value, 'Technology' AS label, + '**Checkbox** - Checkbox for multiple selections. Each checkbox is independent. Use for yes/no questions or multiple selections from a list.' AS description_md; + +SELECT 'terms' AS name, 'checkbox' AS type, TRUE AS required, 'I accept the terms and conditions' AS label, + '**Checkbox (required)** - Use `required` to make acceptance mandatory. Common for terms of service, privacy policies, consent forms.' AS description_md; + +SELECT 'notifications' AS name, 'switch' AS type, 'Enable email notifications' AS label, TRUE AS checked, + '**Switch** (SQLPage custom) - Toggle switch, styled checkbox alternative. Use for on/off settings, feature toggles, preferences. More intuitive than checkboxes for boolean settings.' AS description_md; + +SELECT 'header' AS type, 'File and Media Types' AS label; + +SELECT 'profile_picture' AS name, 'file' AS type, 'image/*' AS accept, + '**File** - File upload control. Use `accept` to limit file types (image/\*, .pdf, .doc). Use `multiple` to allow multiple files. Automatically sets form enctype to multipart/form-data.' AS description_md; + +SELECT 'documents[]' AS name, 'Documents' as label, 'file' AS type, '.pdf,.doc,.docx' AS accept, TRUE AS multiple, + '**File (multiple)** - Allow multiple file uploads with `multiple` attribute. Specify exact extensions or MIME types in `accept`.' AS description_md; + +SELECT 'favorite_color' AS name, 'color' AS type, '#3b82f6' AS value, + '**Color** - Color picker. Use for theme customization, design settings, highlighting preferences. Returns hex color code (#RRGGBB).' AS description_md; + +SELECT 'user_id' AS name, 'hidden' AS type, '12345' AS value, + '**Hidden** - Hidden input, not visible to users. Use for IDs, tokens, state information that needs to be submitted but not displayed or edited.' AS description_md; diff --git a/examples/official-site/examples/from_component_options_source.sql b/examples/official-site/examples/from_component_options_source.sql new file mode 100644 index 0000000..b7be689 --- /dev/null +++ b/examples/official-site/examples/from_component_options_source.sql @@ -0,0 +1,5 @@ +select 'json' as component; + +select name as value, name as label +from component +where name like '%' || $search || '%'; \ No newline at end of file diff --git a/examples/official-site/examples/handle_csv_upload.sql b/examples/official-site/examples/handle_csv_upload.sql new file mode 100644 index 0000000..3adbe68 --- /dev/null +++ b/examples/official-site/examples/handle_csv_upload.sql @@ -0,0 +1,9 @@ +-- temporarily store the data in a table with text columns +create temporary table if not exists product_tmp(name text, description text, price text); +delete from product_tmp; + +-- copy the data from the CSV file into the temporary table +copy product_tmp(name, description, price) from 'product_data_file'; + +select 'table' as component; +select * from product_tmp; \ No newline at end of file diff --git a/examples/official-site/examples/handle_enctype.sql b/examples/official-site/examples/handle_enctype.sql new file mode 100644 index 0000000..72795fc --- /dev/null +++ b/examples/official-site/examples/handle_enctype.sql @@ -0,0 +1,9 @@ +SET ":enctype" = CASE :percent_encoded IS NOT NULL OR :multipart_form_data IS NOT NULL + WHEN TRUE THEN 'with ``' || COALESCE(:percent_encoded, :multipart_form_data) || '``' + ELSE 'form' +END ||' encoding type' +SELECT 'text' AS component; +SELECT 'The following data was submitted '||:enctype||': +``` +' || :data ||' +```' AS contents_md; \ No newline at end of file diff --git a/examples/official-site/examples/handle_picture_upload.sql b/examples/official-site/examples/handle_picture_upload.sql new file mode 100644 index 0000000..2b2e7b3 --- /dev/null +++ b/examples/official-site/examples/handle_picture_upload.sql @@ -0,0 +1,36 @@ +select 'dynamic' as component, properties FROM example WHERE component = 'shell' LIMIT 1; + +select 'title' as component, 'SQLPage Image Upload Demo' as contents; + +set data_url = sqlpage.read_file_as_data_url(sqlpage.uploaded_file_path('my_file')); + +select 'card' as component, 1 as columns where $data_url is not null; +select 'Your picture' as title, + $data_url as top_image, + 'Uploaded file type: ' || sqlpage.uploaded_file_mime_type('my_file') as description +where $data_url is not null; + +select 'form' as component, 'Upload picture' as validate; +select 'my_file' as name, 'file' as type, 'Picture' as label; + +select 'text' as component, ' +## About + +This is a demo of the SQLPage file upload feature. +A file upload form is created using the [form](/documentation.sql?component=form#component) component: + +```sql +select ''form'' as component; +select ''my_file'' as name, ''file'' as type, ''Picture'' as label; +``` + +When a file is uploaded, it is displayed in a [card](/documentation.sql?component=card#component) component +using the [sqlpage.read_file_as_data_url](/functions.sql?function=read_file_as_data_url#function) function: + +```sql +select ''card'' as component, 1 as columns; +select ''Your picture'' as title, sqlpage.read_file_as_data_url(sqlpage.uploaded_file_path(''my_file'')) as top_image; +``` + +[See the source code of this page](https://github.com/sqlpage/SQLPage/blob/main/examples/official-site/examples/handle_picture_upload.sql). +' as contents_md; \ No newline at end of file diff --git a/examples/official-site/examples/hash_password.sql b/examples/official-site/examples/hash_password.sql new file mode 100644 index 0000000..a9e8504 --- /dev/null +++ b/examples/official-site/examples/hash_password.sql @@ -0,0 +1,54 @@ +select 'dynamic' as component, properties FROM example WHERE component = 'shell' LIMIT 1; + +select 'text' as component, ' + +# Password Hashing + +In SQLPage, you can use the [`sqlpage.hash_password`](/functions.sql?function=hash_password) function to +create a sequence of letters and numbers that can be used to verify +a password, but cannot be used to recover the password itself. +This is called a [hash](https://en.wikipedia.org/wiki/Hash_function) of the password, +and it is a common way to store passwords in a database. +This way, even if someone gains access to the database, they cannot +recover the passwords. + +They could still try to guess the passwords, but since SQLPage +uses the [argon2](https://en.wikipedia.org/wiki/Argon2) algorithm, +it would take a very long time (hundreds of years) to guess a strong password. + +The `sqlpage.hash_password` function takes a password as input, and +returns a hash of the password as output. It takes some time +(a few hundred milliseconds) to compute the hash, so you should +only call it when the user is creating a new account and on the initial +login. You should not call it on every page load. + +When you have logged in an user using the +[`authentication`](/documentation.sql?component=authentication#component) component, +you can store their session identifier on their browser using the +[`cookie`](/documentation.sql?component=cookie#component) component. + +## Example + + - [Source code for this page](https://github.com/sqlpage/SQLPage/blob/main/examples/official-site/examples/hash_password.sql) + - [Full user authentication and session management example](https://github.com/sqlpage/SQLPage/blob/main/examples/user-authentication) + +# Try it out + +You can try the password hashing function out by entering a password +below and clicking "Hash Password". +' as contents_md; + +select 'form' as component, 'Hash Password' as validate; +select 'password' as type, 'password' as name, 'Password' as label, 'Enter a password to hash' as placeholder; + +select 'text' as component, ' + +### Hashed Password + +The password you entered above hashed to the following value: + +``` +' || sqlpage.hash_password(:password) || ' +``` +' as contents_md +where :password is not null; \ No newline at end of file diff --git a/examples/official-site/examples/index.sql b/examples/official-site/examples/index.sql new file mode 100644 index 0000000..371d206 --- /dev/null +++ b/examples/official-site/examples/index.sql @@ -0,0 +1 @@ +select 'redirect' as component, '/examples/tabs' as link; \ No newline at end of file diff --git a/examples/official-site/examples/layouts.sql b/examples/official-site/examples/layouts.sql new file mode 100644 index 0000000..1169d77 --- /dev/null +++ b/examples/official-site/examples/layouts.sql @@ -0,0 +1,60 @@ +select 'http_header' as component, 'nofollow' as "X-Robots-Tag"; -- nofollow to avoid duplicate content + +set layout = coalesce($layout, 'boxed'); +set sidebar = coalesce($sidebar, 0); + +select 'dynamic' as component, + json_patch(properties->0, + json_object( + 'layout', $layout, + 'sidebar', $sidebar = 1 + ) + ) as properties +FROM example WHERE component = 'shell' LIMIT 1; + +select 'text' as component, ' +# Layouts + +SQLPage comes with a few built-in layouts that you can use to organize the general structure of your application. +The menu items are displayed horizontally at the top of the page by default, which you can change to a vertical sidebar +with `true as sidebar`. +The default `layout` is `''boxed''`, which has a fixed-width container for the main content area. +You can also choose between a `''horizontal''` layout with a larger container and a `''fluid''` layout that spans the full width of the page. +When using a sidebar, the page layout will automatically switch to a fluid layout. + +Click on one of the layouts below to try it out. + +For more information on how to use layouts, see the [shell component documentation](/documentation.sql?component=shell#component). +' as contents_md; + +select 'list' as component, 'Available SQLPage shell layouts' as title; +select + column1 as title, + sqlpage.set_variable('layout', lower(column1)) as link, + $layout = lower(column1) as active, + column3 as icon, + column2 as description +from (VALUES + ('Boxed', 'A compact layout with a fixed-width container. This is the default layout.', 'layout-distribute-vertical'), + ('Horizontal', 'A full-width layout with a horizontal navigation bar.', 'layout-align-top'), + ('Fluid', 'A full-width layout with a fluid container.', 'layout-distribute-horizontal') +) as t; + +select 'list' as component, 'Available Menu layouts' as title; +select + column1 as title, + sqlpage.set_variable('sidebar', column1 = 'Sidebar') as link, + (column1 = 'Sidebar' AND $sidebar = 1) OR (column1 = 'Horizontal' AND $sidebar = 0) as active, + column2 as description, + column3 as icon +from (VALUES + ('Horizontal', 'Display menu items horizontally at the top of the page.', 'layout-navbar'), + ('Sidebar', 'Display menu items vertically on the left side of the page.', 'layout-sidebar') +) as t; + +select 'code' as component; +select 'SQL code to use' as title, 'sql' as language, printf('select + ''shell'' as component, + ''%s'' as layout, + %s as sidebar; +', $layout, case when $sidebar then 'true' else 'false' end) as contents; \ No newline at end of file diff --git a/examples/official-site/examples/menu_icon.sql b/examples/official-site/examples/menu_icon.sql new file mode 100644 index 0000000..e558fdb --- /dev/null +++ b/examples/official-site/examples/menu_icon.sql @@ -0,0 +1,10 @@ +SELECT + 'shell' AS component, + 'SQLPage' AS title, + 'database' AS icon, + '/' AS link, + TRUE AS fixed_top_menu, + '{"title":"About","icon": "settings","submenu":[{"link":"/safety.sql","title":"Security","icon": "logout"},{"link":"/performance.sql","title":"Performance"}]}' AS menu_item, + '{"title":"Examples","image": "https://upload.wikimedia.org/wikipedia/en/6/6b/Terrestrial_globe.svg","submenu":[{"link":"/examples/tabs/","title":"Tabs","image": "https://upload.wikimedia.org/wikipedia/en/6/6b/Terrestrial_globe.svg"},{"link":"/examples/layouts.sql","title":"Layouts"}]}' AS menu_item, + '{"title":"Examples","size":"sm","image": "https://upload.wikimedia.org/wikipedia/en/6/6b/Terrestrial_globe.svg","submenu":[{"link":"/examples/tabs/","title":"Tabs","image": "https://upload.wikimedia.org/wikipedia/en/6/6b/Terrestrial_globe.svg"},{"link":"/examples/layouts.sql","title":"Layouts"}]}' AS menu_item, + 'Official [SQLPage](https://sql-page.com) documentation' as footer; diff --git a/examples/official-site/examples/multistep-form/index.sql b/examples/official-site/examples/multistep-form/index.sql new file mode 100644 index 0000000..cccaafb --- /dev/null +++ b/examples/official-site/examples/multistep-form/index.sql @@ -0,0 +1,90 @@ +-- This demonstrates how to build multi-step forms using the `form` component, and hidden inputs. +select 'dynamic' as component, properties FROM example WHERE component = 'shell' LIMIT 1; + +select 'text' as component, ' +# SQLPage Multi-Step Form Example + +The example below demonstrates how to build complex multi-step forms using the [`form`](/documentation.sql?component=form#component) component. +In this example, the list of cities is taken from a dynamic database table, +and each step of the form is shown conditionally based on the previous step. +The form has a variable number of fields: after the number of adults and children are selected, +a field is shown for each passenger to enter their name. + +See [the SQL source on GitHub](https://github.com/sqlpage/SQLPage/blob/main/examples/official-site/examples/multistep-form) for the full code. +' as contents_md; + +create temporary table if not exists cities as +select 1 as id, 'Cairo' as city union all +select 2, 'Delhi' union all +select 3, 'Dhaka' union all +select 4, 'Istanbul' union all +select 5, 'Karachi' union all +select 6, 'Kinshasa' union all +select 7, 'Lagos' union all +select 8, 'Mexico' union all +select 9, 'New York City' union all +select 10, 'Paris'; + +select 'form' as component, 'Book a flight' as title, + case when :adults is null + then 'Next' + else 'Book the flight !' + end as validate, + case when :adults is null + then '' + else 'result.sql' + end as action; + +-- First step: Select origin city +select 'select' as type, 'origin' as name, 'From' as label, 'Select your origin city' as placeholder, true as searchable, +case when :origin is null then 12 else 6 end as width, -- The origin field takes the entire width of the form, unless it's already selected +CAST(:origin AS INTEGER) as value, -- We keep the value of the origin city in the form. All form fields are submitted as text +json_group_array(json_object('value', id, 'label', city)) as options +from cities; + +-- Second step: Select destination city, but only show destinations once origin is selected, and not the same as origin +select 'select' as type, 'destination' as name, 'To' as label, 'Select your destination city' as placeholder, true as searchable, +6 as width, -- The destination field always takes half the width of the form +CAST(:destination AS INTEGER) as value, -- We keep the value of the destination city in the form +json_group_array(json_object('value', id, 'label', city)) as options +from cities +where id != CAST(:origin AS INTEGER) -- We can't fly to the same city we're flying from +having :origin is not null; -- Only show destinations once origin is selected + +-- Third step: Select departure date and number of passengers +select 'date' as type, 'departure_date' as name, 'Departure date' as label, 'When do you want to depart?' as placeholder, +date('now') as min, date('now', '+6 months') as max, +4 as width, -- The departure date field takes a third of the width of the form +coalesce(:departure_date, date('now', '+7 days')) as value -- Default to a week from now +where :destination is not null; -- Only show departure date once destination is selected + +select 'number' as type, 'adults' as name, 'Number of Adults' as label, 'How many adults are flying?' as placeholder, 1 as min, 10 as max, +coalesce(:adults, 2) as value, -- Default to 1 adult +:adults is not null as readonly, -- The number of adults field is readonly once it's selected +4 as width -- The number of adults field takes a third of the width of the form +where :destination is not null; -- Only show number of adults once destination is selected + +select 'number' as type, 'children' as name, 'Number of Children' as label, 'How many children are flying?' as placeholder, +coalesce(:children, '0') as value, -- Default to 0 children +:children is not null as readonly, -- The number of adults field is readonly once it's selected +4 as width -- The number of children field takes a third of the width of the form +where :destination is not null; -- Only show number of children once destination is selected + +-- Fourth step: Enter passenger details +with recursive passenger_ids as ( + select 0 as id, 0 as passenger_type union all + select id + 1 as id, + case when id < CAST(:adults AS INTEGER) + then 'adult' + else 'child' + end as passenger_type + from passenger_ids + where id < CAST(:adults AS INTEGER) + CAST(:children AS INTEGER) +) +select 'text' as type, + printf('%s_names[]', passenger_type) as name, + true as required, + printf('Passenger %d (%s)', id, passenger_type) as label, + printf('Enter %s passenger name', passenger_type) as placeholder +from passenger_ids +where id>0 and :adults is not null -- Only show passenger details once number of adults and children are selected \ No newline at end of file diff --git a/examples/official-site/examples/multistep-form/result.sql b/examples/official-site/examples/multistep-form/result.sql new file mode 100644 index 0000000..8e1985b --- /dev/null +++ b/examples/official-site/examples/multistep-form/result.sql @@ -0,0 +1,44 @@ +select 'dynamic' as component, properties FROM example WHERE component = 'shell' LIMIT 1; + +select 'hero' as component, + 'Booking confirmation' as title, + 'Your flight is confirmed ! We wish you a pleasant journey with *SQLPage Airlines*.' as description_md, + 'https://upload.wikimedia.org/wikipedia/commons/9/9a/Bl%C3%A9riot_XI_Thulin_A_Mikael_Carlson_OTT_2013_08b.jpg' as image; + + +select 'datagrid' as component, 'plane-departure' as icon, 'Flight SQL-966' as title, 'Gate closes: ' || :departure_date || ' at 8AM' as description; +select + 'Payment' as title, + 'processed' as description, + 'https://upload.wikimedia.org/wikipedia/commons/2/2a/Mastercard-logo.svg' as image_url; +select + 'Status' as title, + 'Confirmed' as description, + 'green' as color; +select + 'Receipt' as title, + 'Email sent' as description, + 'check' as icon, + TRUE as active; +select + 'Check-In' as title, + 'Online Check-In' as description, + 'https://ophir.dev/' as link; + +select 'list' as component, 'Passengers' as title, 'users' as icon; +select value as title, 'Adult' as description, 'user' as icon from json_each(:adult_names); +select value as title, 'Child' as description, 'baby-carriage' as icon from json_each(:child_names); + +select 'divider' as component, 'Technical details' as contents; + +select 'text' as component, ' +# How is this even possible ? + +Not a single line of *HTML*, *CSS*, *JavaScript*, *Python*, *PHP* or *Ruby* was written to implement +SQLPage Airlines'' multi-step, conditional booking process. How is this even possible ? + +The entire form and result page are dynamically generated by a few simple SQL queries, that +select graphical components from [SQLPage''s component library](/documentation.sql). + +You can find the [**full source code** of this example on GitHub](https://github.com/sqlpage/SQLPage/blob/main/examples/official-site/examples/multistep-form). +' as contents_md; diff --git a/examples/official-site/examples/show_variables.sql b/examples/official-site/examples/show_variables.sql new file mode 100644 index 0000000..8ed3c63 --- /dev/null +++ b/examples/official-site/examples/show_variables.sql @@ -0,0 +1,19 @@ +SELECT 'shell' as component, 'SQLPage' as title, + 'chart' as menu_item, + 'layouts' as menu_item, + 'tabs' as menu_item, + 'show_variables' as menu_item; + +select 'list' as component, 'POST variables' as title, + 'Here is the list of POST variables sent to this page. + Post variables are accessible with `:variable_name`.' as description_md, + 'No POST variable.' as empty_title; +select key as title, ':' || key || ' = ' || "value" as description +from json_each(sqlpage.variables('post')); + +select 'list' as component, 'GET variables' as title, + 'Here is the list of GET variables sent to this page. + Get variables are accessible with `$variable_name`.' as description_md, + 'No GET variable.' as empty_title; +select key as title, '$' || key || ' = ' || "value" as description +from json_each(sqlpage.variables('get')); \ No newline at end of file diff --git a/examples/official-site/examples/tabs.sql b/examples/official-site/examples/tabs.sql new file mode 100644 index 0000000..4eea610 --- /dev/null +++ b/examples/official-site/examples/tabs.sql @@ -0,0 +1 @@ +select 'redirect' as component, 'tabs/' as link; \ No newline at end of file diff --git a/examples/official-site/examples/tabs/images/CRUD - Authentication.svg b/examples/official-site/examples/tabs/images/CRUD - Authentication.svg new file mode 100644 index 0000000..3e818d5 --- /dev/null +++ b/examples/official-site/examples/tabs/images/CRUD - Authentication.svg @@ -0,0 +1,143 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/official-site/examples/tabs/images/PostGIS - using sqlpage with geographic data.svg b/examples/official-site/examples/tabs/images/PostGIS - using sqlpage with geographic data.svg new file mode 100644 index 0000000..f3a064a --- /dev/null +++ b/examples/official-site/examples/tabs/images/PostGIS - using sqlpage with geographic data.svg @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/official-site/examples/tabs/images/SQLPage developer user interface.svg b/examples/official-site/examples/tabs/images/SQLPage developer user interface.svg new file mode 100644 index 0000000..33803aa --- /dev/null +++ b/examples/official-site/examples/tabs/images/SQLPage developer user interface.svg @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/official-site/examples/tabs/images/corporate-conundrum.svg b/examples/official-site/examples/tabs/images/corporate-conundrum.svg new file mode 100644 index 0000000..aa87b88 --- /dev/null +++ b/examples/official-site/examples/tabs/images/corporate-conundrum.svg @@ -0,0 +1,161 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/official-site/examples/tabs/images/custom form component.svg b/examples/official-site/examples/tabs/images/custom form component.svg new file mode 100644 index 0000000..90491a2 --- /dev/null +++ b/examples/official-site/examples/tabs/images/custom form component.svg @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/official-site/examples/tabs/images/forms-with-multiple-steps.svg b/examples/official-site/examples/tabs/images/forms-with-multiple-steps.svg new file mode 100644 index 0000000..8490b2f --- /dev/null +++ b/examples/official-site/examples/tabs/images/forms-with-multiple-steps.svg @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/official-site/examples/tabs/images/image gallery with user uploads.svg b/examples/official-site/examples/tabs/images/image gallery with user uploads.svg new file mode 100644 index 0000000..8b35b5a --- /dev/null +++ b/examples/official-site/examples/tabs/images/image gallery with user uploads.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/official-site/examples/tabs/images/microsoft sql server advanced forms.svg b/examples/official-site/examples/tabs/images/microsoft sql server advanced forms.svg new file mode 100644 index 0000000..558df3a --- /dev/null +++ b/examples/official-site/examples/tabs/images/microsoft sql server advanced forms.svg @@ -0,0 +1,385 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SQL Server + diff --git a/examples/official-site/examples/tabs/images/mysql json handling.svg b/examples/official-site/examples/tabs/images/mysql json handling.svg new file mode 100644 index 0000000..0efc2fd --- /dev/null +++ b/examples/official-site/examples/tabs/images/mysql json handling.svg @@ -0,0 +1,163 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/official-site/examples/tabs/images/rich-text-editor.svg b/examples/official-site/examples/tabs/images/rich-text-editor.svg new file mode 100644 index 0000000..409c52d --- /dev/null +++ b/examples/official-site/examples/tabs/images/rich-text-editor.svg @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/official-site/examples/tabs/images/roundest_pokemon_rating.svg b/examples/official-site/examples/tabs/images/roundest_pokemon_rating.svg new file mode 100644 index 0000000..06609eb --- /dev/null +++ b/examples/official-site/examples/tabs/images/roundest_pokemon_rating.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/official-site/examples/tabs/images/sending emails.svg b/examples/official-site/examples/tabs/images/sending emails.svg new file mode 100644 index 0000000..a5fec0a --- /dev/null +++ b/examples/official-site/examples/tabs/images/sending emails.svg @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/official-site/examples/tabs/images/simple-website-example.svg b/examples/official-site/examples/tabs/images/simple-website-example.svg new file mode 100644 index 0000000..1acb758 --- /dev/null +++ b/examples/official-site/examples/tabs/images/simple-website-example.svg @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/official-site/examples/tabs/images/splitwise.svg b/examples/official-site/examples/tabs/images/splitwise.svg new file mode 100644 index 0000000..1228970 --- /dev/null +++ b/examples/official-site/examples/tabs/images/splitwise.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/official-site/examples/tabs/images/todo application (PostgreSQL).svg b/examples/official-site/examples/tabs/images/todo application (PostgreSQL).svg new file mode 100644 index 0000000..1721414 --- /dev/null +++ b/examples/official-site/examples/tabs/images/todo application (PostgreSQL).svg @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/official-site/examples/tabs/images/user-authentication.svg b/examples/official-site/examples/tabs/images/user-authentication.svg new file mode 100644 index 0000000..40898e9 --- /dev/null +++ b/examples/official-site/examples/tabs/images/user-authentication.svg @@ -0,0 +1,666 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/official-site/examples/tabs/images/web servers - apache.svg b/examples/official-site/examples/tabs/images/web servers - apache.svg new file mode 100644 index 0000000..c502c5e --- /dev/null +++ b/examples/official-site/examples/tabs/images/web servers - apache.svg @@ -0,0 +1,318 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/official-site/examples/tabs/index.sql b/examples/official-site/examples/tabs/index.sql new file mode 100644 index 0000000..b5f3285 --- /dev/null +++ b/examples/official-site/examples/tabs/index.sql @@ -0,0 +1,27 @@ +select 'http_header' as component, '; rel="canonical"' as "Link"; + +select 'dynamic' as component, json_patch(json_extract(properties, '$[0]'), json_object( + 'title', 'SQLPage - SQL website examples', + 'description', 'These small focused examples each illustrate one feature of the SQLPage website builder.' +)) as properties +FROM example WHERE component = 'shell' LIMIT 1; + +select 'tab' as component, true as center; +select 'Show all examples' as title, 'All database examples' as description, '?' as link, $db is null as active; +select db_engine as title, + format('%s database examples', db_engine) as description, + format('?db=%s', db_engine) as link, + $db=db_engine as active, + case $db when db_engine then db_engine end as color +from example_cards +group by db_engine; + +select 'card' as component; +select title, description, + format('images/%s.svg', folder) as top_image, + db_engine as color, + 'https://github.com/sqlpage/SQLPage/tree/main/examples/' || folder as link +from example_cards +where $db is null or $db = db_engine; + +select 'text' as component, 'See [source code on GitHub](https://github.com/sqlpage/SQLPage/blob/main/examples/official-site/examples/tabs/)' as contents_md; diff --git a/examples/official-site/extensions-to-sql.md b/examples/official-site/extensions-to-sql.md new file mode 100644 index 0000000..36eb57a --- /dev/null +++ b/examples/official-site/extensions-to-sql.md @@ -0,0 +1,281 @@ +## How SQLPage runs your SQL + +SQLPage reads your SQL file and runs one statement at a time. For each statement, it + +- decides whether to: + - handle it inside SQLPage, or + - prepare it as a (potentially slightly modified) sql statement on the database. +- extracts values from the request to pass them as prepared statements parameters +- runs [`sqlpage.*` functions](/functions) +- passes the database results to components + +This page explains every step of the process, +with examples and details about differences between how SQLPage understands SQL and how your database does. + +## What runs where + +### Handled locally by SQLPage + +- Static simple selects (a tiny, fast subset of SELECT) +- Simple variable assignments that use only literals or variables + - All sqlpage functions + + +### Sent to your database + +Everything else: joins, subqueries, arithmetic, database functions, `SELECT @@VERSION`, `CURRENT_TIMESTAMP`, `SELECT *`, expressions, `FROM`, `WHERE`, `GROUP BY`, `ORDER BY`, `LIMIT`/`FETCH`, `WITH`, `DISTINCT`, etc. + +### Mixed statements using `sqlpage.*` functions + +[`sqlpage.*` functions](/functions.sql) are executed by SQLPage; your database never sees them. They can run: + +- Before the query, when used as values inside conditions or parameters. +- After the query, when used as top-level selected columns (applied per row). + +Examples are shown below. + +## Static simple selects + +A *static simple select* is a very restricted `SELECT` that SQLPage can execute entirely by itself. This avoids back and forths between SQLPage and the database for trivial queries. + +To be static and simple, a statement must satisfy all of the following: + +- No `FROM`, `WHERE`, `GROUP BY`, `HAVING`, `ORDER BY`, `LIMIT`/`FETCH`, `WITH`, `DISTINCT`, `TOP`, windowing, locks, or other clauses. +- Each selected item is of the form `value AS alias`. +- Each `value` is either: + - a literal (single-quoted string, number, boolean, or `NULL`), or + - a variable (like `$name`, `:message`) + +That’s it. If any part is more complex, it is not a static simple select and will be sent to the database. + +#### Examples that ARE static (executed by SQLPage) + +```sql +SELECT 'text' AS component, 'Hello' AS contents; +SELECT 'text' AS component, $name AS contents; +``` + +#### Examples that are NOT static (sent to the database) + +```sql +-- Has string concatenation +select 'from' as component, 'handle_form.sql?id=' || $id as action; + +-- Has WHERE +select 'text' as component, $alert_message as contents where $should_alert; + +-- Uses database functions or expressions +SELECT 1 + 1 AS two; +SELECT CURRENT_TIMESTAMP AS now; +SELECT @@VERSION AS version; -- SQL Server variables +-- Uses a subquery +SELECT (select 1) AS one; +``` + +## Variables + +SQLPage communicates information about incoming HTTP requests to your SQL code through prepared statement variables. + +### Variable Types and Mutability + +There are three types of variables in SQLPage: + +1. `GET` variables, or **URL parameters** (immutable): + - data sent in the URL query string. For example, in `http://example.com/my_page.sql?id=123`, your SQL code would have access to `$id`. +2. `POST` variables, or **form parameters** (immutable): + - data sent in the HTTP request body. For example, submitting a form with a field named `username` would make `:username` available in your SQL code. +3. `SET` variables, or **User-defined variables** (mutable): + - Variables created and modified with the `SET` command. For example, `SET greetings = $greetings || '!'` would update the value of `$greetings`. + +`SET` variables shadow `GET` variables with the same name, but the underlying url parameter value is still accessible using [`sqlpage.variables('get')`](/functions?function=variables). + +### POST parameters + +Form fields sent with POST are available as `:name`. + +```sql +SELECT + 'form' AS component, + 'POST' AS method, + 'result.sql' AS action; + +SELECT 'age' AS name, 'How old are you?' AS label, 'number' AS type; +``` + +```sql +-- result.sql +SELECT 'text' AS component, 'You are ' || :age || ' years old!' AS contents; +``` + +### URL parameters + +Query-string parameters are available as `$name`. + +```sql +SELECT 'text' AS component, 'You are ' || $age || ' years old!' AS contents; +-- /result.sql?age=42 → You are 42 years old! +``` + +When a URL parameter is not set, its value is `NULL`. + +### The SET command + +`SET` creates or updates a user-defined variable in SQLPage (not in the database). Only strings and `NULL` are stored. + +```sql +-- Give a default value to a variable +SET post_id = COALESCE($post_id, 0); + +-- User-defined variables shadow URL parameters with the same name +SET my_var = 'custom value'; -- This value takes precedence over ?my_var=... +``` + +**Variable Lookup Precedence:** +- `$var`: checks user-defined variables first, then URL parameters +- `:var`: checks user-defined variables first, then POST parameters + +This means `SET` variables always take precedence over request parameters when using `$var` or `:var` syntax. + +**How SET works:** +- If the right-hand side is purely literals/variables, SQLPage computes it directly. See the section about *static simple select* above. +- If it needs the database (for example, calls a database function), SQLPage runs an internal `SELECT` to compute it and stores the first column of the first row of results. + +Only a single textual value (**string or `NULL`**) is stored. +`SET id = 1` will store the string `'1'`, not the number `1`. + +On databases with a strict type system, such as PostgreSQL, if you need a number, you will need to cast your variables: `SELECT * FROM post WHERE id = $id::int`. + +Complex structures can be stored as json strings. + +For larger temporary results, prefer temporary tables on your database; do not send them to SQLPage at all. + +## `sqlpage.*` functions + +Functions under the `sqlpage.` prefix run in SQLPage. See the [functions page](/functions.sql). + +They can run: + +### Before sending the query (as input values) + +Used inside conditions or parameters, the function is evaluated first and its result is passed to the database. + +```sql +SELECT * +FROM blog +WHERE slug = sqlpage.path(); +``` + +### After receiving results (as top-level selected columns) + +Used as top-level selected columns, the query is rewritten to first fetch the raw column, and the function is applied per row in SQLPage. + +```sql +SELECT sqlpage.read_file_as_text(file_path) AS contents +FROM blog_posts; +``` + +## Performance + +See the [performance page](/performance.sql) for details. In short: + +- Statements sent to the database are prepared and cached. +- Variables and pre-computed values are bound as parameters. +- This keeps queries fast and repeatable. + +## Working with larger temporary results + +### Temporary tables in your database + +When you reuse the same values multiple times in your page, +store them in a temporary table. + +```sql +DROP TABLE IF EXISTS filtered_posts; +CREATE TEMPORARY TABLE filtered_posts AS +SELECT * FROM posts where category = $category; + +select 'alert' as component, count(*) || 'results' as title +from filtered_posts; + +select 'list' as component; +select name from filtered_posts; +``` + +### Small JSON values in variables + +Useful for small datasets that you want to keep in memory. +See the [guide on JSON in SQL](/blog.sql?post=JSON+in+SQL%3A+A+Comprehensive+Guide). + +```sql +set product = ( + select json_object('name', name, 'price', price) + from products where id = $product_id +); +``` + +## CSV imports + +When you write a compatible `COPY ... FROM 'field'` statement and upload a file with the matching form field name, SQLPage orchestrates the import: + +- PostgreSQL: the file is streamed directly to the database using `COPY FROM STDIN`; the database performs the import. +- Other databases: SQLPage reads the CSV and inserts rows using a prepared `INSERT` statement. Options like delimiter, quote, header, escape, and a custom `NULL` string are supported. With a header row, column names are matched by name; otherwise, the order is used. + +Example: + +```sql +COPY my_table (col1, col2) +FROM 'my_csv' +(DELIMITER ';', HEADER); +``` + +The uploaded file should be provided in a form field with `'file' as type, 'my_csv' as name`. + +## Data types + +Each database has its own usually large set of data types. +SQLPage itself has a much more rudimentary type system. + +### From the user to SQLPage + +Form fields and URL parameters in HTTP are fundamentally untyped. +They are just sequences of bytes. SQLPage requires them to be valid utf8 strings. + +SQLPage follows the convention that when a parameter name ends with `[]`, it represents an array. +Arrays in SQLPage are represented as JSON strings. + +Example: In `users.sql?user[]=Tim&user[]=Tom`, `$user` becomes `'["Tim", "Tom"]'` (a JSON string exploitable with your database's builtin json functions). + +### From SQLPage to the database + +SQLPage sends only strings (`TEXT` or `VARCHAR`) and `NULL`s as parameters. + +### From the database to SQLPage + +Each row returned by the database becomes a JSON object +before its passed to components: + +- Each column is a key. Duplicate column names turn into arrays. +- Numbers, booleans, text, and `NULL` map naturally. +- Dates/times become ISO strings. +- Binary data (BLOBs) becomes a data URL (with mime type auto-detection). + +#### Example + +```sql +SELECT + 1 AS one, + 'x' AS my_array, 'y' AS my_array, + now() AS today, + ''::bytea AS my_image; +``` + +Produces something like: + +```json +{ + "one": 1, + "my_array": ["x", "y"], + "today": "2025-08-30T06:40:13.894918+00:00", + "my_image": "data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=" +} +``` diff --git a/examples/official-site/extensions-to-sql.sql b/examples/official-site/extensions-to-sql.sql new file mode 100644 index 0000000..752eac7 --- /dev/null +++ b/examples/official-site/extensions-to-sql.sql @@ -0,0 +1,12 @@ +select 'http_header' as component, + 'public, max-age=300, stale-while-revalidate=3600, stale-if-error=86400' as "Cache-Control"; + +select 'dynamic' as component, json_patch(json_extract(properties, '$[0]'), json_object( + 'title', 'SQLPage - Extensions to SQL' +)) as properties +FROM example WHERE component = 'shell' LIMIT 1; + +-- Article by Matthew Larkin +select 'text' as component, + sqlpage.read_file_as_text('extensions-to-sql.md') as contents_md, + true as article; diff --git a/examples/official-site/favicon.ico b/examples/official-site/favicon.ico new file mode 100644 index 0000000..10814b5 Binary files /dev/null and b/examples/official-site/favicon.ico differ diff --git a/examples/official-site/functions.sql b/examples/official-site/functions.sql new file mode 100644 index 0000000..ad91cfa --- /dev/null +++ b/examples/official-site/functions.sql @@ -0,0 +1,68 @@ +select 'http_header' as component, + printf('<%s>; rel="canonical"', + iif($function is not null, sqlpage.link('functions', json_object('function', $function)), 'functions.sql') + ) as "Link"; + +select 'dynamic' as component, json_patch(json_extract(properties, '$[0]'), json_object( + 'title', coalesce($function || ' - ', '') || 'SQLPage Functions Documentation' +)) as properties +FROM example WHERE component = 'shell' LIMIT 1; + +select 'breadcrumb' as component; +select 'SQLPage' as title, '/' as link, 'Home page' as description; +select 'Functions' as title, '/functions.sql' as link, 'List of all functions' as description; +select $function as title, sqlpage.set_variable('function', $function) as link where $function IS NOT NULL; + +select 'text' as component, 'SQLPage built-in functions' as title where $function IS NULL; +select ' +In addition to normal SQL functions supported by your database, +SQLPage provides a few special functions to help you extract data from user requests. + +These functions are special, because they are not executed inside your database, +but by SQLPage itself before sending the query to your database. +Thus, they require all the parameters to be known at the time the query is sent to your database. +Function parameters cannot reference columns from the rest of your query. +The only case when you can call a SQLPage function with a parameter that is not a constant is when it appears at the top level of a `SELECT` statement. +For example, `SELECT sqlpage.url_encode(url) FROM t` is allowed because SQLPage can execute `SELECT url FROM t` and then apply the `url_encode` function to each value. + +For more information about how SQLPage functions are evaluated, and data types in SQLPage, read [the SQLPage data model documentation](/extensions-to-sql). +' as contents_md where $function IS NULL; + +select 'list' as component, 'SQLPage functions' as title where $function IS NULL; +select name as title, + icon, + '?function=' || name || '#function' as link, + $function = name as active +from sqlpage_functions +where $function IS NULL +order by name; + +select 'text' as component, 'sqlpage.' || $function || '(' || string_agg(name, ', ') || ')' as title, 'function' as id +from sqlpage_function_parameters where $function IS NOT NULL and "function" = $function; + +select 'text' as component; +select 'Introduced in SQLPage ' || introduced_in_version || '.' as contents, 1 as size from sqlpage_functions where name = $function; + +SELECT description_md as contents_md FROM sqlpage_functions WHERE name = $function; + +select 'title' as component, 3 as level, 'Parameters' as contents where $function IS NOT NULL AND EXISTS (SELECT 1 from sqlpage_function_parameters where "function" = $function); +select 'card' as component, 3 AS columns where $function IS NOT NULL; +select + name as title, + description_md as description, + type as footer, + 'azure' as color +from sqlpage_function_parameters where "function" = $function +ORDER BY "index"; + +select + 'button' as component, + 'sm' as size, + 'pill' as shape; +select + name as title, + icon, + sqlpage.set_variable('function', name) as link +from sqlpage_functions +where $function IS NOT NULL +order by name; diff --git a/examples/official-site/get started.sql b/examples/official-site/get started.sql new file mode 100644 index 0000000..ce4ebb8 --- /dev/null +++ b/examples/official-site/get started.sql @@ -0,0 +1 @@ +SELECT 'redirect' as component, 'your-first-sql-website/' as link; diff --git a/examples/official-site/get_started.sql b/examples/official-site/get_started.sql new file mode 100644 index 0000000..ce4ebb8 --- /dev/null +++ b/examples/official-site/get_started.sql @@ -0,0 +1 @@ +SELECT 'redirect' as component, 'your-first-sql-website/' as link; diff --git a/examples/official-site/index-old.sql b/examples/official-site/index-old.sql new file mode 100644 index 0000000..297d790 --- /dev/null +++ b/examples/official-site/index-old.sql @@ -0,0 +1,232 @@ +select 'http_header' as component, + 'public, max-age=600, stale-while-revalidate=3600, stale-if-error=86400' as "Cache-Control", + '; rel="canonical"' as "Link"; + +-- Fetch the page title and header from the database +select 'dynamic' as component, properties FROM example WHERE component = 'shell' LIMIT 1; + +SELECT 'hero' as component, + 'From database to data app, fast.' as title, + '**SQLPage** lets you build data-driven applications in a few SQL queries. + +It's free, [open-source](https://github.com/sqlpage/SQLPage), lightweight, and easy to use. + ' as description_md, + 'sqlpage_cover_image.webp' as image, + TRUE as rounded, + 'your-first-sql-website/' as link, + 'Build your first SQL website !' as link_text; + +-- the mantra: fast, beautiful, easy +SELECT 'Easy' as title, + 'You can teach yourself enough SQL to query and edit a database through SQLPage in a weekend. +We handle [security](safety.sql) and [performance](performance.sql) for you, so you can focus on your data.' as description_md, + 'sofa' as icon, + 'blue' as color; +SELECT 'Beautiful' as title, + 'The page you are looking at right now is written entirely in SQL. +No design skills are required, yet your website will be responsive, and look professional and modern by default.' as description, + 'eye' as icon, + 'green' as color; +SELECT 'Fast' as title, + 'Pages [load instantly](performance.sql), even on slow mobile networks. + SQLPage is designed as a single **lightweight** executable leveraging server-side rendering to ensure fast page loads even on low-cost servers.' as description_md, + 'mail-fast' as icon, + 'red' as color; + +SELECT 'hero' as component, + true as reverse, + '🧩 SQL User Interfaces' as title, + 'At the core of SQLPage is a [rich library of user interface components](/documentation.sql) for tables, charts, maps, timelines, forms and much more. + +To build your app, you just populate the components with data returned by your database queries.' as description_md, + 'sqlpage_illustration_components.webp' as image; + +SELECT 'hero' as component, + '🪄 Seamlessly' as title, + 'SQLPage handles HTTP requests, database connections, streaming rendering, styling, [security](safety.sql), and [performance](performance.sql) for you. + +Focus only on your data, and how you want to present it. We''ve tamed the tech, you tame the data.' as description_md, + 'sqlpage_illustration_alien.webp' as image; + +-- Quick feature overview +SELECT 'card' as component, + 'What is SQLPage ?' as title, + 1 as columns; +SELECT '✨ SQLPage turns your SQL queries into eye-catching websites' as title, + ' +SQLPage is a tool that allows you to **build websites** using nothing more than **SQL queries**. +You write simple text files containing SQL queries, SQLPage runs them on your database, and **renders the results as a website**. + +You can display the information you `SELECT` from your database in +lists, tables, charts, maps, forms, and many other user interface widgets. +But you can also `INSERT`, `UPDATE` and `DELETE` data from your database using SQLPage, and build a full webapp.' as description_md, + 'paint' as icon, + 'blue' as color; +SELECT '🧩 Pre-built components let you construct websites Quickly and Easily' as title, + 'At the core of SQLPage is [a rich library of **components**](./documentation.sql). + These components are built using traditional web technologies, but you never have to edit them if you don''t want to. + SQLPage populates the components with data returned by your SQL queries. + You can build entire web applications just by combining the components that come bundled with SQLPage. + +As an example, the list of features on this page is generated using a simple SQL query that looks like this: + +```sql +SELECT ''card'' as component, ''What is SQLPage ?'' as title; +SELECT header AS title, contents AS description_md FROM homepage_features; +``` + +However, you can also create your own components, or edit the existing ones to customize your website to your liking. +Creating a new component is as simple as creating an HTML template file. +' as description_md, + 'rocket' as icon, + 'green' as color; +SELECT '🛡️ A secure web server written in Rust' as title, + 'SQLPage removes a lot of the complexity and bloat of the modern web. +It''s a **lightweight web server** that just receives a request, finds the file to execute, runs it, +and returns a web page for the browser to display. + +Written in a fast and secure programming language ([**Rust**](https://en.wikipedia.org/wiki/Rust_(programming_language))), +it empowers non-developers to build secure web applications easily. You download [a single executable file](https://github.com/sqlpage/SQLPage/releases), +write an `index.sql`, and in five minutes you turned your database into a website that you can +[deploy on the internet easily](https://datapage.app). + +We made all the [optimizations](performance.sql), wrote all of the HTTP request handling code and rendering logic, +implemented all of the security features, so that you can think about your data, and nothing else. + +When SQLPage receives a request, it finds the corresponding SQL file (with or without the .sql extension), runs it on the database, passing it information from the web request as SQL statement parameters [in a safe manner](safety.sql). +When the database starts returning rows for the query, +SQLPage maps each piece of information in the row to a parameter in the template of a pre-defined component, +and streams the result back to the user's browser. +' as description_md, + 'server' as icon, + 'purple' as color; +SELECT '🌱 Start Simple, Scale to Advanced' as title, + 'SQLPage is a great starting point for building websites, internal tools, dashboards and data applications, +especially if you don''t have a lot of time, but need something more powerful and user-friendly than a spreadsheet. + +When your app grows, you can take the same underlying data structure and queries, +and wrap them in a more established framework with a dedicated front end if you need to. +There is no lock-in, only simple, standard SQL queries that run directly on your database. + +**Focus on what matters** first: your data and your users. Not centering boxes in CSS, not setting up a web framework.' as description_md, + 'world-cog' as icon, + 'orange' as color; + +-- Useful links +SELECT 'list' as component, + 'Get started: where to go from here ?' as title, + 'Here are some useful links to get you started with SQLPage.' as description; +SELECT 'Download' as title, + 'https://github.com/sqlpage/SQLPage/releases' as link, + 'SQLPage is distributed as a single binary that you can execute locally or on a web server to get started quickly.' as description, + 'green' as color, + 'download' as icon; +SELECT 'Tutorial' as title, + 'get started.sql' as link, + 'A short tutorial that will guide you through the creation of your first SQL-only website.' as description, + 'orange' as color, + 'book' as icon, + TRUE as active; +SELECT 'SQLPage Documentation' as title, + 'documentation.sql' as link, + 'List of all available components, with examples of how to use them.' as description, + 'purple' as color, + 'book' as icon; +SELECT 'Technical documentation on Github' as title, + 'https://github.com/sqlpage/SQLPage/blob/main/README.md#sqlpage' as link, + 'The official README file on Github contains instructions to get started using SQLPage.' as description, + 'yellow' as color, + 'file-text' as icon; +SELECT 'Youtube Video Series' as title, + 'https://www.youtube.com/playlist?list=PLTue_qIAHxAf9fEjBY2CN0N_5XOiffOk_' as link, + 'A series of video tutorials that will guide you through the creation of an application with SQLPage.' as description, + 'red' as color, + 'brand-youtube' as icon; +SELECT 'Learnsqlpage.com' as title, + 'https://learnsqlpage.com' as link, + 'A website dedicated to learning SQLPage, with detailed tutorials.' as description, + 'blue' as color, + 'globe' as icon; + +SELECT 'list' as component, 'Examples' as title; +SELECT 'Github Examples' as title, + 'https://github.com/sqlpage/SQLPage/tree/main/examples/' as link, + 'SQL source code for examples and demos of websites built with SQLPage.' as description, + 'teal' as color, + 'code' as icon; +SELECT 'Corporate Conundrum' as title, + 'https://conundrum.ophir.dev' as link, + 'A demo web application powered by SQLPage, designed for playing a fun trivia board game with friends.' as description, + 'cyan' as color, + 'affiliate' as icon; + +SELECT 'list' as component, 'Community' as title; +SELECT 'Discussion forum' as title, + 'https://github.com/sqlpage/SQLPage/discussions' as link, + 'Come to our community page to discuss SQLPage with other users and ask questions.' as description, + 'pink' as color, + 'user-heart' as icon; +-- github link +SELECT 'Source code' as title, + 'https://github.com/sqlpage/SQLPage' as link, + 'The rust source code for SQLPage itself is open and available on Github.' as description, + 'github' as color, + 'brand-github' as icon; +SELECT 'Report a bug, make a suggestion' as title, + 'https://github.com/sqlpage/SQLPage/issues' as link, + 'If you have a question, a suggestion, or if you found a bug, please open an issue on Github.' as description, + 'red' as color, + 'bug' as icon; + +-- User personas: who is SQLPage for ? +SELECT 'card' as component, + 'Is SQLPage for you ?' as title, + ' +SQLPage empowers SQL-savvy individuals to create dynamic websites without complex programming. + + - If you are looking to quickly build something simple yet dynamic, SQLPage is for you. + - If you want to customize how every pixel of your website looks, SQLPage is not for you. + +Compared to other low-code platforms, SQLPage focuses on SQL-driven development, more lightweight performance, and total openness. +Where other platforms try to lock you in, SQLPage makes it trivial to switch to something else when your application grows.' as description_md, + 4 as columns; +SELECT 'Business Analyst' as title, + 'Replace static dashboards with dynamic websites' as description, + 'Business analysts can leverage SQLPage to create interactive and real-time data visualizations, replacing traditional static dashboards and enabling more dynamic and insightful reporting.' as footer, + 'green' as color, + 'chart-arrows-vertical' as icon; +SELECT 'Data Scientist' as title, + 'Prototype and share data-driven experiments and analysis' as description, + 'Data scientists can utilize SQLPage to quickly prototype and share their data-driven experiments and analysis by creating interactive web applications directly from SQL queries, enabling collaboration and faster iterations.' as footer, + 'purple' as color, + 'square-root-2' as icon; +SELECT 'Marketer' as title, + 'Create dynamic landing pages and personalized campaigns' as description, + 'Marketers can leverage SQLPage to create dynamic landing pages and personalized campaigns by fetching and displaying data from databases, enabling targeted messaging and customized user experiences.' as footer, + 'orange' as color, + 'message-circle-dollar' as icon; +SELECT 'Engineer' as title, + 'Build internal tools and admin panels with ease' as description, + 'Engineers can use SQLPage to build internal tools and admin panels, utilizing their SQL skills to create custom interfaces and workflows, streamlining processes and improving productivity.' as footer, + 'blue' as color, + 'settings' as icon; +SELECT 'Product Manager' as title, + 'Create interactive prototypes and mockups' as description, + 'Product managers can leverage SQLPage to create interactive prototypes and mockups, allowing stakeholders to experience and provide feedback on website functionalities before development, improving product design and user experience.' as footer, + 'red' as color, + 'cube-send' as icon; +SELECT 'Educator' as title, + 'Develop interactive learning materials and exercises' as description, + 'Educators can utilize SQLPage to develop interactive learning materials and exercises, leveraging SQLPage components to present data and engage students in a dynamic online learning environment.' as footer, + 'yellow' as color, + 'school' as icon; +SELECT 'Researcher' as title, + 'Create data-driven websites to share findings and insights' as description, + 'Researchers can use SQLPage to create data-driven websites, making complex information more accessible and interactive for the audience, facilitating knowledge dissemination and engagement.' as footer, + 'cyan' as color, + 'flask-2' as icon; +SELECT 'Startup Founder' as title, + 'Quickly build a Minimum Viable Product' as description, + 'Startup founders can quickly build a Minimum Viable Product (MVP) using their SQL expertise with SQLPage, creating a functional website with database integration to validate their business idea and gather user feedback.' as footer, + 'pink' as color, + 'rocket' as icon; diff --git a/examples/official-site/index.sql b/examples/official-site/index.sql new file mode 100644 index 0000000..dabd079 --- /dev/null +++ b/examples/official-site/index.sql @@ -0,0 +1,5 @@ +select 'http_header' as component, + 'public, max-age=600, stale-while-revalidate=3600, stale-if-error=86400' as "Cache-Control", + '; rel="canonical"' as "Link"; + +select 'shell-home' as component; diff --git a/examples/official-site/llms.txt.sql b/examples/official-site/llms.txt.sql new file mode 100644 index 0000000..a1f5cda --- /dev/null +++ b/examples/official-site/llms.txt.sql @@ -0,0 +1,145 @@ +select + 'http_header' as component, + 'text/markdown; charset=utf-8' as "Content-Type", + 'inline; filename="llms.txt"' as "Content-Disposition"; + +select + 'shell-empty' as component, + '# SQLPage + +> SQLPage is a SQL-only web application framework. It lets you build entire websites and web applications using nothing but SQL queries. Write `.sql` files, and SQLPage executes them, maps results to UI components (handlebars templates), and streams HTML to the browser. + +SQLPage is designed for developers who are comfortable with SQL but want to avoid the complexity of traditional web frameworks. It works with SQLite, PostgreSQL, MySQL, and Microsoft SQL Server, and through ODBC with any other database that has an ODBC driver installed. + +Key features: +- No backend code needed: Your SQL files are your backend +- Component-based UI: Built-in components for forms, tables, charts, maps, and more +- Database-first: Every HTTP request triggers a sequence of SQL queries from a .sql file, the results are rendered with built-in or custom components, defined as .handlebars files in the sqlpage/templates folder. +- Simple deployment: Single binary with no runtime dependencies +- Secure by default: Parameterized queries prevent SQL injection + +## Getting Started + +- [Introduction to SQLPage: installation, guiding principles, and a first example](/your-first-sql-website/tutorial.md): Complete beginner tutorial covering setup, database connections, forms, and deployment + +## Core Documentation + +- [Components reference](/documentation.sql): List of all ' || ( + select + count(*) + from + component + ) || ' built-in UI components with parameters and examples +- [Functions reference](/functions.sql): SQLPage built-in functions for handling requests, encoding data, and more +- [Configuration guide](https://github.com/sqlpage/SQLPage/blob/main/configuration.md): Complete list of configuration options in sqlpage.json + +## Components + +' || ( + select + group_concat ( + '### [' || c.name || '](/component.sql?component=' || c.name || ') + +' || c.description || ' + +' || ( + select + case when exists ( + select + 1 + from + parameter + where + component = c.name + and top_level + ) then '#### Top-level parameters + +' || group_concat ( + '- `' || name || '` (' || type || ')' || case when not optional then ' **REQUIRED**' else '' end || ': ' || description, + char(10) + ) + else + '' + end + from + parameter + where + component = c.name + and top_level + ) || ' + +' || ( + select + case when exists ( + select + 1 + from + parameter + where + component = c.name + and not top_level + ) then '#### Row-level parameters + +' || group_concat ( + '- `' || name || '` (' || type || ')' || case when not optional then ' **REQUIRED**' else '' end || ': ' || description, + char(10) + ) + else + '' + end + from + parameter + where + component = c.name + and not top_level + ) || ' + +', + '' + ) + from + component c + order by + c.name + ) || ' + +## Functions + +' || ( + select + group_concat ( + '### [sqlpage.' || name || '()](/functions.sql?function=' || name || ') +' || replace ( + replace ( + description_md, + char(10) || '#', + char(10) || '###' + ), + ' ', + ' ' + ), + char(10) + ) + from + sqlpage_functions + order by + name + ) || ' + +## Examples + +- [Authentication example](https://github.com/sqlpage/SQLPage/tree/main/examples/user-authentication): Complete user registration and login system +- [CRUD application](https://github.com/sqlpage/SQLPage/tree/main/examples/CRUD%20-%20Authentication): Create, read, update, delete with authentication +- [Image gallery](https://github.com/sqlpage/SQLPage/tree/main/examples/image%20gallery%20with%20user%20uploads): File upload and image display +- [Todo application](https://github.com/sqlpage/SQLPage/tree/main/examples/todo%20application): Simple CRUD app +- [Master-detail forms](https://github.com/sqlpage/SQLPage/tree/main/examples/master-detail-forms): Working with related data +- [Charts example](https://github.com/sqlpage/SQLPage/tree/main/examples/plots%20tables%20and%20forms): Data visualization + +## Optional + +- [Custom components guide](/custom_components.sql): Create your own handlebars components +- [Safety and security](/safety.sql): Understanding SQL injection prevention +- [Docker deployment](https://github.com/sqlpage/SQLPage#with-docker): Running SQLPage in containers +- [Systemd service](https://github.com/sqlpage/SQLPage/blob/main/sqlpage.service): Production deployment setup +- [Repository structure](https://github.com/sqlpage/SQLPage/blob/main/CONTRIBUTING.md): Project organization and contribution guide +' as html; \ No newline at end of file diff --git a/examples/official-site/manual_setup.sql b/examples/official-site/manual_setup.sql new file mode 100644 index 0000000..c4a989e --- /dev/null +++ b/examples/official-site/manual_setup.sql @@ -0,0 +1 @@ +SELECT 'redirect' as component, 'get started.sql' as link; \ No newline at end of file diff --git a/examples/official-site/performance.sql b/examples/official-site/performance.sql new file mode 100644 index 0000000..332f6e3 --- /dev/null +++ b/examples/official-site/performance.sql @@ -0,0 +1,102 @@ +select 'http_header' as component, + 'public, max-age=600, stale-while-revalidate=3600, stale-if-error=86400' as "Cache-Control", + '; rel="canonical"' as "Link"; + +select 'dynamic' as component, json_patch(json_extract(properties, '$[0]'), json_object( + 'title', 'SQLPage applications are fast' +)) as properties +FROM example WHERE component = 'shell' LIMIT 1; + +select 'hero' as component, + 'Performance in SQLPage' as title, + 'SQLPage applications are fast, because they are server-side rendered, and begin streaming the page to the browser while the database is still processing the request.' as description, + 'performance.webp' as image; + +select 'text' as component, + ' +# Performance of SQLPage applications + +In SQLPage, the website author *declaratively* specifies the contents and behavior of the website using SQL queries, +as opposed to writing imperative code in a backend programming language like Java, Ruby, Python, or PHP. + +This declarative approach allows SQLPage to offer **optimizations** out of the box that are difficult or time-consuming +to achieve in traditional web development stacks. + +## Progressive server-side rendering + +SQLPage applications are [server-side rendered](https://web.dev/articles/rendering-on-the-web), +which means that the SQL queries are executed on the server, and the results are sent to the user''s browser as HTML. +In contrast, many other web frameworks render the page on the client side, which means that the browser has to download +some HTML, then download some JavaScript, then execute the JavaScript, then make more requests, +wait for the database to produce a full result set, +then process the responses before it can start rendering the actual data the user is interested in. +This can lead to loading times that are several times longer than a SQLPage application. + +### Streaming + +SQLPage applications will often feel faster than even equivalent applications written even in alternative server-side rendering +frameworks, because SQLPage streams the results of the SQL queries to the browser as soon as they are available. +The user sees the start of the page even before the database has finished producing the last query results. + +Most server-side rendering frameworks will first wait for all the SQL queries to finish, then render the page in memory +on the server, and only then send the HTML webpage to the browser. If a page contains a long list of items, the user +will have to wait for all the items to have been fetched from the database before seeing anything on the screen. +In contrast, SQLPage will start sending the first item as HTML to the browser as soon as it is available, +and the browser will start rendering it immediately. + +## Compiled SQL queries + +SQLPage prepares all your SQL queries only once, when they are first executed, and then caches the prepared statements +for future use. This means that the database does not have to parse the SQL queries, check their syntax, and create +an execution plan every time an user requests a page. + +When an user loads a page, all SQLPage has to do is tell the database: "Hey, do you remember that query we talked about +earlier? Can you give me the results for these specific parameters?". This is much faster than sending the whole SQL query +string to the database every time, especially for large complex queries that require heavy planning on the database side. + +## Compiled templates + +SQLPage also caches the compiled component templates that are used to generate the HTML for your website. +Both [built-in components](/documentation.sql) and [custom components](/custom_components.sql) you write yourself are parsed just once, and +compiled to an efficient memory representation that can be reused for every request. + +## Processing data as fast as your CPU can go + +In a traditional web development stack, the code you write in a high-level language has to be interpreted by a runtime +again and again every time a request is made to your website. +In contrast, SQLPage is entirely written in Rust, a compiled language that is known for its speed and safety guarantees. +The SQLPage binary you download already contains the optimized machine code that your cpu understands natively. + + +You traditionnally had to choose between the speed of compiled languages, +and the ease of use and developer productivity of interpreted languages. SQLPage offers the best of both worlds: + - requests are processed as fast as if you had manually written the code in Rust, + - you just have to write SQL queries, which are orders of magnitude easier to write and maintain than C++ or Rust code. + +All the logic required to serve a request to your application will be executed either in rust in SQLPage +itself, or in the database, which is also written in a performant compiled language. + +## SQL query elimination + +In the SQLPage model, you will often find yourself writing SQL queries that are entirely static, +and the results of which do not depend on the contents of the database. +For example, when you open a list with `SELECT ''list'' as component;`, you already know that the query will return +a single row with a single column, containing the string `''list''`, no matter what the contents of the database are. +SQLPage is able to detect these static queries, and it will not execute them on the database at all. +Instead, it will cache statically known results, and process them as soon as the page is requested, without any database +interaction. + +## Key Takeaways + +Performance is a key feature of SQLPage. +Its architecture allows you to build fast websites without having to implement advanced optimizations yourself. + +## Ready to get started? + +[Build your fast, secure, and beautiful website](/your-first-sql-website) with SQLPage today! + +## Already a SQLPage developer ? + +Have a look at our [performance guide](/blog?post=Performance+Guide) to learn the best practices to leverage +all the features that will make your site faster. +' as contents_md; diff --git a/examples/official-site/performance.webp b/examples/official-site/performance.webp new file mode 100644 index 0000000..60cebbc Binary files /dev/null and b/examples/official-site/performance.webp differ diff --git a/examples/official-site/pgconf/2024-sqlpage-badass.pdf b/examples/official-site/pgconf/2024-sqlpage-badass.pdf new file mode 100644 index 0000000..4691836 Binary files /dev/null and b/examples/official-site/pgconf/2024-sqlpage-badass.pdf differ diff --git a/examples/official-site/pgconf/pgconf-2023.html b/examples/official-site/pgconf/pgconf-2023.html new file mode 100644 index 0000000..f87302f --- /dev/null +++ b/examples/official-site/pgconf/pgconf-2023.html @@ -0,0 +1,1487 @@ + + + + + SQLPage: Slides + + + + + + + + +
+
+
+

Build Websites in SQL

+
+
+

Introducing SQLPage

+
+
+
+

Build Websites in SQL

+
+
+

Introducing SQLPage

+
+
+
select 
+  'form' as component,
+  'User' as title,
+  'Create' as validate;
+
+---
+
+select
+  "name", "type",
+  "description"
+from my_form_fields;
+
+
+

./user_creation.sql

+

https://example.com/user_creation.sql

+

Who made it ?

+
+
+
    +
  • Ophir Lojkine
  • +
  • Free Software developer
  • +
  • CTO @hyxos in Paris
  • +
+
+
+
+

@ophir_dev

+
+
+

@lovasoa

+
+
+
+
    +
  • Engineering degrees from 🇫🇷 & 🇷🇺
  • +
  • Ex-data engineer @Qwant
  • +
  • Father next month
  • +
+
+

Who made it ?

+
+
+
    +
  • Hyxos
  • +
  • Startup
  • +
  • Energy infrastructure management software
  • +
  • Lots of projects
  • +
+
+
+ + + + + + + + + + + + + + + + + +
+ + + + + + + +
+
+ +
+
+
+ +
+ +
+
+ +
+
+
+

uses it in production ?

+

isn't this a stupid idea ?

+

does it do exactly ?

+

to build a website in a day ?

+

let's build twitter together

+

of the coolest features

+

Build Websites in SQL

+
+
+

Seriously ?

+
+
+
+
+
+
+
+

Build Websites in SQL

+
+
+

Seriously ?

+
+ + + + + +
+

Your scientists were so preoccupied with whether they could, they didn’t stop to think if they should

+
+

Why ?

+
+ + + + + + + + + + + + + +
+

We have a problem

+
+

1 programmer

+
+

empty seats

+

McKinsey, 2023: for 10 Java job postings, there is <1 skilled profile online

+
+
+
+
+
+
+
+
+
+
+

10

+

Why ?

+
+ + + + + + + + + + + + + +
+

We have a problem

+
+

non-programmers will write apps

+
+

some apps will never materialize

+
+
+

programmers will write more apps

+

Why ?

+
+ + + + + + + + + + + + + +
+

We have a problem

+
+

non-programmers will write apps

+
+
+

programmers will write more apps

+

Let's make their lives easier...

+

Why ?

+
+ + +
+

Simplicity

+

🌱

+
+

Efficiency

+

🕒

+
+

Expressiveness

+

🧠

+
+

Reusability

+

🔄

+

+How to put your idea online in 2024 ? +

+
+ + +
+

Drupal

+
+

WordPress

+
+

Shopify

+
+

Squarespace

+
+

Django

+
+

Rails

+
+

Laravel

+
+

Spring Boot

+
+

Budibase

+
+

AppSmith

+
+

React

+
+

SvelteKit

+
+
+
+
+

CMS & Website Builders

+

Backend Frameworks

+

Low-Code app builders

+

Front-end frameworks

+ + +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
+
+

Web Site

+

Web App

+

Beginner Friendly

+

Customizable

+
+
+

+How to put your idea online in 2024 ? +

+
+

How to build a website in 2024 ?

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +

Web Site

+

Web App

+

Beginner Friendly

+

Customizable

+
+

Everything loads instantly

+
+

User-Generated

+ +

content

+
+
+

Accessible to non-programmers

+

Connects to any database

+

How to build a website in 2024 ?

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+
+

Easy

+

Beautiful

+

Fast

+

Build your site in a weekend

+

I write the CSS, not you

+

Rust, Lighthouse 100, scales to

+

Build Websites in SQL

+
+
+

Seriously ?

+
+ + + + + +
+

YES !

+
+
+
+
+
+

Build Websites in SQL

+
+
+

Seriously ?

+
+ + + + + +
+

YES !

+
+
+
+
+
+ + + +
+

What ?

+

What is SQLPage ?
+Let's dive in !

+
+
+ + + +
+

What ?

+
+

An

+ +

open-source

+ +

low-code

+ +

SQL-only

+ +

web application server.

+ +

 

+ +

 

+ +

License: MIT

+ +

Language: Rust

+ +


+Dependencies: None

+
+
+
+ + + + + + + + + + +

SQLPage is ...

+ + + +
+
+

... written in Rust

+

A web server

+
+ + +
+
+

reusable HTML

+

Components

+
+
+
+

Database connections

+

Drivers

+
+ +
+
+
+ + + + + + + + + + +

SQLPage is ...

+ + + + + + + + + + + +
+ + + + + + + + + + +

Core principles

+ + + + + + + + + + + +
+

Web Page

+

SQL file

+

1

+

1

+ +
+ +
+ +
+
+
+
+
+

3 components

+ +
+
+
+

tab

+

component

+
+ + +
+
+
+

tab

+

component

+
+ + + + + + + + +
componentcenter
tabtrue
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
titlelinkactivecolor
Show all cards?falseNULL
Show blue cards?tab=bluefalseNULL
Show green cards?tab=greentruegreen
Show red cards?tab=redfalseNULL
+

Opening component, top-level properties

+

Filling the component, row-level properties

+
+ + +
+
+
+

card

+

component

+ + + +
+ + +
+
+
+

card

+

component

+
+ + + + + + + + +
component
card
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
titlelinkdescriptioncolortop_image
Leafhttps://.../Autumn's dance begins, [...]greenleaf.jpg
Caterpillarhttps://.../Caterpillar crawls [...]greencaterpillar.jpg
Butterflyhttps://.../Cocoon unfolds wings [...]greenbutterfly.jpg
+

Opening component

+

Filling the component, row-level properties

+
+ + +
+
+
+

text

+

component

+ + + +
+ + +
+
+
+

text

+

component

+
+ + + + + + + + + + +
componentcontents_md
textSee [source code on GitHub](https://github.com/.../tabs.sql)
+ +

Opening component

+
+

Let's build

+
+ +
+

TinyTweeter

+
+
+
+
+
+
CREATE TABLE
+tweets (
+  "id" bigserial,
+  "tweet" text
+);
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
select 'shell' as component;
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
select 'shell' as component,
+       'TinyTweeter' as title;
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
select 'shell' as component,
+       'TinyTweeter' as title;
+
+select 'form' as component;
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
select 'shell' as component,
+       'TinyTweeter' as title;
+
+select 'form' as component,
+       'Tweet' as validate;
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
select 'shell' as component,
+       'TinyTweeter' as title;
+
+select 'form' as component,
+       'Tweet' as validate;
+select 
+    'Tell me your story...'
+    as placeholder;
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
select 'shell' as component,
+       'TinyTweeter' as title;
+
+select 'form' as component,
+       'Tweet' as validate;
+select 'textarea' as type,
+       'Tell me your story...'
+       as placeholder;
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
select 'shell' as component,
+       'TinyTweeter' as title;
+
+select 'form' as component,
+       'Tweet' as validate;
+select 'textarea' as type,
+       'Tell me your story...' as placeholder;
+select 'checkbox' as type,
+       'Terms and conditions'
+       as label;
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
select 'shell' as component,
+       'TinyTweeter' as title;
+
+select 'form' as component,
+       'Tweet' as validate;
+select 'new_tweet' as name,
+       'Your story' as label,
+       'textarea' as type,
+       'Tell me your story...' as placeholder;
+select 'checkbox' as type,
+       'Terms and conditions' as label,
+       true as required;
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
select 'shell' as component,
+       'TinyTweeter' as title;
+
+select 'form' as component,
+       'Tweet' as validate;
+select 'new_tweet' as name,
+       'Your story' as label,
+       'textarea' as type,
+       'Tell me your story...' as placeholder;
+select 'checkbox' as type,
+       'Terms and conditions' as label,
+       true as required;
+       
+insert into tweets (tweet)
+select :new_tweet;
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
select 'shell' as component,
+       'TinyTweeter' as title;
+
+select 'form' as component,
+       'Tweet' as validate;
+select 'new_tweet' as name,
+       'Your story' as label,
+       'textarea' as type,
+       'Tell me your story...' as placeholder;
+select 'checkbox' as type,
+       'Terms and conditions' as label,
+       true as required;
+       
+insert into tweets (tweet)
+select :new_tweet
+where :new_tweet is not null;
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
select 'shell' as component,
+       'TinyTweeter' as title;
+
+select 'form' as component,
+       'Tweet' as validate;
+select 'new_tweet' as name,
+       'Your story' as label,
+       'textarea' as type,
+       'Tell me your story...' as placeholder;
+select 'checkbox' as type,
+       'Terms and conditions' as label,
+       true as required;
+       
+insert into tweets (tweet)
+select :new_tweet
+where :new_tweet is not null;
+
+select 'card' as component;
+select tweet as description
+from tweets;
+
+
+

SQLPage in Action

+
+
+
+

Examples of SQLPage in use

+
+
+

SQLPage in Action

+
+
+
+

What are people doing with SQLPage ?

+
+
+
+

Helping

+ +

disabled

+ +

pupils

+
+
+
+
+

Mapping

+ +

archeological

+ +

artefacts

+
+
+
+

Digitalization

+ +

in

+ +

South Africa

+
+
+
+
+

SQLPage in Action

+
+
+
+

What are people doing with SQLPage ?

+
+
+
+

Helping

+ +

disabled

+ +

pupils

+
+
+
+

SQLPage in Action

+
+
+

Helping disabled pupils with SQLPage

+
+

David

+
+
+

Coordinator,

+ +

Inclusive Localized Support Hub

+
+

Coordinating human support resources based on the needs of students with disabilities

+

No past experience with SQL

+

Looking for a tool to replace an excel spreadsheet

+
+

SQLPage in Action

+
+

David

+
+
+
+

SQLPage in Action

+
+

David

+
+
+
+

SQLPage in Action

+
+

David

+
+
+
+
+

SQLPage in Action

+
+
+
+

What are people doing with SQLPage ?

+
+
+

Mapping archaeological artefacts

+
+
+

SQLPage in Action

+
+
+

Mapping archaeological artefacts

+
+

Florent

+
+
+

Responsible for Protohistoric Operations,

+ +

National Institute of Preventive Archaeological Research (INRAP)

+
+

Public institution in France that is responsible for conducting archaeological research and excavations

+

Geographic Information System Coordinator

+

RAMEN collective
+Recherches Archéologiques en Modélisation de l'Enregistrement Numérique

+
+
+

BADASS database

+ +

Base archéologique de données attributaires et spatiales

+
+
+
+
+

SQLPage in Action

+
+

Florent

+
+
+
+

SQLPage in Action

+
+

Florent

+
+
+
+

SQLPage in Action

+
+

Florent

+
+
+
+
+

SQLPage in Action

+
+
+
+

What are people doing with SQLPage ?

+
+
+
+

Digitalization

+ +

in

+ +

Africa

+
+
+
+

SQLPage in Action

+
+
+

From call center to building the company's next app

+
+
+
+

William

+
+

 IT support

+

Building a tool to manage clients and processes

+

Started at the company's call center

+

Promoted to IT support

+

Now works on SQLPage most of the day

+

Programming experience in Delphi

+

SQLPage in Action

+
+
+

From call center to building the company's next app

+
+
+
+

William

+
+
+

I don’t think I’ll use anything else for frontend.

+
+
+

I’d like to use it for personal projects as well: I can make extra income, it’s very efficient !

+
+
+

It’s kind of a big thing. We launch in January.

+
+

Examples

+
+
+

Cool features

+
+

Examples

+
+
+

Build an API in 45 seconds

+
+
SELECT
+  'json' AS component, 
+   JSON_OBJECT(
+     'users', (
+       SELECT JSON_GROUP_ARRAY(
+       JSON_OBJECT(
+         'username', username,
+         'userid', id
+       )
+      ) FROM users
+    )
+  ) AS contents;
+
+
api.sql
+
{ 
+  "users" : [
+    {
+      "username": "Mary",
+      "userid": 128448
+    },
+    {
+      "username": "Bob",
+      "userid": 479326
+    }
+  ]
+}
+
+
http://localhost/api.sql
+
docker run -p 80:8080 -v .:/var/www sqlpage/SQLPage
+

Examples

+
+
+

Create user account in 1 minute

+
+
INSERT INTO
+  user_info (
+    username,
+    password_hash
+  )
+VALUES (
+	:username,
+    sqlpage.hash_password(:password)
+);
+
signup.sql
+
postgres://localhost/sqlpage
+
+ + + + + + + + +
usernamepassword_hash
John$argon2id$v=19$m=16,t=2,p=1$TERTd0lIcUpraWFTcmRQYw$+bjtag7Xjb6p1dsuYOkngw
+
SELECT
+  'authentication' AS component,
+  'signin.sql?error' AS link,
+  ( SELECT password_hash
+    FROM user_info
+    WHERE username = :username
+  ) AS password_hash,
+  :password AS password;
+
login.sql
+

Questions ?

+
+
+

Leave a star on github !

+
+ + + + + +
+

sqlpage/SQLPage

+
+

sql-page.com

+
+

Visit the website

+
+
+
+ + + diff --git a/examples/official-site/pgconf/script.js b/examples/official-site/pgconf/script.js new file mode 100644 index 0000000..8ca0ac2 --- /dev/null +++ b/examples/official-site/pgconf/script.js @@ -0,0 +1,109 @@ + + + var SLConfig = {"current_user":{"id":2571881,"username":"jegac15681","name":null,"description":null,"thumbnail_url":"https://www.gravatar.com/avatar/2bfb8d6543c8b1813f8df1821e3d02fe?s=140\u0026d=https%3A%2F%2Fstatic.slid.es%2Fimages%2Fdefault-profile-picture.png","account_type":"default","team_id":null,"settings":{"id":57170066,"present_controls":true,"present_upsizing":true,"present_pointer":true,"present_notes":true,"default_deck_tag_id":null,"editor_grid":true,"editor_grid_on_top":false,"editor_snap":true,"editor_fixed_notes":false,"developer_mode":true,"speaker_layout":null,"speaker_theme":null,"phone_number":null,"phone_country_code":null,"media_sources":null,"export_controls":null,"export_slide_number":null,"export_slide_notes":null,"export_separate_fragments":null,"auto_animate_tutorial_completed":true,"profile_sorting":null,"profile_layout":null},"email":"jegac15681@rdluxe.com","notify_on_receipt":true,"billing_address":null,"billing_vat_id":null,"editor_tutorial_completed":true,"manually_upgraded":false,"deck_user_editor_limit":null,"storage_used":7309625,"storage_limit":262144000,"image_upload_limit":10485760,"video_upload_limit":104857600},"deck":{"id":3057920,"slug":"sqlpage-pgconf","title":"SQLPage","description":"","width":960,"height":700,"margin":0.05,"visibility":"all","published_at":"2023-11-12T00:00:11.375Z","sanitize_messages":null,"thumbnail_url":"https://s3.amazonaws.com/media-p.slid.es/thumbnails/c9640053c48e6426e3a741cce978120f/thumb.jpg?1702287516","view_count":0,"user":{"id":2571881,"username":"jegac15681","name":null,"description":null,"thumbnail_url":"https://www.gravatar.com/avatar/2bfb8d6543c8b1813f8df1821e3d02fe?s=140\u0026d=https%3A%2F%2Fstatic.slid.es%2Fimages%2Fdefault-profile-picture.png","account_type":"default","team_id":null,"settings":{"id":57170066,"present_controls":true,"present_upsizing":true,"present_pointer":true,"present_notes":true,"default_deck_tag_id":null}},"background_transition":"slide","transition":"slide","theme_id":null,"theme_font":"montserrat","theme_color":"black-blue","auto_slide_interval":0,"comments_enabled":false,"forking_enabled":true,"rolling_links":false,"center":false,"shuffle":false,"should_loop":false,"share_notes":false,"slide_number":false,"slide_count":73,"rtl":false,"version":2,"collaborative":false,"deck_user_editor_limit":null,"data_updated_at":1702502378510,"font_typekit":null,"font_google":null,"time_limit":null,"navigation_mode":"default","upsizing_enabled":true,"language":"en","notes":{}},"user":{"id":2571881,"username":"jegac15681","name":null,"description":null,"thumbnail_url":"https://www.gravatar.com/avatar/2bfb8d6543c8b1813f8df1821e3d02fe?s=140\u0026d=https%3A%2F%2Fstatic.slid.es%2Fimages%2Fdefault-profile-picture.png","account_type":"default","team_id":null,"settings":{"id":57170066,"present_controls":true,"present_upsizing":true,"present_pointer":true,"present_notes":true,"default_deck_tag_id":null}}}; + + + + !function(e){function t(e,t,r,n,i){this._listener=t,this._isOnce=r,this.context=n,this._signal=e,this._priority=i||0}function r(e,t){if("function"!=typeof e)throw new Error("listener is a required param of {fn}() and should be a Function.".replace("{fn}",t))}function n(){this._bindings=[],this._prevParams=null;var e=this;this.dispatch=function(){n.prototype.dispatch.apply(e,arguments)}}t.prototype={active:!0,params:null,execute:function(e){var t,r;return this.active&&this._listener&&(r=this.params?this.params.concat(e):e,t=this._listener.apply(this.context,r),this._isOnce&&this.detach()),t},detach:function(){return this.isBound()?this._signal.remove(this._listener,this.context):null},isBound:function(){return!!this._signal&&!!this._listener},isOnce:function(){return this._isOnce},getListener:function(){return this._listener},getSignal:function(){return this._signal},_destroy:function(){delete this._signal,delete this._listener,delete this.context},toString:function(){return"[SignalBinding isOnce:"+this._isOnce+", isBound:"+this.isBound()+", active:"+this.active+"]"}},n.prototype={VERSION:"1.0.0",memorize:!1,_shouldPropagate:!0,active:!0,_registerListener:function(e,r,n,i){var a,o=this._indexOfListener(e,n);if(-1!==o){if((a=this._bindings[o]).isOnce()!==r)throw new Error("You cannot add"+(r?"":"Once")+"() then add"+(r?"Once":"")+"() the same listener without removing the relationship first.")}else a=new t(this,e,r,n,i),this._addBinding(a);return this.memorize&&this._prevParams&&a.execute(this._prevParams),a},_addBinding:function(e){var t=this._bindings.length;do{--t}while(this._bindings[t]&&e._priority<=this._bindings[t]._priority);this._bindings.splice(t+1,0,e)},_indexOfListener:function(e,t){for(var r,n=this._bindings.length;n--;)if((r=this._bindings[n])._listener===e&&r.context===t)return n;return-1},has:function(e,t){return-1!==this._indexOfListener(e,t)},add:function(e,t,n){return r(e,"add"),this._registerListener(e,!1,t,n)},addOnce:function(e,t,n){return r(e,"addOnce"),this._registerListener(e,!0,t,n)},remove:function(e,t){if(!this._bindings)return null;r(e,"remove");var n=this._indexOfListener(e,t);return-1!==n&&(this._bindings[n]._destroy(),this._bindings.splice(n,1)),e},removeAll:function(){for(var e=this._bindings.length;e--;)this._bindings[e]._destroy();this._bindings.length=0},getNumListeners:function(){return this._bindings.length},halt:function(){this._shouldPropagate=!1},dispatch:function(){if(this.active){var e,t=Array.prototype.slice.call(arguments),r=this._bindings.length;if(this.memorize&&(this._prevParams=t),r){e=this._bindings.slice(),this._shouldPropagate=!0;do{r--}while(e[r]&&this._shouldPropagate&&!1!==e[r].execute(t))}}},forget:function(){this._prevParams=null},dispose:function(){this.removeAll(),delete this._bindings,delete this._prevParams},toString:function(){return"[Signal active:"+this.active+" numListeners:"+this.getNumListeners()+"]"}};var i=n;i.Signal=n,"function"==typeof define&&define.amd?define(function(){return i}):"undefined"!=typeof module&&module.exports?module.exports=i:e.signals=i}(this),function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.katex=t():e.katex=t()}("undefined"!=typeof self?self:this,function(){return function(){"use strict";function e(e){if(e["default"])return e["default"];var t=e.type,r=Array.isArray(t)?t[0]:t;if("string"!=typeof r)return r["enum"][0];switch(r){case"boolean":return!1;case"string":return"";case"number":return 0;case"object":return{}}}function t(e){for(var t=0;t=Y[t]&&e<=Y[t+1])return!0;return!1}function r(e,r,n){if(!Z[r])throw new Error("Font metrics not found for font: "+r+".");var i=e.charCodeAt(0),a=Z[r][i];if(!a&&e[0]in Q&&(i=Q[e[0]].charCodeAt(0),a=Z[r][i]),a||"text"!==n||t(i)&&(a=Z[r][77]),a)return{depth:a[0],height:a[1],italic:a[2],skew:a[3],width:a[4]}}function n(e){if(e instanceof be)return e;throw new Error("Expected symbolNode but got "+String(e)+".")}function i(e,t,r,n,i,a){Me[e][i]={font:t,group:r,replace:n},a&&n&&(Me[e][n]=Me[e][i])}function a(e){for(var t=e.type,r=e.names,n=e.props,i=e.handler,a=e.htmlBuilder,o=e.mathmlBuilder,s={type:t,numArgs:n.numArgs,argTypes:n.argTypes,allowedInArgument:!!n.allowedInArgument,allowedInText:!!n.allowedInText,allowedInMath:void 0===n.allowedInMath||n.allowedInMath,numOptionalArgs:n.numOptionalArgs||0,infix:!!n.infix,primitive:!!n.primitive,handler:i},l=0;l0&&(o.push(s(l,t)),l=[]),o.push(i[h]));l.length>0&&o.push(s(l,t)),r?((a=s(It(r,t,!0))).classes=["tag"],o.push(a)):n&&o.push(n);var u=Bt(["katex-html"],o);if(u.setAttribute("aria-hidden","true"),a){var m=a.children[0];m.style.height=ce(u.height+u.depth),u.depth&&(m.style.verticalAlign=ce(-u.depth))}return u}function h(e){return new K(e)}function c(e,t,r,n,i){var a,o=Yt(e,r);a=1===o.length&&o[0]instanceof _t&&I.contains(["mrow","mtable"],o[0].type)?o[0]:new Gt.MathNode("mrow",o);var s=new Gt.MathNode("annotation",[new Gt.TextNode(t)]);s.setAttribute("encoding","application/x-tex");var l=new Gt.MathNode("semantics",[a,s]),h=new Gt.MathNode("math",[l]);h.setAttribute("xmlns","http://www.w3.org/1998/Math/MathML"),n&&h.setAttribute("display","block");var c=i?"katex":"katex-mathml";return yt.makeSpan([c],[h])}function u(e,t){if(!e||e.type!==t)throw new Error("Expected node of type "+t+", but got "+(e?"node of type "+e.type:String(e)));return e}function m(e){var t=d(e);if(!t)throw new Error("Expected node of symbol group type, but got "+(e?"node of type "+e.type:String(e)));return t}function d(e){return e&&("atom"===e.type||Ae.hasOwnProperty(e.type))?e:null}function p(e,t){var r=It(e.body,t,!0);return lr([e.mclass],r,t)}function f(e,t){var r,n=Yt(e.body,t);return"minner"===e.mclass?r=new Gt.MathNode("mpadded",n):"mord"===e.mclass?e.isCharacterBox?(r=n[0]).type="mi":r=new Gt.MathNode("mi",n):(e.isCharacterBox?(r=n[0]).type="mo":r=new Gt.MathNode("mo",n),"mbin"===e.mclass?(r.attributes.lspace="0.22em",r.attributes.rspace="0.22em"):"mpunct"===e.mclass?(r.attributes.lspace="0em",r.attributes.rspace="0.17em"):"mopen"===e.mclass||"mclose"===e.mclass?(r.attributes.lspace="0em",r.attributes.rspace="0em"):"minner"===e.mclass&&(r.attributes.lspace="0.0556em",r.attributes.width="+0.1111em")),r}function g(e,t,r){var n=cr[e];switch(n){case"\\\\cdrightarrow":case"\\\\cdleftarrow":return r.callFunction(n,[t[0]],[t[1]]);case"\\uparrow":case"\\downarrow":var i={type:"atom",text:n,mode:"math",family:"rel"},a={type:"ordgroup",mode:"math",body:[r.callFunction("\\\\cdleft",[t[0]],[]),r.callFunction("\\Big",[i],[]),r.callFunction("\\\\cdright",[t[1]],[])]};return r.callFunction("\\\\cdparent",[a],[]);case"\\\\cdlongequal":return r.callFunction("\\\\cdlongequal",[],[]);case"\\Vert":return r.callFunction("\\Big",[{type:"textord",text:"\\Vert",mode:"math"}],[]);default:return{type:"textord",text:" ",mode:"math"}}}function v(e,t){var r=d(e);if(r&&I.contains(_r,r.text))return r;throw new B(r?"Invalid delimiter '"+r.text+"' after '"+t.funcName+"'":"Invalid delimiter type '"+e.type+"'",e)}function y(e){if(!e.body)throw new Error("Bug: The leftright ParseNode wasn't fully parsed.")}function b(e){for(var t=e.type,r=e.names,n=e.props,i=e.handler,a=e.htmlBuilder,o=e.mathmlBuilder,s={type:t,numArgs:n.numArgs||0,allowedInText:!1,numOptionalArgs:0,handler:i},l=0;l1||!m)&&v.pop(),b.length15?"\u2026"+o.slice(r-15,r):o.slice(0,r))+s+(n+15":">","<":"<",'"':""","'":"'"},q=/[&><"']/g,E=function ai(e){return"ordgroup"===e.type||"color"===e.type?1===e.body.length?ai(e.body[0]):e:"font"===e.type?ai(e.body):e},I={contains:function(e,t){return-1!==e.indexOf(t)},deflt:function(e,t){return void 0===e?t:e},escape:function(e){return String(e).replace(q,function(e){return N[e]})},hyphenate:function(e){return e.replace(L,"-$1").toLowerCase()},getBaseElem:E,isCharacterBox:function(e){var t=E(e);return"mathord"===t.type||"textord"===t.type||"atom"===t.type},protocolFromUrl:function(e){var t=/^\s*([^\\/#]*?)(?::|�*58|�*3a)/i.exec(e);return null!=t?t[1]:"_relative"}},O={displayMode:{type:"boolean",description:"Render math in display mode, which puts the math in display style (so \\int and \\sum are large, for example), and centers the math on the page on its own line.",cli:"-d, --display-mode"},output:{type:{"enum":["htmlAndMathml","html","mathml"]},description:"Determines the markup language of the output.",cli:"-F, --format "},leqno:{type:"boolean",description:"Render display math in leqno style (left-justified tags)."},fleqn:{type:"boolean",description:"Render display math flush left."},throwOnError:{type:"boolean","default":!0,cli:"-t, --no-throw-on-error",cliDescription:"Render errors (in the color given by --error-color) instead of throwing a ParseError exception when encountering an error."},errorColor:{type:"string","default":"#cc0000",cli:"-c, --error-color ",cliDescription:"A color string given in the format 'rgb' or 'rrggbb' (no #). This option determines the color of errors rendered by the -t option.",cliProcessor:function(e){return"#"+e}},macros:{type:"object",cli:"-m, --macro ",cliDescription:"Define custom macro of the form '\\foo:expansion' (use multiple -m arguments for multiple macros).",cliDefault:[],cliProcessor:function(e,t){return t.push(e),t}},minRuleThickness:{type:"number",description:"Specifies a minimum thickness, in ems, for fraction lines, `\\sqrt` top lines, `{array}` vertical lines, `\\hline`, `\\hdashline`, `\\underline`, `\\overline`, and the borders of `\\fbox`, `\\boxed`, and `\\fcolorbox`.",processor:function(e){return Math.max(0,e)},cli:"--min-rule-thickness ",cliProcessor:parseFloat},colorIsTextColor:{type:"boolean",description:"Makes \\color behave like LaTeX's 2-argument \\textcolor, instead of LaTeX's one-argument \\color mode change.",cli:"-b, --color-is-text-color"},strict:{type:[{"enum":["warn","ignore","error"]},"boolean","function"],description:"Turn on strict / LaTeX faithfulness mode, which throws an error if the input uses features that are not supported by LaTeX.",cli:"-S, --strict",cliDefault:!1},trust:{type:["boolean","function"],description:"Trust the input, enabling all HTML features such as \\url.",cli:"-T, --trust"},maxSize:{type:"number","default":1/0,description:"If non-zero, all user-specified sizes, e.g. in \\rule{500em}{500em}, will be capped to maxSize ems. Otherwise, elements and spaces can be arbitrarily large",processor:function(e){return Math.max(0,e)},cli:"-s, --max-size ",cliProcessor:parseInt},maxExpand:{type:"number","default":1e3,description:"Limit the number of macro expansions to the specified number, to prevent e.g. infinite macro loops. If set to Infinity, the macro expander will try to fully expand as in LaTeX.",processor:function(e){return Math.max(0,e)},cli:"-e, --max-expand ",cliProcessor:function(e){return"Infinity"===e?1/0:parseInt(e)}},globalGroup:{type:"boolean",cli:!1}},R=function(){function t(t){for(var r in this.displayMode=void 0,this.output=void 0,this.leqno=void 0,this.fleqn=void 0,this.throwOnError=void 0,this.errorColor=void 0,this.macros=void 0,this.minRuleThickness=void 0,this.colorIsTextColor=void 0,this.strict=void 0,this.trust=void 0,this.maxSize=void 0,this.maxExpand=void 0,this.globalGroup=void 0,t=t||{},O)if(O.hasOwnProperty(r)){var n=O[r];this[r]=void 0!==t[r]?n.processor?n.processor(t[r]):t[r]:e(n)}}var r=t.prototype;return r.reportNonstrict=function(e,t,r){var n=this.strict;if("function"==typeof n&&(n=n(e,t,r)),n&&"ignore"!==n){if(!0===n||"error"===n)throw new B("LaTeX-incompatible input and strict mode is set to 'error': "+t+" ["+e+"]",r);"warn"===n?"undefined"!=typeof console&&console.warn("LaTeX-incompatible input and strict mode is set to 'warn': "+t+" ["+e+"]"):"undefined"!=typeof console&&console.warn("LaTeX-incompatible input and strict mode is set to unrecognized '"+n+"': "+t+" ["+e+"]")}},r.useStrictBehavior=function(e,t,r){var n=this.strict;if("function"==typeof n)try{n=n(e,t,r)}catch(e){n="error"}return!(!n||"ignore"===n||!0!==n&&"error"!==n&&("warn"===n?("undefined"!=typeof console&&console.warn("LaTeX-incompatible input and strict mode is set to 'warn': "+t+" ["+e+"]"),1):("undefined"!=typeof console&&console.warn("LaTeX-incompatible input and strict mode is set to unrecognized '"+n+"': "+t+" ["+e+"]"),1)))},r.isTrusted=function(e){e.url&&!e.protocol&&(e.protocol=I.protocolFromUrl(e.url));var t="function"==typeof this.trust?this.trust(e):this.trust;return Boolean(t)},t}(),H=function(){function e(e,t,r){this.id=void 0,this.size=void 0,this.cramped=void 0,this.id=e,this.size=t,this.cramped=r}var t=e.prototype;return t.sup=function(){return D[P[this.id]]},t.sub=function(){return D[F[this.id]]},t.fracNum=function(){return D[_[this.id]]},t.fracDen=function(){return D[V[this.id]]},t.cramp=function(){return D[G[this.id]]},t.text=function(){return D[j[this.id]]},t.isTight=function(){return this.size>=2},e}(),D=[new H(0,0,!1),new H(1,0,!0),new H(2,1,!1),new H(3,1,!0),new H(4,2,!1),new H(5,2,!0),new H(6,3,!1),new H(7,3,!0)],P=[4,5,4,5,6,7,6,7],F=[5,5,5,5,7,7,7,7],_=[2,3,4,5,6,7,6,7],V=[3,3,5,5,7,7,7,7],G=[1,1,3,3,5,5,7,7],j=[0,1,2,3,2,3,2,3],U={DISPLAY:D[0],TEXT:D[2],SCRIPT:D[4],SCRIPTSCRIPT:D[6]},W=[{name:"latin",blocks:[[256,591],[768,879]]},{name:"cyrillic",blocks:[[1024,1279]]},{name:"armenian",blocks:[[1328,1423]]},{name:"brahmic",blocks:[[2304,4255]]},{name:"georgian",blocks:[[4256,4351]]},{name:"cjk",blocks:[[12288,12543],[19968,40879],[65280,65376]]},{name:"hangul",blocks:[[44032,55215]]}],Y=[];W.forEach(function(e){return e.blocks.forEach(function(e){return Y.push.apply(Y,e)})});var X=80,$={doubleleftarrow:"M262 157\nl10-10c34-36 62.7-77 86-123 3.3-8 5-13.3 5-16 0-5.3-6.7-8-20-8-7.3\n 0-12.2.5-14.5 1.5-2.3 1-4.8 4.5-7.5 10.5-49.3 97.3-121.7 169.3-217 216-28\n 14-57.3 25-88 33-6.7 2-11 3.8-13 5.5-2 1.7-3 4.2-3 7.5s1 5.8 3 7.5\nc2 1.7 6.3 3.5 13 5.5 68 17.3 128.2 47.8 180.5 91.5 52.3 43.7 93.8 96.2 124.5\n 157.5 9.3 8 15.3 12.3 18 13h6c12-.7 18-4 18-10 0-2-1.7-7-5-15-23.3-46-52-87\n-86-123l-10-10h399738v-40H218c328 0 0 0 0 0l-10-8c-26.7-20-65.7-43-117-69 2.7\n-2 6-3.7 10-5 36.7-16 72.3-37.3 107-64l10-8h399782v-40z\nm8 0v40h399730v-40zm0 194v40h399730v-40z",doublerightarrow:"M399738 392l\n-10 10c-34 36-62.7 77-86 123-3.3 8-5 13.3-5 16 0 5.3 6.7 8 20 8 7.3 0 12.2-.5\n 14.5-1.5 2.3-1 4.8-4.5 7.5-10.5 49.3-97.3 121.7-169.3 217-216 28-14 57.3-25 88\n-33 6.7-2 11-3.8 13-5.5 2-1.7 3-4.2 3-7.5s-1-5.8-3-7.5c-2-1.7-6.3-3.5-13-5.5-68\n-17.3-128.2-47.8-180.5-91.5-52.3-43.7-93.8-96.2-124.5-157.5-9.3-8-15.3-12.3-18\n-13h-6c-12 .7-18 4-18 10 0 2 1.7 7 5 15 23.3 46 52 87 86 123l10 10H0v40h399782\nc-328 0 0 0 0 0l10 8c26.7 20 65.7 43 117 69-2.7 2-6 3.7-10 5-36.7 16-72.3 37.3\n-107 64l-10 8H0v40zM0 157v40h399730v-40zm0 194v40h399730v-40z",leftarrow:"M400000 241H110l3-3c68.7-52.7 113.7-120\n 135-202 4-14.7 6-23 6-25 0-7.3-7-11-21-11-8 0-13.2.8-15.5 2.5-2.3 1.7-4.2 5.8\n-5.5 12.5-1.3 4.7-2.7 10.3-4 17-12 48.7-34.8 92-68.5 130S65.3 228.3 18 247\nc-10 4-16 7.7-18 11 0 8.7 6 14.3 18 17 47.3 18.7 87.8 47 121.5 85S196 441.3 208\n 490c.7 2 1.3 5 2 9s1.2 6.7 1.5 8c.3 1.3 1 3.3 2 6s2.2 4.5 3.5 5.5c1.3 1 3.3\n 1.8 6 2.5s6 1 10 1c14 0 21-3.7 21-11 0-2-2-10.3-6-25-20-79.3-65-146.7-135-202\n l-3-3h399890zM100 241v40h399900v-40z",leftbrace:"M6 548l-6-6v-35l6-11c56-104 135.3-181.3 238-232 57.3-28.7 117\n-45 179-50h399577v120H403c-43.3 7-81 15-113 26-100.7 33-179.7 91-237 174-2.7\n 5-6 9-10 13-.7 1-7.3 1-20 1H6z",leftbraceunder:"M0 6l6-6h17c12.688 0 19.313.3 20 1 4 4 7.313 8.3 10 13\n 35.313 51.3 80.813 93.8 136.5 127.5 55.688 33.7 117.188 55.8 184.5 66.5.688\n 0 2 .3 4 1 18.688 2.7 76 4.3 172 5h399450v120H429l-6-1c-124.688-8-235-61.7\n-331-161C60.687 138.7 32.312 99.3 7 54L0 41V6z",leftgroup:"M400000 80\nH435C64 80 168.3 229.4 21 260c-5.9 1.2-18 0-18 0-2 0-3-1-3-3v-38C76 61 257 0\n 435 0h399565z",leftgroupunder:"M400000 262\nH435C64 262 168.3 112.6 21 82c-5.9-1.2-18 0-18 0-2 0-3 1-3 3v38c76 158 257 219\n 435 219h399565z",leftharpoon:"M0 267c.7 5.3 3 10 7 14h399993v-40H93c3.3\n-3.3 10.2-9.5 20.5-18.5s17.8-15.8 22.5-20.5c50.7-52 88-110.3 112-175 4-11.3 5\n-18.3 3-21-1.3-4-7.3-6-18-6-8 0-13 .7-15 2s-4.7 6.7-8 16c-42 98.7-107.3 174.7\n-196 228-6.7 4.7-10.7 8-12 10-1.3 2-2 5.7-2 11zm100-26v40h399900v-40z",leftharpoonplus:"M0 267c.7 5.3 3 10 7 14h399993v-40H93c3.3-3.3 10.2-9.5\n 20.5-18.5s17.8-15.8 22.5-20.5c50.7-52 88-110.3 112-175 4-11.3 5-18.3 3-21-1.3\n-4-7.3-6-18-6-8 0-13 .7-15 2s-4.7 6.7-8 16c-42 98.7-107.3 174.7-196 228-6.7 4.7\n-10.7 8-12 10-1.3 2-2 5.7-2 11zm100-26v40h399900v-40zM0 435v40h400000v-40z\nm0 0v40h400000v-40z",leftharpoondown:"M7 241c-4 4-6.333 8.667-7 14 0 5.333.667 9 2 11s5.333\n 5.333 12 10c90.667 54 156 130 196 228 3.333 10.667 6.333 16.333 9 17 2 .667 5\n 1 9 1h5c10.667 0 16.667-2 18-6 2-2.667 1-9.667-3-21-32-87.333-82.667-157.667\n-152-211l-3-3h399907v-40zM93 281 H400000 v-40L7 241z",leftharpoondownplus:"M7 435c-4 4-6.3 8.7-7 14 0 5.3.7 9 2 11s5.3 5.3 12\n 10c90.7 54 156 130 196 228 3.3 10.7 6.3 16.3 9 17 2 .7 5 1 9 1h5c10.7 0 16.7\n-2 18-6 2-2.7 1-9.7-3-21-32-87.3-82.7-157.7-152-211l-3-3h399907v-40H7zm93 0\nv40h399900v-40zM0 241v40h399900v-40zm0 0v40h399900v-40z",lefthook:"M400000 281 H103s-33-11.2-61-33.5S0 197.3 0 164s14.2-61.2 42.5\n-83.5C70.8 58.2 104 47 142 47 c16.7 0 25 6.7 25 20 0 12-8.7 18.7-26 20-40 3.3\n-68.7 15.7-86 37-10 12-15 25.3-15 40 0 22.7 9.8 40.7 29.5 54 19.7 13.3 43.5 21\n 71.5 23h399859zM103 281v-40h399897v40z",leftlinesegment:"M40 281 V428 H0 V94 H40 V241 H400000 v40z\nM40 281 V428 H0 V94 H40 V241 H400000 v40z",leftmapsto:"M40 281 V448H0V74H40V241H400000v40z\nM40 281 V448H0V74H40V241H400000v40z",leftToFrom:"M0 147h400000v40H0zm0 214c68 40 115.7 95.7 143 167h22c15.3 0 23\n-.3 23-1 0-1.3-5.3-13.7-16-37-18-35.3-41.3-69-70-101l-7-8h399905v-40H95l7-8\nc28.7-32 52-65.7 70-101 10.7-23.3 16-35.7 16-37 0-.7-7.7-1-23-1h-22C115.7 265.3\n 68 321 0 361zm0-174v-40h399900v40zm100 154v40h399900v-40z",longequal:"M0 50 h400000 v40H0z m0 194h40000v40H0z\nM0 50 h400000 v40H0z m0 194h40000v40H0z",midbrace:"M200428 334\nc-100.7-8.3-195.3-44-280-108-55.3-42-101.7-93-139-153l-9-14c-2.7 4-5.7 8.7-9 14\n-53.3 86.7-123.7 153-211 199-66.7 36-137.3 56.3-212 62H0V214h199568c178.3-11.7\n 311.7-78.3 403-201 6-8 9.7-12 11-12 .7-.7 6.7-1 18-1s17.3.3 18 1c1.3 0 5 4 11\n 12 44.7 59.3 101.3 106.3 170 141s145.3 54.3 229 60h199572v120z",midbraceunder:"M199572 214\nc100.7 8.3 195.3 44 280 108 55.3 42 101.7 93 139 153l9 14c2.7-4 5.7-8.7 9-14\n 53.3-86.7 123.7-153 211-199 66.7-36 137.3-56.3 212-62h199568v120H200432c-178.3\n 11.7-311.7 78.3-403 201-6 8-9.7 12-11 12-.7.7-6.7 1-18 1s-17.3-.3-18-1c-1.3 0\n-5-4-11-12-44.7-59.3-101.3-106.3-170-141s-145.3-54.3-229-60H0V214z",oiintSize1:"M512.6 71.6c272.6 0 320.3 106.8 320.3 178.2 0 70.8-47.7 177.6\n-320.3 177.6S193.1 320.6 193.1 249.8c0-71.4 46.9-178.2 319.5-178.2z\nm368.1 178.2c0-86.4-60.9-215.4-368.1-215.4-306.4 0-367.3 129-367.3 215.4 0 85.8\n60.9 214.8 367.3 214.8 307.2 0 368.1-129 368.1-214.8z",oiintSize2:"M757.8 100.1c384.7 0 451.1 137.6 451.1 230 0 91.3-66.4 228.8\n-451.1 228.8-386.3 0-452.7-137.5-452.7-228.8 0-92.4 66.4-230 452.7-230z\nm502.4 230c0-111.2-82.4-277.2-502.4-277.2s-504 166-504 277.2\nc0 110 84 276 504 276s502.4-166 502.4-276z",oiiintSize1:"M681.4 71.6c408.9 0 480.5 106.8 480.5 178.2 0 70.8-71.6 177.6\n-480.5 177.6S202.1 320.6 202.1 249.8c0-71.4 70.5-178.2 479.3-178.2z\nm525.8 178.2c0-86.4-86.8-215.4-525.7-215.4-437.9 0-524.7 129-524.7 215.4 0\n85.8 86.8 214.8 524.7 214.8 438.9 0 525.7-129 525.7-214.8z",oiiintSize2:"M1021.2 53c603.6 0 707.8 165.8 707.8 277.2 0 110-104.2 275.8\n-707.8 275.8-606 0-710.2-165.8-710.2-275.8C311 218.8 415.2 53 1021.2 53z\nm770.4 277.1c0-131.2-126.4-327.6-770.5-327.6S248.4 198.9 248.4 330.1\nc0 130 128.8 326.4 772.7 326.4s770.5-196.4 770.5-326.4z",rightarrow:"M0 241v40h399891c-47.3 35.3-84 78-110 128\n-16.7 32-27.7 63.7-33 95 0 1.3-.2 2.7-.5 4-.3 1.3-.5 2.3-.5 3 0 7.3 6.7 11 20\n 11 8 0 13.2-.8 15.5-2.5 2.3-1.7 4.2-5.5 5.5-11.5 2-13.3 5.7-27 11-41 14.7-44.7\n 39-84.5 73-119.5s73.7-60.2 119-75.5c6-2 9-5.7 9-11s-3-9-9-11c-45.3-15.3-85\n-40.5-119-75.5s-58.3-74.8-73-119.5c-4.7-14-8.3-27.3-11-40-1.3-6.7-3.2-10.8-5.5\n-12.5-2.3-1.7-7.5-2.5-15.5-2.5-14 0-21 3.7-21 11 0 2 2 10.3 6 25 20.7 83.3 67\n 151.7 139 205zm0 0v40h399900v-40z",rightbrace:"M400000 542l\n-6 6h-17c-12.7 0-19.3-.3-20-1-4-4-7.3-8.3-10-13-35.3-51.3-80.8-93.8-136.5-127.5\ns-117.2-55.8-184.5-66.5c-.7 0-2-.3-4-1-18.7-2.7-76-4.3-172-5H0V214h399571l6 1\nc124.7 8 235 61.7 331 161 31.3 33.3 59.7 72.7 85 118l7 13v35z",rightbraceunder:"M399994 0l6 6v35l-6 11c-56 104-135.3 181.3-238 232-57.3\n 28.7-117 45-179 50H-300V214h399897c43.3-7 81-15 113-26 100.7-33 179.7-91 237\n-174 2.7-5 6-9 10-13 .7-1 7.3-1 20-1h17z",rightgroup:"M0 80h399565c371 0 266.7 149.4 414 180 5.9 1.2 18 0 18 0 2 0\n 3-1 3-3v-38c-76-158-257-219-435-219H0z",rightgroupunder:"M0 262h399565c371 0 266.7-149.4 414-180 5.9-1.2 18 0 18\n 0 2 0 3 1 3 3v38c-76 158-257 219-435 219H0z",rightharpoon:"M0 241v40h399993c4.7-4.7 7-9.3 7-14 0-9.3\n-3.7-15.3-11-18-92.7-56.7-159-133.7-199-231-3.3-9.3-6-14.7-8-16-2-1.3-7-2-15-2\n-10.7 0-16.7 2-18 6-2 2.7-1 9.7 3 21 15.3 42 36.7 81.8 64 119.5 27.3 37.7 58\n 69.2 92 94.5zm0 0v40h399900v-40z",rightharpoonplus:"M0 241v40h399993c4.7-4.7 7-9.3 7-14 0-9.3-3.7-15.3-11\n-18-92.7-56.7-159-133.7-199-231-3.3-9.3-6-14.7-8-16-2-1.3-7-2-15-2-10.7 0-16.7\n 2-18 6-2 2.7-1 9.7 3 21 15.3 42 36.7 81.8 64 119.5 27.3 37.7 58 69.2 92 94.5z\nm0 0v40h399900v-40z m100 194v40h399900v-40zm0 0v40h399900v-40z",rightharpoondown:"M399747 511c0 7.3 6.7 11 20 11 8 0 13-.8 15-2.5s4.7-6.8\n 8-15.5c40-94 99.3-166.3 178-217 13.3-8 20.3-12.3 21-13 5.3-3.3 8.5-5.8 9.5\n-7.5 1-1.7 1.5-5.2 1.5-10.5s-2.3-10.3-7-15H0v40h399908c-34 25.3-64.7 57-92 95\n-27.3 38-48.7 77.7-64 119-3.3 8.7-5 14-5 16zM0 241v40h399900v-40z",rightharpoondownplus:"M399747 705c0 7.3 6.7 11 20 11 8 0 13-.8\n 15-2.5s4.7-6.8 8-15.5c40-94 99.3-166.3 178-217 13.3-8 20.3-12.3 21-13 5.3-3.3\n 8.5-5.8 9.5-7.5 1-1.7 1.5-5.2 1.5-10.5s-2.3-10.3-7-15H0v40h399908c-34 25.3\n-64.7 57-92 95-27.3 38-48.7 77.7-64 119-3.3 8.7-5 14-5 16zM0 435v40h399900v-40z\nm0-194v40h400000v-40zm0 0v40h400000v-40z",righthook:"M399859 241c-764 0 0 0 0 0 40-3.3 68.7-15.7 86-37 10-12 15-25.3\n 15-40 0-22.7-9.8-40.7-29.5-54-19.7-13.3-43.5-21-71.5-23-17.3-1.3-26-8-26-20 0\n-13.3 8.7-20 26-20 38 0 71 11.2 99 33.5 0 0 7 5.6 21 16.7 14 11.2 21 33.5 21\n 66.8s-14 61.2-42 83.5c-28 22.3-61 33.5-99 33.5L0 241z M0 281v-40h399859v40z",rightlinesegment:"M399960 241 V94 h40 V428 h-40 V281 H0 v-40z\nM399960 241 V94 h40 V428 h-40 V281 H0 v-40z",rightToFrom:"M400000 167c-70.7-42-118-97.7-142-167h-23c-15.3 0-23 .3-23\n 1 0 1.3 5.3 13.7 16 37 18 35.3 41.3 69 70 101l7 8H0v40h399905l-7 8c-28.7 32\n-52 65.7-70 101-10.7 23.3-16 35.7-16 37 0 .7 7.7 1 23 1h23c24-69.3 71.3-125 142\n-167z M100 147v40h399900v-40zM0 341v40h399900v-40z",twoheadleftarrow:"M0 167c68 40\n 115.7 95.7 143 167h22c15.3 0 23-.3 23-1 0-1.3-5.3-13.7-16-37-18-35.3-41.3-69\n-70-101l-7-8h125l9 7c50.7 39.3 85 86 103 140h46c0-4.7-6.3-18.7-19-42-18-35.3\n-40-67.3-66-96l-9-9h399716v-40H284l9-9c26-28.7 48-60.7 66-96 12.7-23.333 19\n-37.333 19-42h-46c-18 54-52.3 100.7-103 140l-9 7H95l7-8c28.7-32 52-65.7 70-101\n 10.7-23.333 16-35.7 16-37 0-.7-7.7-1-23-1h-22C115.7 71.3 68 127 0 167z",twoheadrightarrow:"M400000 167\nc-68-40-115.7-95.7-143-167h-22c-15.3 0-23 .3-23 1 0 1.3 5.3 13.7 16 37 18 35.3\n 41.3 69 70 101l7 8h-125l-9-7c-50.7-39.3-85-86-103-140h-46c0 4.7 6.3 18.7 19 42\n 18 35.3 40 67.3 66 96l9 9H0v40h399716l-9 9c-26 28.7-48 60.7-66 96-12.7 23.333\n-19 37.333-19 42h46c18-54 52.3-100.7 103-140l9-7h125l-7 8c-28.7 32-52 65.7-70\n 101-10.7 23.333-16 35.7-16 37 0 .7 7.7 1 23 1h22c27.3-71.3 75-127 143-167z",tilde1:"M200 55.538c-77 0-168 73.953-177 73.953-3 0-7\n-2.175-9-5.437L2 97c-1-2-2-4-2-6 0-4 2-7 5-9l20-12C116 12 171 0 207 0c86 0\n 114 68 191 68 78 0 168-68 177-68 4 0 7 2 9 5l12 19c1 2.175 2 4.35 2 6.525 0\n 4.35-2 7.613-5 9.788l-19 13.05c-92 63.077-116.937 75.308-183 76.128\n-68.267.847-113-73.952-191-73.952z",tilde2:"M344 55.266c-142 0-300.638 81.316-311.5 86.418\n-8.01 3.762-22.5 10.91-23.5 5.562L1 120c-1-2-1-3-1-4 0-5 3-9 8-10l18.4-9C160.9\n 31.9 283 0 358 0c148 0 188 122 331 122s314-97 326-97c4 0 8 2 10 7l7 21.114\nc1 2.14 1 3.21 1 4.28 0 5.347-3 9.626-7 10.696l-22.3 12.622C852.6 158.372 751\n 181.476 676 181.476c-149 0-189-126.21-332-126.21z",tilde3:"M786 59C457 59 32 175.242 13 175.242c-6 0-10-3.457\n-11-10.37L.15 138c-1-7 3-12 10-13l19.2-6.4C378.4 40.7 634.3 0 804.3 0c337 0\n 411.8 157 746.8 157 328 0 754-112 773-112 5 0 10 3 11 9l1 14.075c1 8.066-.697\n 16.595-6.697 17.492l-21.052 7.31c-367.9 98.146-609.15 122.696-778.15 122.696\n -338 0-409-156.573-744-156.573z",tilde4:"M786 58C457 58 32 177.487 13 177.487c-6 0-10-3.345\n-11-10.035L.15 143c-1-7 3-12 10-13l22-6.7C381.2 35 637.15 0 807.15 0c337 0 409\n 177 744 177 328 0 754-127 773-127 5 0 10 3 11 9l1 14.794c1 7.805-3 13.38-9\n 14.495l-20.7 5.574c-366.85 99.79-607.3 139.372-776.3 139.372-338 0-409\n -175.236-744-175.236z",vec:"M377 20c0-5.333 1.833-10 5.5-14S391 0 397 0c4.667 0 8.667 1.667 12 5\n3.333 2.667 6.667 9 10 19 6.667 24.667 20.333 43.667 41 57 7.333 4.667 11\n10.667 11 18 0 6-1 10-3 12s-6.667 5-14 9c-28.667 14.667-53.667 35.667-75 63\n-1.333 1.333-3.167 3.5-5.5 6.5s-4 4.833-5 5.5c-1 .667-2.5 1.333-4.5 2s-4.333 1\n-7 1c-4.667 0-9.167-1.833-13.5-5.5S337 184 337 178c0-12.667 15.667-32.333 47-59\nH213l-171-1c-8.667-6-13-12.333-13-19 0-4.667 4.333-11.333 13-20h359\nc-16-25.333-24-45-24-59z",widehat1:"M529 0h5l519 115c5 1 9 5 9 10 0 1-1 2-1 3l-4 22\nc-1 5-5 9-11 9h-2L532 67 19 159h-2c-5 0-9-4-11-9l-5-22c-1-6 2-12 8-13z",widehat2:"M1181 0h2l1171 176c6 0 10 5 10 11l-2 23c-1 6-5 10\n-11 10h-1L1182 67 15 220h-1c-6 0-10-4-11-10l-2-23c-1-6 4-11 10-11z",widehat3:"M1181 0h2l1171 236c6 0 10 5 10 11l-2 23c-1 6-5 10\n-11 10h-1L1182 67 15 280h-1c-6 0-10-4-11-10l-2-23c-1-6 4-11 10-11z",widehat4:"M1181 0h2l1171 296c6 0 10 5 10 11l-2 23c-1 6-5 10\n-11 10h-1L1182 67 15 340h-1c-6 0-10-4-11-10l-2-23c-1-6 4-11 10-11z",widecheck1:"M529,159h5l519,-115c5,-1,9,-5,9,-10c0,-1,-1,-2,-1,-3l-4,-22c-1,\n-5,-5,-9,-11,-9h-2l-512,92l-513,-92h-2c-5,0,-9,4,-11,9l-5,22c-1,6,2,12,8,13z",widecheck2:"M1181,220h2l1171,-176c6,0,10,-5,10,-11l-2,-23c-1,-6,-5,-10,\n-11,-10h-1l-1168,153l-1167,-153h-1c-6,0,-10,4,-11,10l-2,23c-1,6,4,11,10,11z",widecheck3:"M1181,280h2l1171,-236c6,0,10,-5,10,-11l-2,-23c-1,-6,-5,-10,\n-11,-10h-1l-1168,213l-1167,-213h-1c-6,0,-10,4,-11,10l-2,23c-1,6,4,11,10,11z",widecheck4:"M1181,340h2l1171,-296c6,0,10,-5,10,-11l-2,-23c-1,-6,-5,-10,\n-11,-10h-1l-1168,273l-1167,-273h-1c-6,0,-10,4,-11,10l-2,23c-1,6,4,11,10,11z",baraboveleftarrow:"M400000 620h-399890l3 -3c68.7 -52.7 113.7 -120 135 -202\nc4 -14.7 6 -23 6 -25c0 -7.3 -7 -11 -21 -11c-8 0 -13.2 0.8 -15.5 2.5\nc-2.3 1.7 -4.2 5.8 -5.5 12.5c-1.3 4.7 -2.7 10.3 -4 17c-12 48.7 -34.8 92 -68.5 130\ns-74.2 66.3 -121.5 85c-10 4 -16 7.7 -18 11c0 8.7 6 14.3 18 17c47.3 18.7 87.8 47\n121.5 85s56.5 81.3 68.5 130c0.7 2 1.3 5 2 9s1.2 6.7 1.5 8c0.3 1.3 1 3.3 2 6\ns2.2 4.5 3.5 5.5c1.3 1 3.3 1.8 6 2.5s6 1 10 1c14 0 21 -3.7 21 -11\nc0 -2 -2 -10.3 -6 -25c-20 -79.3 -65 -146.7 -135 -202l-3 -3h399890z\nM100 620v40h399900v-40z M0 241v40h399900v-40zM0 241v40h399900v-40z",rightarrowabovebar:"M0 241v40h399891c-47.3 35.3-84 78-110 128-16.7 32\n-27.7 63.7-33 95 0 1.3-.2 2.7-.5 4-.3 1.3-.5 2.3-.5 3 0 7.3 6.7 11 20 11 8 0\n13.2-.8 15.5-2.5 2.3-1.7 4.2-5.5 5.5-11.5 2-13.3 5.7-27 11-41 14.7-44.7 39\n-84.5 73-119.5s73.7-60.2 119-75.5c6-2 9-5.7 9-11s-3-9-9-11c-45.3-15.3-85-40.5\n-119-75.5s-58.3-74.8-73-119.5c-4.7-14-8.3-27.3-11-40-1.3-6.7-3.2-10.8-5.5\n-12.5-2.3-1.7-7.5-2.5-15.5-2.5-14 0-21 3.7-21 11 0 2 2 10.3 6 25 20.7 83.3 67\n151.7 139 205zm96 379h399894v40H0zm0 0h399904v40H0z",baraboveshortleftharpoon:"M507,435c-4,4,-6.3,8.7,-7,14c0,5.3,0.7,9,2,11\nc1.3,2,5.3,5.3,12,10c90.7,54,156,130,196,228c3.3,10.7,6.3,16.3,9,17\nc2,0.7,5,1,9,1c0,0,5,0,5,0c10.7,0,16.7,-2,18,-6c2,-2.7,1,-9.7,-3,-21\nc-32,-87.3,-82.7,-157.7,-152,-211c0,0,-3,-3,-3,-3l399351,0l0,-40\nc-398570,0,-399437,0,-399437,0z M593 435 v40 H399500 v-40z\nM0 281 v-40 H399908 v40z M0 281 v-40 H399908 v40z",rightharpoonaboveshortbar:"M0,241 l0,40c399126,0,399993,0,399993,0\nc4.7,-4.7,7,-9.3,7,-14c0,-9.3,-3.7,-15.3,-11,-18c-92.7,-56.7,-159,-133.7,-199,\n-231c-3.3,-9.3,-6,-14.7,-8,-16c-2,-1.3,-7,-2,-15,-2c-10.7,0,-16.7,2,-18,6\nc-2,2.7,-1,9.7,3,21c15.3,42,36.7,81.8,64,119.5c27.3,37.7,58,69.2,92,94.5z\nM0 241 v40 H399908 v-40z M0 475 v-40 H399500 v40z M0 475 v-40 H399500 v40z", +shortbaraboveleftharpoon:"M7,435c-4,4,-6.3,8.7,-7,14c0,5.3,0.7,9,2,11\nc1.3,2,5.3,5.3,12,10c90.7,54,156,130,196,228c3.3,10.7,6.3,16.3,9,17c2,0.7,5,1,9,\n1c0,0,5,0,5,0c10.7,0,16.7,-2,18,-6c2,-2.7,1,-9.7,-3,-21c-32,-87.3,-82.7,-157.7,\n-152,-211c0,0,-3,-3,-3,-3l399907,0l0,-40c-399126,0,-399993,0,-399993,0z\nM93 435 v40 H400000 v-40z M500 241 v40 H400000 v-40z M500 241 v40 H400000 v-40z",shortrightharpoonabovebar:"M53,241l0,40c398570,0,399437,0,399437,0\nc4.7,-4.7,7,-9.3,7,-14c0,-9.3,-3.7,-15.3,-11,-18c-92.7,-56.7,-159,-133.7,-199,\n-231c-3.3,-9.3,-6,-14.7,-8,-16c-2,-1.3,-7,-2,-15,-2c-10.7,0,-16.7,2,-18,6\nc-2,2.7,-1,9.7,3,21c15.3,42,36.7,81.8,64,119.5c27.3,37.7,58,69.2,92,94.5z\nM500 241 v40 H399408 v-40z M500 435 v40 H400000 v-40z"},K=function(){function e(e){this.children=void 0,this.classes=void 0,this.height=void 0,this.depth=void 0,this.maxFontSize=void 0,this.style=void 0,this.children=e,this.classes=[],this.height=0,this.depth=0,this.maxFontSize=0,this.style={}}var t=e.prototype;return t.hasClass=function(e){return I.contains(this.classes,e)},t.toNode=function(){for(var e=document.createDocumentFragment(),t=0;t=5?0:e>=3?1:2]){var r=ee[t]={cssEmPerMu:J.quad[t]/18};for(var n in J)J.hasOwnProperty(n)&&(r[n]=J[n][t])}return ee[t]}(this.size)),this._fontMetrics},t.getColor=function(){return this.phantom?"transparent":this.color},e}();ie.BASESIZE=6;var ae=ie,oe={pt:1,mm:7227/2540,cm:7227/254,"in":72.27,bp:1.00375,pc:12,dd:1238/1157,cc:14856/1157,nd:685/642,nc:1370/107,sp:1/65536,px:1.00375},se={ex:!0,em:!0,mu:!0},le=function(e){return"string"!=typeof e&&(e=e.unit),e in oe||e in se||"ex"===e},he=function(e,t){var r;if(e.unit in oe)r=oe[e.unit]/t.fontMetrics().ptPerEm/t.sizeMultiplier;else if("mu"===e.unit)r=t.fontMetrics().cssEmPerMu;else{var n;if(n=t.style.isTight()?t.havingStyle(t.style.text()):t,"ex"===e.unit)r=n.fontMetrics().xHeight;else{if("em"!==e.unit)throw new B("Invalid unit: '"+e.unit+"'");r=n.fontMetrics().quad}n!==t&&(r*=n.sizeMultiplier/t.sizeMultiplier)}return Math.min(e.number*r,t.maxSize)},ce=function(e){return+e.toFixed(4)+"em"},ue=function(e){return e.filter(function(e){return e}).join(" ")},me=function(e,t,r){if(this.classes=e||[],this.attributes={},this.height=0,this.depth=0,this.maxFontSize=0,this.style=r||{},t){t.style.isTight()&&this.classes.push("mtight");var n=t.getColor();n&&(this.style.color=n)}},de=function(e){var t=document.createElement(e);for(var r in t.className=ue(this.classes),this.style)this.style.hasOwnProperty(r)&&(t.style[r]=this.style[r]);for(var n in this.attributes)this.attributes.hasOwnProperty(n)&&t.setAttribute(n,this.attributes[n]);for(var i=0;i"},fe=function(){function e(e,t,r,n){this.children=void 0,this.attributes=void 0, +this.classes=void 0,this.height=void 0,this.depth=void 0,this.width=void 0,this.maxFontSize=void 0,this.style=void 0,me.call(this,e,r,n),this.children=t||[]}var t=e.prototype;return t.setAttribute=function(e,t){this.attributes[e]=t},t.hasClass=function(e){return I.contains(this.classes,e)},t.toNode=function(){return de.call(this,"span")},t.toMarkup=function(){return pe.call(this,"span")},e}(),ge=function(){function e(e,t,r,n){this.children=void 0,this.attributes=void 0,this.classes=void 0,this.height=void 0,this.depth=void 0,this.maxFontSize=void 0,this.style=void 0,me.call(this,t,n),this.children=r||[],this.setAttribute("href",e)}var t=e.prototype;return t.setAttribute=function(e,t){this.attributes[e]=t},t.hasClass=function(e){return I.contains(this.classes,e)},t.toNode=function(){return de.call(this,"a")},t.toMarkup=function(){return pe.call(this,"a")},e}(),ve=function(){function e(e,t,r){this.src=void 0,this.alt=void 0,this.classes=void 0,this.height=void 0,this.depth=void 0,this.maxFontSize=void 0,this.style=void 0,this.alt=t,this.src=e,this.classes=["mord"],this.style=r}var t=e.prototype;return t.hasClass=function(e){return I.contains(this.classes,e)},t.toNode=function(){var e=document.createElement("img");for(var t in e.src=this.src,e.alt=this.alt,e.className="mord",this.style)this.style.hasOwnProperty(t)&&(e.style[t]=this.style[t]);return e},t.toMarkup=function(){var e=""+this.alt+""},e}(),ye={"\xee":"\u0131\u0302","\xef":"\u0131\u0308","\xed":"\u0131\u0301","\xec":"\u0131\u0300"},be=function(){function e(e,t,r,n,i,a,o,s){this.text=void 0,this.height=void 0,this.depth=void 0,this.italic=void 0,this.skew=void 0,this.width=void 0,this.maxFontSize=void 0,this.classes=void 0,this.style=void 0,this.text=e,this.height=t||0,this.depth=r||0,this.italic=n||0,this.skew=i||0,this.width=a||0,this.classes=o||[],this.style=s||{},this.maxFontSize=0;var l=function(e){for(var t=0;t=i[0]&&e<=i[1])return r.name}return null}(this.text.charCodeAt(0));l&&this.classes.push(l+"_fallback"),/[\xee\xef\xed\xec]/.test(this.text)&&(this.text=ye[this.text])}var t=e.prototype;return t.hasClass=function(e){return I.contains(this.classes,e)},t.toNode=function(){var e=document.createTextNode(this.text),t=null;for(var r in this.italic>0&&((t=document.createElement("span")).style.marginRight=ce(this.italic)),this.classes.length>0&&((t=t||document.createElement("span")).className=ue(this.classes)),this.style)this.style.hasOwnProperty(r)&&((t=t||document.createElement("span")).style[r]=this.style[r]);return t?(t.appendChild(e),t):e},t.toMarkup=function(){var e=!1,t="0&&(r+="margin-right:"+this.italic+"em;"),this.style)this.style.hasOwnProperty(n)&&(r+=I.hyphenate(n)+":"+this.style[n]+";");r&&(e=!0,t+=' style="'+I.escape(r)+'"');var i=I.escape(this.text);return e?(t+=">",t+=i,t+=""):i},e}(),xe=function(){function e(e,t){this.children=void 0,this.attributes=void 0,this.children=e||[],this.attributes=t||{}}var t=e.prototype;return t.toNode=function(){var e=document.createElementNS("http://www.w3.org/2000/svg","svg");for(var t in this.attributes)Object.prototype.hasOwnProperty.call(this.attributes,t)&&e.setAttribute(t,this.attributes[t]);for(var r=0;r"},e}(),we=function(){function e(e,t){this.pathName=void 0,this.alternate=void 0,this.pathName=e,this.alternate=t}var t=e.prototype;return t.toNode=function(){var e=document.createElementNS("http://www.w3.org/2000/svg","path");return this.alternate?e.setAttribute("d",this.alternate):e.setAttribute("d",$[this.pathName]),e},t.toMarkup=function(){return this.alternate?"":""},e}(),ke=function(){function e(e){this.attributes=void 0,this.attributes=e||{}}var t=e.prototype;return t.toNode=function(){var e=document.createElementNS("http://www.w3.org/2000/svg","line");for(var t in this.attributes)Object.prototype.hasOwnProperty.call(this.attributes,t)&&e.setAttribute(t,this.attributes[t]);return e},t.toMarkup=function(){var e=""},e}(),Se={bin:1,close:1,inner:1,open:1,punct:1,rel:1},Ae={"accent-token":1,mathord:1,"op-token":1,spacing:1,textord:1},Me={math:{},text:{}},ze=Me,Te="math",Ce="text",Be="main",Le="ams",Ne="accent-token",qe="bin",Ee="close",Ie="inner",Oe="mathord",Re="op-token",He="open",De="punct",Pe="rel",Fe="spacing",_e="textord";i(Te,Be,Pe,"\u2261","\\equiv",!0),i(Te,Be,Pe,"\u227a","\\prec",!0),i(Te,Be,Pe,"\u227b","\\succ",!0),i(Te,Be,Pe,"\u223c","\\sim",!0),i(Te,Be,Pe,"\u22a5","\\perp"),i(Te,Be,Pe,"\u2aaf","\\preceq",!0),i(Te,Be,Pe,"\u2ab0","\\succeq",!0),i(Te,Be,Pe,"\u2243","\\simeq",!0),i(Te,Be,Pe,"\u2223","\\mid",!0),i(Te,Be,Pe,"\u226a","\\ll",!0),i(Te,Be,Pe,"\u226b","\\gg",!0),i(Te,Be,Pe,"\u224d","\\asymp",!0),i(Te,Be,Pe,"\u2225","\\parallel"),i(Te,Be,Pe,"\u22c8","\\bowtie",!0),i(Te,Be,Pe,"\u2323","\\smile",!0),i(Te,Be,Pe,"\u2291","\\sqsubseteq",!0),i(Te,Be,Pe,"\u2292","\\sqsupseteq",!0),i(Te,Be,Pe,"\u2250","\\doteq",!0),i(Te,Be,Pe,"\u2322","\\frown",!0),i(Te,Be,Pe,"\u220b","\\ni",!0),i(Te,Be,Pe,"\u221d","\\propto",!0),i(Te,Be,Pe,"\u22a2","\\vdash",!0),i(Te,Be,Pe,"\u22a3","\\dashv",!0),i(Te,Be,Pe,"\u220b","\\owns"),i(Te,Be,De,".","\\ldotp"),i(Te,Be,De,"\u22c5","\\cdotp"),i(Te,Be,_e,"#","\\#"),i(Ce,Be,_e,"#","\\#"),i(Te,Be,_e,"&","\\&"),i(Ce,Be,_e,"&","\\&"),i(Te,Be,_e,"\u2135","\\aleph",!0),i(Te,Be,_e,"\u2200","\\forall",!0),i(Te,Be,_e,"\u210f","\\hbar",!0),i(Te,Be,_e,"\u2203","\\exists",!0),i(Te,Be,_e,"\u2207","\\nabla",!0),i(Te,Be,_e,"\u266d","\\flat",!0),i(Te,Be,_e,"\u2113","\\ell",!0),i(Te,Be,_e,"\u266e","\\natural",!0),i(Te,Be,_e,"\u2663","\\clubsuit",!0),i(Te,Be,_e,"\u2118","\\wp",!0),i(Te,Be,_e,"\u266f","\\sharp",!0),i(Te,Be,_e,"\u2662","\\diamondsuit",!0),i(Te,Be,_e,"\u211c","\\Re",!0),i(Te,Be,_e,"\u2661","\\heartsuit",!0),i(Te,Be,_e,"\u2111","\\Im",!0),i(Te,Be,_e,"\u2660","\\spadesuit",!0),i(Te,Be,_e,"\xa7","\\S",!0),i(Ce,Be,_e,"\xa7","\\S"),i(Te,Be,_e,"\xb6","\\P",!0),i(Ce,Be,_e,"\xb6","\\P"),i(Te,Be,_e,"\u2020","\\dag"),i(Ce,Be,_e,"\u2020","\\dag"),i(Ce,Be,_e,"\u2020","\\textdagger"),i(Te,Be,_e,"\u2021","\\ddag"),i(Ce,Be,_e,"\u2021","\\ddag"),i(Ce,Be,_e,"\u2021","\\textdaggerdbl"),i(Te,Be,Ee,"\u23b1","\\rmoustache",!0),i(Te,Be,He,"\u23b0","\\lmoustache",!0),i(Te,Be,Ee,"\u27ef","\\rgroup",!0),i(Te,Be,He,"\u27ee","\\lgroup",!0),i(Te,Be,qe,"\u2213","\\mp",!0),i(Te,Be,qe,"\u2296","\\ominus",!0),i(Te,Be,qe,"\u228e","\\uplus",!0),i(Te,Be,qe,"\u2293","\\sqcap",!0),i(Te,Be,qe,"\u2217","\\ast"),i(Te,Be,qe,"\u2294","\\sqcup",!0),i(Te,Be,qe,"\u25ef","\\bigcirc",!0),i(Te,Be,qe,"\u2219","\\bullet",!0),i(Te,Be,qe,"\u2021","\\ddagger"),i(Te,Be,qe,"\u2240","\\wr",!0),i(Te,Be,qe,"\u2a3f","\\amalg"),i(Te,Be,qe,"&","\\And"),i(Te,Be,Pe,"\u27f5","\\longleftarrow",!0),i(Te,Be,Pe,"\u21d0","\\Leftarrow",!0),i(Te,Be,Pe,"\u27f8","\\Longleftarrow",!0),i(Te,Be,Pe,"\u27f6","\\longrightarrow",!0),i(Te,Be,Pe,"\u21d2","\\Rightarrow",!0),i(Te,Be,Pe,"\u27f9","\\Longrightarrow",!0),i(Te,Be,Pe,"\u2194","\\leftrightarrow",!0),i(Te,Be,Pe,"\u27f7","\\longleftrightarrow",!0),i(Te,Be,Pe,"\u21d4","\\Leftrightarrow",!0),i(Te,Be,Pe,"\u27fa","\\Longleftrightarrow",!0),i(Te,Be,Pe,"\u21a6","\\mapsto",!0),i(Te,Be,Pe,"\u27fc","\\longmapsto",!0),i(Te,Be,Pe,"\u2197","\\nearrow",!0),i(Te,Be,Pe,"\u21a9","\\hookleftarrow",!0),i(Te,Be,Pe,"\u21aa","\\hookrightarrow",!0),i(Te,Be,Pe,"\u2198","\\searrow",!0),i(Te,Be,Pe,"\u21bc","\\leftharpoonup",!0),i(Te,Be,Pe,"\u21c0","\\rightharpoonup",!0),i(Te,Be,Pe,"\u2199","\\swarrow",!0),i(Te,Be,Pe,"\u21bd","\\leftharpoondown",!0),i(Te,Be,Pe,"\u21c1","\\rightharpoondown",!0),i(Te,Be,Pe,"\u2196","\\nwarrow",!0),i(Te,Be,Pe,"\u21cc","\\rightleftharpoons",!0),i(Te,Le,Pe,"\u226e","\\nless",!0),i(Te,Le,Pe,"\ue010","\\@nleqslant"),i(Te,Le,Pe,"\ue011","\\@nleqq"),i(Te,Le,Pe,"\u2a87","\\lneq",!0),i(Te,Le,Pe,"\u2268","\\lneqq",!0),i(Te,Le,Pe,"\ue00c","\\@lvertneqq"),i(Te,Le,Pe,"\u22e6","\\lnsim",!0),i(Te,Le,Pe,"\u2a89","\\lnapprox",!0),i(Te,Le,Pe,"\u2280","\\nprec",!0),i(Te,Le,Pe,"\u22e0","\\npreceq",!0),i(Te,Le,Pe,"\u22e8","\\precnsim",!0),i(Te,Le,Pe,"\u2ab9","\\precnapprox",!0),i(Te,Le,Pe,"\u2241","\\nsim",!0),i(Te,Le,Pe,"\ue006","\\@nshortmid"),i(Te,Le,Pe,"\u2224","\\nmid",!0),i(Te,Le,Pe,"\u22ac","\\nvdash",!0),i(Te,Le,Pe,"\u22ad","\\nvDash",!0),i(Te,Le,Pe,"\u22ea","\\ntriangleleft"),i(Te,Le,Pe,"\u22ec","\\ntrianglelefteq",!0),i(Te,Le,Pe,"\u228a","\\subsetneq",!0),i(Te,Le,Pe,"\ue01a","\\@varsubsetneq"),i(Te,Le,Pe,"\u2acb","\\subsetneqq",!0),i(Te,Le,Pe,"\ue017","\\@varsubsetneqq"),i(Te,Le,Pe,"\u226f","\\ngtr",!0),i(Te,Le,Pe,"\ue00f","\\@ngeqslant"),i(Te,Le,Pe,"\ue00e","\\@ngeqq"),i(Te,Le,Pe,"\u2a88","\\gneq",!0),i(Te,Le,Pe,"\u2269","\\gneqq",!0),i(Te,Le,Pe,"\ue00d","\\@gvertneqq"),i(Te,Le,Pe,"\u22e7","\\gnsim",!0),i(Te,Le,Pe,"\u2a8a","\\gnapprox",!0),i(Te,Le,Pe,"\u2281","\\nsucc",!0),i(Te,Le,Pe,"\u22e1","\\nsucceq",!0),i(Te,Le,Pe,"\u22e9","\\succnsim",!0),i(Te,Le,Pe,"\u2aba","\\succnapprox",!0),i(Te,Le,Pe,"\u2246","\\ncong",!0),i(Te,Le,Pe,"\ue007","\\@nshortparallel"),i(Te,Le,Pe,"\u2226","\\nparallel",!0),i(Te,Le,Pe,"\u22af","\\nVDash",!0),i(Te,Le,Pe,"\u22eb","\\ntriangleright"),i(Te,Le,Pe,"\u22ed","\\ntrianglerighteq",!0),i(Te,Le,Pe,"\ue018","\\@nsupseteqq"),i(Te,Le,Pe,"\u228b","\\supsetneq",!0),i(Te,Le,Pe,"\ue01b","\\@varsupsetneq"),i(Te,Le,Pe,"\u2acc","\\supsetneqq",!0),i(Te,Le,Pe,"\ue019","\\@varsupsetneqq"),i(Te,Le,Pe,"\u22ae","\\nVdash",!0),i(Te,Le,Pe,"\u2ab5","\\precneqq",!0),i(Te,Le,Pe,"\u2ab6","\\succneqq",!0),i(Te,Le,Pe,"\ue016","\\@nsubseteqq"),i(Te,Le,qe,"\u22b4","\\unlhd"),i(Te,Le,qe,"\u22b5","\\unrhd"),i(Te,Le,Pe,"\u219a","\\nleftarrow",!0),i(Te,Le,Pe,"\u219b","\\nrightarrow",!0),i(Te,Le,Pe,"\u21cd","\\nLeftarrow",!0),i(Te,Le,Pe,"\u21cf","\\nRightarrow",!0),i(Te,Le,Pe,"\u21ae","\\nleftrightarrow",!0),i(Te,Le,Pe,"\u21ce","\\nLeftrightarrow",!0),i(Te,Le,Pe,"\u25b3","\\vartriangle"),i(Te,Le,_e,"\u210f","\\hslash"),i(Te,Le,_e,"\u25bd","\\triangledown"),i(Te,Le,_e,"\u25ca","\\lozenge"),i(Te,Le,_e,"\u24c8","\\circledS"),i(Te,Le,_e,"\xae","\\circledR"),i(Ce,Le,_e,"\xae","\\circledR"),i(Te,Le,_e,"\u2221","\\measuredangle",!0),i(Te,Le,_e,"\u2204","\\nexists"),i(Te,Le,_e,"\u2127","\\mho"),i(Te,Le,_e,"\u2132","\\Finv",!0),i(Te,Le,_e,"\u2141","\\Game",!0),i(Te,Le,_e,"\u2035","\\backprime"),i(Te,Le,_e,"\u25b2","\\blacktriangle"),i(Te,Le,_e,"\u25bc","\\blacktriangledown"),i(Te,Le,_e,"\u25a0","\\blacksquare"),i(Te,Le,_e,"\u29eb","\\blacklozenge"),i(Te,Le,_e,"\u2605","\\bigstar"),i(Te,Le,_e,"\u2222","\\sphericalangle",!0),i(Te,Le,_e,"\u2201","\\complement",!0),i(Te,Le,_e,"\xf0","\\eth",!0),i(Ce,Be,_e,"\xf0","\xf0"),i(Te,Le,_e,"\u2571","\\diagup"),i(Te,Le,_e,"\u2572","\\diagdown"),i(Te,Le,_e,"\u25a1","\\square"),i(Te,Le,_e,"\u25a1","\\Box"),i(Te,Le,_e,"\u25ca","\\Diamond"),i(Te,Le,_e,"\xa5","\\yen",!0),i(Ce,Le,_e,"\xa5","\\yen",!0),i(Te,Le,_e,"\u2713","\\checkmark",!0),i(Ce,Le,_e,"\u2713","\\checkmark"),i(Te,Le,_e,"\u2136","\\beth",!0),i(Te,Le,_e,"\u2138","\\daleth",!0),i(Te,Le,_e,"\u2137","\\gimel",!0),i(Te,Le,_e,"\u03dd","\\digamma",!0),i(Te,Le,_e,"\u03f0","\\varkappa"),i(Te,Le,He,"\u250c","\\@ulcorner",!0),i(Te,Le,Ee,"\u2510","\\@urcorner",!0),i(Te,Le,He,"\u2514","\\@llcorner",!0),i(Te,Le,Ee,"\u2518","\\@lrcorner",!0),i(Te,Le,Pe,"\u2266","\\leqq",!0),i(Te,Le,Pe,"\u2a7d","\\leqslant",!0),i(Te,Le,Pe,"\u2a95","\\eqslantless",!0),i(Te,Le,Pe,"\u2272","\\lesssim",!0),i(Te,Le,Pe,"\u2a85","\\lessapprox",!0),i(Te,Le,Pe,"\u224a","\\approxeq",!0),i(Te,Le,qe,"\u22d6","\\lessdot"),i(Te,Le,Pe,"\u22d8","\\lll",!0),i(Te,Le,Pe,"\u2276","\\lessgtr",!0),i(Te,Le,Pe,"\u22da","\\lesseqgtr",!0),i(Te,Le,Pe,"\u2a8b","\\lesseqqgtr",!0),i(Te,Le,Pe,"\u2251","\\doteqdot"),i(Te,Le,Pe,"\u2253","\\risingdotseq",!0),i(Te,Le,Pe,"\u2252","\\fallingdotseq",!0),i(Te,Le,Pe,"\u223d","\\backsim",!0),i(Te,Le,Pe,"\u22cd","\\backsimeq",!0),i(Te,Le,Pe,"\u2ac5","\\subseteqq",!0),i(Te,Le,Pe,"\u22d0","\\Subset",!0),i(Te,Le,Pe,"\u228f","\\sqsubset",!0),i(Te,Le,Pe,"\u227c","\\preccurlyeq",!0),i(Te,Le,Pe,"\u22de","\\curlyeqprec",!0),i(Te,Le,Pe,"\u227e","\\precsim",!0),i(Te,Le,Pe,"\u2ab7","\\precapprox",!0),i(Te,Le,Pe,"\u22b2","\\vartriangleleft"),i(Te,Le,Pe,"\u22b4","\\trianglelefteq"),i(Te,Le,Pe,"\u22a8","\\vDash",!0),i(Te,Le,Pe,"\u22aa","\\Vvdash",!0),i(Te,Le,Pe,"\u2323","\\smallsmile"),i(Te,Le,Pe,"\u2322","\\smallfrown"),i(Te,Le,Pe,"\u224f","\\bumpeq",!0),i(Te,Le,Pe,"\u224e","\\Bumpeq",!0),i(Te,Le,Pe,"\u2267","\\geqq",!0),i(Te,Le,Pe,"\u2a7e","\\geqslant",!0),i(Te,Le,Pe,"\u2a96","\\eqslantgtr",!0),i(Te,Le,Pe,"\u2273","\\gtrsim",!0),i(Te,Le,Pe,"\u2a86","\\gtrapprox",!0),i(Te,Le,qe,"\u22d7","\\gtrdot"),i(Te,Le,Pe,"\u22d9","\\ggg",!0),i(Te,Le,Pe,"\u2277","\\gtrless",!0),i(Te,Le,Pe,"\u22db","\\gtreqless",!0),i(Te,Le,Pe,"\u2a8c","\\gtreqqless",!0),i(Te,Le,Pe,"\u2256","\\eqcirc",!0),i(Te,Le,Pe,"\u2257","\\circeq",!0),i(Te,Le,Pe,"\u225c","\\triangleq",!0),i(Te,Le,Pe,"\u223c","\\thicksim"),i(Te,Le,Pe,"\u2248","\\thickapprox"),i(Te,Le,Pe,"\u2ac6","\\supseteqq",!0),i(Te,Le,Pe,"\u22d1","\\Supset",!0),i(Te,Le,Pe,"\u2290","\\sqsupset",!0),i(Te,Le,Pe,"\u227d","\\succcurlyeq",!0),i(Te,Le,Pe,"\u22df","\\curlyeqsucc",!0),i(Te,Le,Pe,"\u227f","\\succsim",!0),i(Te,Le,Pe,"\u2ab8","\\succapprox",!0),i(Te,Le,Pe,"\u22b3","\\vartriangleright"),i(Te,Le,Pe,"\u22b5","\\trianglerighteq"),i(Te,Le,Pe,"\u22a9","\\Vdash",!0),i(Te,Le,Pe,"\u2223","\\shortmid"),i(Te,Le,Pe,"\u2225","\\shortparallel"),i(Te,Le,Pe,"\u226c","\\between",!0),i(Te,Le,Pe,"\u22d4","\\pitchfork",!0),i(Te,Le,Pe,"\u221d","\\varpropto"),i(Te,Le,Pe,"\u25c0","\\blacktriangleleft"),i(Te,Le,Pe,"\u2234","\\therefore",!0),i(Te,Le,Pe,"\u220d","\\backepsilon"),i(Te,Le,Pe,"\u25b6","\\blacktriangleright"),i(Te,Le,Pe,"\u2235","\\because",!0),i(Te,Le,Pe,"\u22d8","\\llless"),i(Te,Le,Pe,"\u22d9","\\gggtr"),i(Te,Le,qe,"\u22b2","\\lhd"),i(Te,Le,qe,"\u22b3","\\rhd"),i(Te,Le,Pe,"\u2242","\\eqsim",!0),i(Te,Be,Pe,"\u22c8","\\Join"),i(Te,Le,Pe,"\u2251","\\Doteq",!0),i(Te,Le,qe,"\u2214","\\dotplus",!0),i(Te,Le,qe,"\u2216","\\smallsetminus"),i(Te,Le,qe,"\u22d2","\\Cap",!0),i(Te,Le,qe,"\u22d3","\\Cup",!0),i(Te,Le,qe,"\u2a5e","\\doublebarwedge",!0),i(Te,Le,qe,"\u229f","\\boxminus",!0),i(Te,Le,qe,"\u229e","\\boxplus",!0),i(Te,Le,qe,"\u22c7","\\divideontimes",!0),i(Te,Le,qe,"\u22c9","\\ltimes",!0),i(Te,Le,qe,"\u22ca","\\rtimes",!0),i(Te,Le,qe,"\u22cb","\\leftthreetimes",!0),i(Te,Le,qe,"\u22cc","\\rightthreetimes",!0),i(Te,Le,qe,"\u22cf","\\curlywedge",!0),i(Te,Le,qe,"\u22ce","\\curlyvee",!0),i(Te,Le,qe,"\u229d","\\circleddash",!0),i(Te,Le,qe,"\u229b","\\circledast",!0),i(Te,Le,qe,"\u22c5","\\centerdot"),i(Te,Le,qe,"\u22ba","\\intercal",!0),i(Te,Le,qe,"\u22d2","\\doublecap"),i(Te,Le,qe,"\u22d3","\\doublecup"),i(Te,Le,qe,"\u22a0","\\boxtimes",!0),i(Te,Le,Pe,"\u21e2","\\dashrightarrow",!0),i(Te,Le,Pe,"\u21e0","\\dashleftarrow",!0),i(Te,Le,Pe,"\u21c7","\\leftleftarrows",!0),i(Te,Le,Pe,"\u21c6","\\leftrightarrows",!0),i(Te,Le,Pe,"\u21da","\\Lleftarrow",!0),i(Te,Le,Pe,"\u219e","\\twoheadleftarrow",!0),i(Te,Le,Pe,"\u21a2","\\leftarrowtail",!0),i(Te,Le,Pe,"\u21ab","\\looparrowleft",!0),i(Te,Le,Pe,"\u21cb","\\leftrightharpoons",!0),i(Te,Le,Pe,"\u21b6","\\curvearrowleft",!0),i(Te,Le,Pe,"\u21ba","\\circlearrowleft",!0),i(Te,Le,Pe,"\u21b0","\\Lsh",!0),i(Te,Le,Pe,"\u21c8","\\upuparrows",!0),i(Te,Le,Pe,"\u21bf","\\upharpoonleft",!0),i(Te,Le,Pe,"\u21c3","\\downharpoonleft",!0),i(Te,Be,Pe,"\u22b6","\\origof",!0),i(Te,Be,Pe,"\u22b7","\\imageof",!0),i(Te,Le,Pe,"\u22b8","\\multimap",!0),i(Te,Le,Pe,"\u21ad","\\leftrightsquigarrow",!0),i(Te,Le,Pe,"\u21c9","\\rightrightarrows",!0),i(Te,Le,Pe,"\u21c4","\\rightleftarrows",!0),i(Te,Le,Pe,"\u21a0","\\twoheadrightarrow",!0),i(Te,Le,Pe,"\u21a3","\\rightarrowtail",!0),i(Te,Le,Pe,"\u21ac","\\looparrowright",!0),i(Te,Le,Pe,"\u21b7","\\curvearrowright",!0),i(Te,Le,Pe,"\u21bb","\\circlearrowright",!0),i(Te,Le,Pe,"\u21b1","\\Rsh",!0),i(Te,Le,Pe,"\u21ca","\\downdownarrows",!0),i(Te,Le,Pe,"\u21be","\\upharpoonright",!0),i(Te,Le,Pe,"\u21c2","\\downharpoonright",!0),i(Te,Le,Pe,"\u21dd","\\rightsquigarrow",!0),i(Te,Le,Pe,"\u21dd","\\leadsto"),i(Te,Le,Pe,"\u21db","\\Rrightarrow",!0),i(Te,Le,Pe,"\u21be","\\restriction"),i(Te,Be,_e,"\u2018","`"),i(Te,Be,_e,"$","\\$"),i(Ce,Be,_e,"$","\\$"),i(Ce,Be,_e,"$","\\textdollar"),i(Te,Be,_e,"%","\\%"),i(Ce,Be,_e,"%","\\%"),i(Te,Be,_e,"_","\\_"),i(Ce,Be,_e,"_","\\_"),i(Ce,Be,_e,"_","\\textunderscore"),i(Te,Be,_e,"\u2220","\\angle",!0),i(Te,Be,_e,"\u221e","\\infty",!0),i(Te,Be,_e,"\u2032","\\prime"),i(Te,Be,_e,"\u25b3","\\triangle"),i(Te,Be,_e,"\u0393","\\Gamma",!0),i(Te,Be,_e,"\u0394","\\Delta",!0),i(Te,Be,_e,"\u0398","\\Theta",!0),i(Te,Be,_e,"\u039b","\\Lambda",!0),i(Te,Be,_e,"\u039e","\\Xi",!0),i(Te,Be,_e,"\u03a0","\\Pi",!0),i(Te,Be,_e,"\u03a3","\\Sigma",!0),i(Te,Be,_e,"\u03a5","\\Upsilon",!0),i(Te,Be,_e,"\u03a6","\\Phi",!0),i(Te,Be,_e,"\u03a8","\\Psi",!0),i(Te,Be,_e,"\u03a9","\\Omega",!0),i(Te,Be,_e,"A","\u0391"),i(Te,Be,_e,"B","\u0392"),i(Te,Be,_e,"E","\u0395"),i(Te,Be,_e,"Z","\u0396"),i(Te,Be,_e,"H","\u0397"),i(Te,Be,_e,"I","\u0399"),i(Te,Be,_e,"K","\u039a"),i(Te,Be,_e,"M","\u039c"),i(Te,Be,_e,"N","\u039d"),i(Te,Be,_e,"O","\u039f"),i(Te,Be,_e,"P","\u03a1"),i(Te,Be,_e,"T","\u03a4"),i(Te,Be,_e,"X","\u03a7"),i(Te,Be,_e,"\xac","\\neg",!0),i(Te,Be,_e,"\xac","\\lnot"),i(Te,Be,_e,"\u22a4","\\top"),i(Te,Be,_e,"\u22a5","\\bot"),i(Te,Be,_e,"\u2205","\\emptyset"),i(Te,Le,_e,"\u2205","\\varnothing"),i(Te,Be,Oe,"\u03b1","\\alpha",!0),i(Te,Be,Oe,"\u03b2","\\beta",!0),i(Te,Be,Oe,"\u03b3","\\gamma",!0),i(Te,Be,Oe,"\u03b4","\\delta",!0),i(Te,Be,Oe,"\u03f5","\\epsilon",!0),i(Te,Be,Oe,"\u03b6","\\zeta",!0),i(Te,Be,Oe,"\u03b7","\\eta",!0),i(Te,Be,Oe,"\u03b8","\\theta",!0),i(Te,Be,Oe,"\u03b9","\\iota",!0),i(Te,Be,Oe,"\u03ba","\\kappa",!0),i(Te,Be,Oe,"\u03bb","\\lambda",!0),i(Te,Be,Oe,"\u03bc","\\mu",!0),i(Te,Be,Oe,"\u03bd","\\nu",!0),i(Te,Be,Oe,"\u03be","\\xi",!0),i(Te,Be,Oe,"\u03bf","\\omicron",!0),i(Te,Be,Oe,"\u03c0","\\pi",!0),i(Te,Be,Oe,"\u03c1","\\rho",!0),i(Te,Be,Oe,"\u03c3","\\sigma",!0),i(Te,Be,Oe,"\u03c4","\\tau",!0),i(Te,Be,Oe,"\u03c5","\\upsilon",!0),i(Te,Be,Oe,"\u03d5","\\phi",!0),i(Te,Be,Oe,"\u03c7","\\chi",!0),i(Te,Be,Oe,"\u03c8","\\psi",!0),i(Te,Be,Oe,"\u03c9","\\omega",!0),i(Te,Be,Oe,"\u03b5","\\varepsilon",!0),i(Te,Be,Oe,"\u03d1","\\vartheta",!0),i(Te,Be,Oe,"\u03d6","\\varpi",!0),i(Te,Be,Oe,"\u03f1","\\varrho",!0),i(Te,Be,Oe,"\u03c2","\\varsigma",!0),i(Te,Be,Oe,"\u03c6","\\varphi",!0),i(Te,Be,qe,"\u2217","*",!0),i(Te,Be,qe,"+","+"),i(Te,Be,qe,"\u2212","-",!0),i(Te,Be,qe,"\u22c5","\\cdot",!0),i(Te,Be,qe,"\u2218","\\circ",!0),i(Te,Be,qe,"\xf7","\\div",!0),i(Te,Be,qe,"\xb1","\\pm",!0),i(Te,Be,qe,"\xd7","\\times",!0),i(Te,Be,qe,"\u2229","\\cap",!0),i(Te,Be,qe,"\u222a","\\cup",!0),i(Te,Be,qe,"\u2216","\\setminus",!0),i(Te,Be,qe,"\u2227","\\land"),i(Te,Be,qe,"\u2228","\\lor"),i(Te,Be,qe,"\u2227","\\wedge",!0),i(Te,Be,qe,"\u2228","\\vee",!0),i(Te,Be,_e,"\u221a","\\surd"),i(Te,Be,He,"\u27e8","\\langle",!0),i(Te,Be,He,"\u2223","\\lvert"),i(Te,Be,He,"\u2225","\\lVert"),i(Te,Be,Ee,"?","?"),i(Te,Be,Ee,"!","!"),i(Te,Be,Ee,"\u27e9","\\rangle",!0),i(Te,Be,Ee,"\u2223","\\rvert"),i(Te,Be,Ee,"\u2225","\\rVert"),i(Te,Be,Pe,"=","="),i(Te,Be,Pe,":",":"),i(Te,Be,Pe,"\u2248","\\approx",!0),i(Te,Be,Pe,"\u2245","\\cong",!0),i(Te,Be,Pe,"\u2265","\\ge"),i(Te,Be,Pe,"\u2265","\\geq",!0),i(Te,Be,Pe,"\u2190","\\gets"),i(Te,Be,Pe,">","\\gt",!0),i(Te,Be,Pe,"\u2208","\\in",!0),i(Te,Be,Pe,"\ue020","\\@not"),i(Te,Be,Pe,"\u2282","\\subset",!0),i(Te,Be,Pe,"\u2283","\\supset",!0),i(Te,Be,Pe,"\u2286","\\subseteq",!0),i(Te,Be,Pe,"\u2287","\\supseteq",!0),i(Te,Le,Pe,"\u2288","\\nsubseteq",!0),i(Te,Le,Pe,"\u2289","\\nsupseteq",!0),i(Te,Be,Pe,"\u22a8","\\models"),i(Te,Be,Pe,"\u2190","\\leftarrow",!0),i(Te,Be,Pe,"\u2264","\\le"),i(Te,Be,Pe,"\u2264","\\leq",!0),i(Te,Be,Pe,"<","\\lt",!0),i(Te,Be,Pe,"\u2192","\\rightarrow",!0),i(Te,Be,Pe,"\u2192","\\to"),i(Te,Le,Pe,"\u2271","\\ngeq",!0),i(Te,Le,Pe,"\u2270","\\nleq",!0),i(Te,Be,Fe,"\xa0","\\ "),i(Te,Be,Fe,"\xa0","\\space"),i(Te,Be,Fe,"\xa0","\\nobreakspace"),i(Ce,Be,Fe,"\xa0","\\ "),i(Ce,Be,Fe,"\xa0"," "),i(Ce,Be,Fe,"\xa0","\\space"),i(Ce,Be,Fe,"\xa0","\\nobreakspace"),i(Te,Be,Fe,null,"\\nobreak"),i(Te,Be,Fe,null,"\\allowbreak"),i(Te,Be,De,",",","),i(Te,Be,De,";",";"),i(Te,Le,qe,"\u22bc","\\barwedge",!0),i(Te,Le,qe,"\u22bb","\\veebar",!0),i(Te,Be,qe,"\u2299","\\odot",!0),i(Te,Be,qe,"\u2295","\\oplus",!0),i(Te,Be,qe,"\u2297","\\otimes",!0),i(Te,Be,_e,"\u2202","\\partial",!0),i(Te,Be,qe,"\u2298","\\oslash",!0),i(Te,Le,qe,"\u229a","\\circledcirc",!0),i(Te,Le,qe,"\u22a1","\\boxdot",!0),i(Te,Be,qe,"\u25b3","\\bigtriangleup"),i(Te,Be,qe,"\u25bd","\\bigtriangledown"),i(Te,Be,qe,"\u2020","\\dagger"),i(Te,Be,qe,"\u22c4","\\diamond"),i(Te,Be,qe,"\u22c6","\\star"),i(Te,Be,qe,"\u25c3","\\triangleleft"),i(Te,Be,qe,"\u25b9","\\triangleright"),i(Te,Be,He,"{","\\{"),i(Ce,Be,_e,"{","\\{"),i(Ce,Be,_e,"{","\\textbraceleft"),i(Te,Be,Ee,"}","\\}"),i(Ce,Be,_e,"}","\\}"),i(Ce,Be,_e,"}","\\textbraceright"),i(Te,Be,He,"{","\\lbrace"),i(Te,Be,Ee,"}","\\rbrace"),i(Te,Be,He,"[","\\lbrack",!0),i(Ce,Be,_e,"[","\\lbrack",!0),i(Te,Be,Ee,"]","\\rbrack",!0),i(Ce,Be,_e,"]","\\rbrack",!0),i(Te,Be,He,"(","\\lparen",!0),i(Te,Be,Ee,")","\\rparen",!0),i(Ce,Be,_e,"<","\\textless",!0),i(Ce,Be,_e,">","\\textgreater",!0),i(Te,Be,He,"\u230a","\\lfloor",!0),i(Te,Be,Ee,"\u230b","\\rfloor",!0),i(Te,Be,He,"\u2308","\\lceil",!0),i(Te,Be,Ee,"\u2309","\\rceil",!0),i(Te,Be,_e,"\\","\\backslash"),i(Te,Be,_e,"\u2223","|"),i(Te,Be,_e,"\u2223","\\vert"),i(Ce,Be,_e,"|","\\textbar",!0),i(Te,Be,_e,"\u2225","\\|"),i(Te,Be,_e,"\u2225","\\Vert"),i(Ce,Be,_e,"\u2225","\\textbardbl"),i(Ce,Be,_e,"~","\\textasciitilde"),i(Ce,Be,_e,"\\","\\textbackslash"),i(Ce,Be,_e,"^","\\textasciicircum"),i(Te,Be,Pe,"\u2191","\\uparrow",!0),i(Te,Be,Pe,"\u21d1","\\Uparrow",!0),i(Te,Be,Pe,"\u2193","\\downarrow",!0),i(Te,Be,Pe,"\u21d3","\\Downarrow",!0),i(Te,Be,Pe,"\u2195","\\updownarrow",!0),i(Te,Be,Pe,"\u21d5","\\Updownarrow",!0),i(Te,Be,Re,"\u2210","\\coprod"),i(Te,Be,Re,"\u22c1","\\bigvee"),i(Te,Be,Re,"\u22c0","\\bigwedge"),i(Te,Be,Re,"\u2a04","\\biguplus"),i(Te,Be,Re,"\u22c2","\\bigcap"),i(Te,Be,Re,"\u22c3","\\bigcup"),i(Te,Be,Re,"\u222b","\\int"),i(Te,Be,Re,"\u222b","\\intop"),i(Te,Be,Re,"\u222c","\\iint"),i(Te,Be,Re,"\u222d","\\iiint"),i(Te,Be,Re,"\u220f","\\prod"),i(Te,Be,Re,"\u2211","\\sum"),i(Te,Be,Re,"\u2a02","\\bigotimes"),i(Te,Be,Re,"\u2a01","\\bigoplus"),i(Te,Be,Re,"\u2a00","\\bigodot"),i(Te,Be,Re,"\u222e","\\oint"),i(Te,Be,Re,"\u222f","\\oiint"),i(Te,Be,Re,"\u2230","\\oiiint"),i(Te,Be,Re,"\u2a06","\\bigsqcup"),i(Te,Be,Re,"\u222b","\\smallint"),i(Ce,Be,Ie,"\u2026","\\textellipsis"),i(Te,Be,Ie,"\u2026","\\mathellipsis"),i(Ce,Be,Ie,"\u2026","\\ldots",!0),i(Te,Be,Ie,"\u2026","\\ldots",!0),i(Te,Be,Ie,"\u22ef","\\@cdots",!0),i(Te,Be,Ie,"\u22f1","\\ddots",!0),i(Te,Be,_e,"\u22ee","\\varvdots"),i(Te,Be,Ne,"\u02ca","\\acute"),i(Te,Be,Ne,"\u02cb","\\grave"),i(Te,Be,Ne,"\xa8","\\ddot"),i(Te,Be,Ne,"~","\\tilde"),i(Te,Be,Ne,"\u02c9","\\bar"),i(Te,Be,Ne,"\u02d8","\\breve"),i(Te,Be,Ne,"\u02c7","\\check"),i(Te,Be,Ne,"^","\\hat"),i(Te,Be,Ne,"\u20d7","\\vec"),i(Te,Be,Ne,"\u02d9","\\dot"),i(Te,Be,Ne,"\u02da","\\mathring"),i(Te,Be,Oe,"\ue131","\\@imath"),i(Te,Be,Oe,"\ue237","\\@jmath"),i(Te,Be,_e,"\u0131","\u0131"),i(Te,Be,_e,"\u0237","\u0237"),i(Ce,Be,_e,"\u0131","\\i",!0),i(Ce,Be,_e,"\u0237","\\j",!0),i(Ce,Be,_e,"\xdf","\\ss",!0),i(Ce,Be,_e,"\xe6","\\ae",!0),i(Ce,Be,_e,"\u0153","\\oe",!0),i(Ce,Be,_e,"\xf8","\\o",!0),i(Ce,Be,_e,"\xc6","\\AE",!0),i(Ce,Be,_e,"\u0152","\\OE",!0),i(Ce,Be,_e,"\xd8","\\O",!0),i(Ce,Be,Ne,"\u02ca","\\'"),i(Ce,Be,Ne,"\u02cb","\\`"),i(Ce,Be,Ne,"\u02c6","\\^"),i(Ce,Be,Ne,"\u02dc","\\~"),i(Ce,Be,Ne,"\u02c9","\\="),i(Ce,Be,Ne,"\u02d8","\\u"),i(Ce,Be,Ne,"\u02d9","\\."),i(Ce,Be,Ne,"\xb8","\\c"),i(Ce,Be,Ne,"\u02da","\\r"),i(Ce,Be,Ne,"\u02c7","\\v"),i(Ce,Be,Ne,"\xa8",'\\"'),i(Ce,Be,Ne,"\u02dd","\\H"),i(Ce,Be,Ne,"\u25ef","\\textcircled");var Ve={"--":!0,"---":!0,"``":!0,"''":!0};i(Ce,Be,_e,"\u2013","--",!0),i(Ce,Be,_e,"\u2013","\\textendash"),i(Ce,Be,_e,"\u2014","---",!0),i(Ce,Be,_e,"\u2014","\\textemdash"),i(Ce,Be,_e,"\u2018","`",!0),i(Ce,Be,_e,"\u2018","\\textquoteleft"),i(Ce,Be,_e,"\u2019","'",!0),i(Ce,Be,_e,"\u2019","\\textquoteright"),i(Ce,Be,_e,"\u201c","``",!0),i(Ce,Be,_e,"\u201c","\\textquotedblleft"),i(Ce,Be,_e,"\u201d","''",!0),i(Ce,Be,_e,"\u201d","\\textquotedblright"),i(Te,Be,_e,"\xb0","\\degree",!0),i(Ce,Be,_e,"\xb0","\\degree"),i(Ce,Be,_e,"\xb0","\\textdegree",!0),i(Te,Be,_e,"\xa3","\\pounds"),i(Te,Be,_e,"\xa3","\\mathsterling",!0),i(Ce,Be,_e,"\xa3","\\pounds"),i(Ce,Be,_e,"\xa3","\\textsterling",!0),i(Te,Le,_e,"\u2720","\\maltese"),i(Ce,Le,_e,"\u2720","\\maltese");for(var Ge='0123456789/@."',je=0;jet&&(t=a.height),a.depth>r&&(r=a.depth),a.maxFontSize>n&&(n=a.maxFontSize)}e.height=t,e.depth=r,e.maxFontSize=n},mt=function(e,t,r,n){var i=new fe(e,t,r,n);return ut(i),i},dt=function(e,t,r,n){return new fe(e,t,r,n)},pt=function(e){var t=new K(e);return ut(t),t},ft=function(e,t,r){var n="";switch(e){case"amsrm":n="AMS";break;case"textrm":n="Main";break;case"textsf":n="SansSerif";break;case"texttt":n="Typewriter";break;default:n=e}return n+"-"+("textbf"===t&&"textit"===r?"BoldItalic":"textbf"===t?"Bold":"textit"===t?"Italic":"Regular")},gt={mathbf:{variant:"bold",fontName:"Main-Bold"},mathrm:{variant:"normal",fontName:"Main-Regular"},textit:{variant:"italic",fontName:"Main-Italic"},mathit:{variant:"italic",fontName:"Main-Italic"},mathnormal:{variant:"italic",fontName:"Math-Italic"},mathbb:{variant:"double-struck",fontName:"AMS-Regular"},mathcal:{variant:"script",fontName:"Caligraphic-Regular"},mathfrak:{variant:"fraktur",fontName:"Fraktur-Regular"},mathscr:{variant:"script",fontName:"Script-Regular"},mathsf:{variant:"sans-serif",fontName:"SansSerif-Regular"},mathtt:{variant:"monospace",fontName:"Typewriter-Regular"}},vt={vec:["vec",.471,.714],oiintSize1:["oiintSize1",.957,.499],oiintSize2:["oiintSize2",1.472,.659],oiiintSize1:["oiiintSize1",1.304,.499],oiiintSize2:["oiiintSize2",1.98,.659]},yt={fontMap:gt,makeSymbol:ht,mathsym:function(e,t,r,n){return void 0===n&&(n=[]),"boldsymbol"===r.font&<(e,"Main-Bold",t).metrics?ht(e,"Main-Bold",t,r,n.concat(["mathbf"])):"\\"===e||"main"===ze[t][e].font?ht(e,"Main-Regular",t,r,n):ht(e,"AMS-Regular",t,r,n.concat(["amsrm"]))},makeSpan:mt,makeSvgSpan:dt,makeLineSpan:function(e,t,r){var n=mt([e],[],t);return n.height=Math.max(r||t.fontMetrics().defaultRuleThickness,t.minRuleThickness),n.style.borderBottomWidth=ce(n.height),n.maxFontSize=1,n},makeAnchor:function(e,t,r,n){var i=new ge(e,t,r,n);return ut(i),i},makeFragment:pt,wrapFragment:function(e,t){return e instanceof K?mt([],[e],t):e},makeVList:function(e){for(var t=function(e){if("individualShift"===e.positionType){for(var t=e.children,r=[t[0]],n=-t[0].shift-t[0].elem.depth,i=n,a=1;a0)return ht(i,l,n,t,a.concat(h));if(s){var u,m;if("boldsymbol"===s){var d=function(e,t,r,n,a){return"textord"!==a&<(i,"Math-BoldItalic",t).metrics?{fontName:"Math-BoldItalic",fontClass:"boldsymbol"}:{fontName:"Main-Bold",fontClass:"mathbf"}}(0,n,0,0,r);u=d.fontName,m=[d.fontClass]}else o?(u=gt[s].fontName,m=[s]):(u=ft(s,t.fontWeight,t.fontShape),m=[s,t.fontWeight,t.fontShape]);if(lt(i,u,n).metrics)return ht(i,u,n,t,a.concat(m));if(Ve.hasOwnProperty(i)&&"Typewriter"===u.slice(0,10)){for(var p=[],f=0;f0&&(e.className=ue(this.classes));for(var r=0;r0&&(e+=' class ="'+I.escape(ue(this.classes))+'"'),e+=">";for(var r=0;r"},t.toText=function(){return this.children.map(function(e){return e.toText()}).join("")},e}(),Vt=function(){function e(e){this.text=void 0,this.text=e}var t=e.prototype;return t.toNode=function(){return document.createTextNode(this.text)},t.toMarkup=function(){return I.escape(this.toText())},t.toText=function(){return this.text},e}(),Gt={MathNode:_t,TextNode:Vt,SpaceNode:function(){function e(e){this.width=void 0,this.character=void 0,this.width=e,this.character=e>=.05555&&e<=.05556?"\u200a":e>=.1666&&e<=.1667?"\u2009":e>=.2222&&e<=.2223?"\u2005":e>=.2777&&e<=.2778?"\u2005\u200a":e>=-.05556&&e<=-.05555?"\u200a\u2063":e>=-.1667&&e<=-.1666?"\u2009\u2063":e>=-.2223&&e<=-.2222?"\u205f\u2063":e>=-.2778&&e<=-.2777?"\u2005\u2063":null}var t=e.prototype;return t.toNode=function(){if(this.character)return document.createTextNode(this.character);var e=document.createElementNS("http://www.w3.org/1998/Math/MathML","mspace");return e.setAttribute("width",ce(this.width)),e},t.toMarkup=function(){return this.character?""+this.character+"":''},t.toText=function(){return this.character?this.character:" "},e}(),newDocumentFragment:h},jt=function(e,t,r){return!ze[t][e]||!ze[t][e].replace||55349===e.charCodeAt(0)||Ve.hasOwnProperty(e)&&r&&(r.fontFamily&&"tt"===r.fontFamily.slice(4,6)||r.font&&"tt"===r.font.slice(4,6))||(e=ze[t][e].replace),new Gt.TextNode(e)},Ut=function(e){return 1===e.length?e[0]:new Gt.MathNode("mrow",e)},Wt=function(e,t){if("texttt"===t.fontFamily)return"monospace";if("textsf"===t.fontFamily)return"textit"===t.fontShape&&"textbf"===t.fontWeight?"sans-serif-bold-italic":"textit"===t.fontShape?"sans-serif-italic":"textbf"===t.fontWeight?"bold-sans-serif":"sans-serif";if("textit"===t.fontShape&&"textbf"===t.fontWeight)return"bold-italic";if("textit"===t.fontShape)return"italic";if("textbf"===t.fontWeight)return"bold";var n=t.font;if(!n||"mathnormal"===n)return null;var i=e.mode;if("mathit"===n)return"italic";if("boldsymbol"===n)return"textord"===e.type?"bold":"bold-italic";if("mathbf"===n)return"bold";if("mathbb"===n)return"double-struck";if("mathfrak"===n)return"fraktur";if("mathscr"===n||"mathcal"===n)return"script";if("mathsf"===n)return"sans-serif";if("mathtt"===n)return"monospace";var a=e.text;return I.contains(["\\imath","\\jmath"],a)?null:(ze[i][a]&&ze[i][a].replace&&(a=ze[i][a].replace),r(a,yt.fontMap[n].fontName,i)?yt.fontMap[n].variant:null)},Yt=function(e,t,r){if(1===e.length){var n=$t(e[0],t);return r&&n instanceof _t&&"mo"===n.type&&(n.setAttribute("lspace","0em"),n.setAttribute("rspace","0em")),[n]}for(var i,a=[],o=0;o0&&(d.text=d.text.slice(0,1)+"\u0338"+d.text.slice(1),a.pop())}}}a.push(s),i=s}return a},Xt=function(e,t,r){return Ut(Yt(e,t,r))},$t=function(e,t){if(!e)return new Gt.MathNode("mrow");if(zt[e.type])return zt[e.type](e,t);throw new B("Got group of unknown type: '"+e.type+"'")},Kt=function(e){return new ae({style:e.displayMode?U.DISPLAY:U.TEXT,maxSize:e.maxSize,minRuleThickness:e.minRuleThickness})},Zt=function(e,t){if(t.displayMode){var r=["katex-display"];t.leqno&&r.push("leqno"),t.fleqn&&r.push("fleqn"),e=yt.makeSpan(r,[e])}return e},Jt=function(e,t,r){var n,i=Kt(r);if("mathml"===r.output)return c(e,t,i,r.displayMode,!0);if("html"===r.output){var a=l(e,i);n=yt.makeSpan(["katex"],[a])}else{var o=c(e,t,i,r.displayMode,!1),s=l(e,i);n=yt.makeSpan(["katex"],[o,s])}return Zt(n,r)},Qt={widehat:"^",widecheck:"\u02c7",widetilde:"~",utilde:"~",overleftarrow:"\u2190",underleftarrow:"\u2190",xleftarrow:"\u2190",overrightarrow:"\u2192",underrightarrow:"\u2192",xrightarrow:"\u2192",underbrace:"\u23df",overbrace:"\u23de",overgroup:"\u23e0",undergroup:"\u23e1",overleftrightarrow:"\u2194",underleftrightarrow:"\u2194",xleftrightarrow:"\u2194",Overrightarrow:"\u21d2",xRightarrow:"\u21d2",overleftharpoon:"\u21bc",xleftharpoonup:"\u21bc",overrightharpoon:"\u21c0",xrightharpoonup:"\u21c0",xLeftarrow:"\u21d0",xLeftrightarrow:"\u21d4",xhookleftarrow:"\u21a9",xhookrightarrow:"\u21aa",xmapsto:"\u21a6",xrightharpoondown:"\u21c1",xleftharpoondown:"\u21bd",xrightleftharpoons:"\u21cc",xleftrightharpoons:"\u21cb",xtwoheadleftarrow:"\u219e",xtwoheadrightarrow:"\u21a0",xlongequal:"=",xtofrom:"\u21c4",xrightleftarrows:"\u21c4",xrightequilibrium:"\u21cc",xleftequilibrium:"\u21cb","\\cdrightarrow":"\u2192","\\cdleftarrow":"\u2190","\\cdlongequal":"="},er={overrightarrow:[["rightarrow"],.888,522,"xMaxYMin"],overleftarrow:[["leftarrow"],.888,522,"xMinYMin"],underrightarrow:[["rightarrow"],.888,522,"xMaxYMin"],underleftarrow:[["leftarrow"],.888,522,"xMinYMin"],xrightarrow:[["rightarrow"],1.469,522,"xMaxYMin"],"\\cdrightarrow":[["rightarrow"],3,522,"xMaxYMin"],xleftarrow:[["leftarrow"],1.469,522,"xMinYMin"],"\\cdleftarrow":[["leftarrow"],3,522,"xMinYMin"],Overrightarrow:[["doublerightarrow"],.888,560,"xMaxYMin"],xRightarrow:[["doublerightarrow"],1.526,560,"xMaxYMin"],xLeftarrow:[["doubleleftarrow"],1.526,560,"xMinYMin"],overleftharpoon:[["leftharpoon"],.888,522,"xMinYMin"],xleftharpoonup:[["leftharpoon"],.888,522,"xMinYMin"],xleftharpoondown:[["leftharpoondown"],.888,522,"xMinYMin"],overrightharpoon:[["rightharpoon"],.888,522,"xMaxYMin"],xrightharpoonup:[["rightharpoon"],.888,522,"xMaxYMin"],xrightharpoondown:[["rightharpoondown"],.888,522,"xMaxYMin"],xlongequal:[["longequal"],.888,334,"xMinYMin"],"\\cdlongequal":[["longequal"],3,334,"xMinYMin"],xtwoheadleftarrow:[["twoheadleftarrow"],.888,334,"xMinYMin"],xtwoheadrightarrow:[["twoheadrightarrow"],.888,334,"xMaxYMin"],overleftrightarrow:[["leftarrow","rightarrow"],.888,522],overbrace:[["leftbrace","midbrace","rightbrace"],1.6,548],underbrace:[["leftbraceunder","midbraceunder","rightbraceunder"],1.6,548],underleftrightarrow:[["leftarrow","rightarrow"],.888,522],xleftrightarrow:[["leftarrow","rightarrow"],1.75,522],xLeftrightarrow:[["doubleleftarrow","doublerightarrow"],1.75,560],xrightleftharpoons:[["leftharpoondownplus","rightharpoonplus"],1.75,716],xleftrightharpoons:[["leftharpoonplus","rightharpoondownplus"],1.75,716],xhookleftarrow:[["leftarrow","righthook"],1.08,522],xhookrightarrow:[["lefthook","rightarrow"],1.08,522],overlinesegment:[["leftlinesegment","rightlinesegment"],.888,522],underlinesegment:[["leftlinesegment","rightlinesegment"],.888,522],overgroup:[["leftgroup","rightgroup"],.888,342],undergroup:[["leftgroupunder","rightgroupunder"],.888,342],xmapsto:[["leftmapsto","rightarrow"],1.5,522],xtofrom:[["leftToFrom","rightToFrom"],1.75,528],xrightleftarrows:[["baraboveleftarrow","rightarrowabovebar"],1.75,901],xrightequilibrium:[["baraboveshortleftharpoon","rightharpoonaboveshortbar"],1.75,716],xleftequilibrium:[["shortbaraboveleftharpoon","shortrightharpoonabovebar"],1.75,716]},tr=function(e,t,r,n,i){var a,o=e.height+e.depth+r+n;if(/fbox|color|angl/.test(t)){if(a=yt.makeSpan(["stretchy",t],[],i),"fbox"===t){var s=i.color&&i.getColor();s&&(a.style.borderColor=s)}}else{var l=[];/^[bx]cancel$/.test(t)&&l.push(new ke({x1:"0",y1:"0",x2:"100%",y2:"100%","stroke-width":"0.046em"})),/^x?cancel$/.test(t)&&l.push(new ke({x1:"0",y1:"100%",x2:"100%",y2:"0","stroke-width":"0.046em"}));var h=new xe(l,{width:"100%",height:ce(o)});a=yt.makeSvgSpan([],[h],i)}return a.height=o,a.style.height=ce(o),a},rr=function(e){var t=new Gt.MathNode("mo",[new Gt.TextNode(Qt[e.replace(/^\\/,"")])]);return t.setAttribute("stretchy","true"),t},nr=function(e,t){var r=function(){var r=4e5,n=e.label.slice(1);if(I.contains(["widehat","widecheck","widetilde","utilde"],n)){var i,a,o,s="ordgroup"===(d=e.base).type?d.body.length:1;if(s>5)"widehat"===n||"widecheck"===n?(i=420,r=2364,o=.42,a=n+"4"):(i=312,r=2340,o=.34,a="tilde4");else{var l=[1,1,2,2,3,3][s];"widehat"===n||"widecheck"===n?(r=[0,1062,2364,2364,2364][l],i=[0,239,300,360,420][l],o=[0,.24,.3,.3,.36,.42][l],a=n+l):(r=[0,600,1033,2339,2340][l],i=[0,260,286,306,312][l],o=[0,.26,.286,.3,.306,.34][l],a="tilde"+l)}var h=new we(a),c=new xe([h],{width:"100%",height:ce(o),viewBox:"0 0 "+r+" "+i,preserveAspectRatio:"none"});return{span:yt.makeSvgSpan([],[c],t),minWidth:0,height:o}}var u,m,d,p=[],f=er[n],g=f[0],v=f[1],y=f[2],b=y/1e3,x=g.length;if(1===x)u=["hide-tail"],m=[f[3]];else if(2===x)u=["halfarrow-left","halfarrow-right"],m=["xMinYMin","xMaxYMin"];else{if(3!==x)throw new Error("Correct katexImagesData or update code here to support\n "+x+" children.");u=["brace-left","brace-center","brace-right"],m=["xMinYMin","xMidYMin","xMaxYMin"]}for(var w=0;w0&&(n.style.minWidth=ce(i)),n},ir=function(e,t){var r,i,a;e&&"supsub"===e.type?(r=(i=u(e.base,"accent")).base,e.base=r,a=function(e){if(e instanceof fe)return e;throw new Error("Expected span but got "+String(e)+".")}(Ft(e,t)),e.base=i):r=(i=u(e,"accent")).base;var o=Ft(r,t.havingCrampedStyle()),s=0;if(i.isShifty&&I.isCharacterBox(r)){var l=I.getBaseElem(r);s=n(Ft(l,t.havingCrampedStyle())).skew}var h,c="\\c"===i.label,m=c?o.height+o.depth:Math.min(o.height,t.fontMetrics().xHeight);if(i.isStretchy)h=nr(i,t),h=yt.makeVList({positionType:"firstBaseline",children:[{type:"elem",elem:o},{type:"elem",elem:h,wrapperClasses:["svg-align"],wrapperStyle:s>0?{width:"calc(100% - "+ce(2*s)+")",marginLeft:ce(2*s)}:void 0}]},t);else{var d,p;"\\vec"===i.label?(d=yt.staticSvg("vec",t),p=yt.svgData.vec[1]):((d=n(d=yt.makeOrd({mode:i.mode,text:i.label},t,"textord"))).italic=0,p=d.width,c&&(m+=d.depth)),h=yt.makeSpan(["accent-body"],[d]);var f="\\textcircled"===i.label;f&&(h.classes.push("accent-full"),m=o.height);var g=s;f||(g-=p/2),h.style.left=ce(g),"\\textcircled"===i.label&&(h.style.top=".2em"),h=yt.makeVList({positionType:"firstBaseline",children:[{type:"elem",elem:o},{type:"kern",size:-m},{type:"elem",elem:h}]},t)}var v=yt.makeSpan(["mord","accent"],[h],t);return a?(a.children[0]=v,a.height=Math.max(v.height,a.height),a.classes[0]="mord",a):v},ar=function(e,t){var r=e.isStretchy?rr(e.label):new Gt.MathNode("mo",[jt(e.label,e.mode)]),n=new Gt.MathNode("mover",[$t(e.base,t),r]);return n.setAttribute("accent","true"),n},or=new RegExp(["\\acute","\\grave","\\ddot","\\tilde","\\bar","\\breve","\\check","\\hat","\\vec","\\dot","\\mathring"].map(function(e){return"\\"+e}).join("|"));a({type:"accent",names:["\\acute","\\grave","\\ddot","\\tilde","\\bar","\\breve","\\check","\\hat","\\vec","\\dot","\\mathring","\\widecheck","\\widehat","\\widetilde","\\overrightarrow","\\overleftarrow","\\Overrightarrow","\\overleftrightarrow","\\overgroup","\\overlinesegment","\\overleftharpoon","\\overrightharpoon"],props:{numArgs:1},handler:function(e,t){var r=Tt(t[0]),n=!or.test(e.funcName),i=!n||"\\widehat"===e.funcName||"\\widetilde"===e.funcName||"\\widecheck"===e.funcName;return{type:"accent",mode:e.parser.mode,label:e.funcName,isStretchy:n,isShifty:i,base:r}},htmlBuilder:ir,mathmlBuilder:ar}),a({type:"accent",names:["\\'","\\`","\\^","\\~","\\=","\\u","\\.",'\\"',"\\c","\\r","\\H","\\v","\\textcircled"],props:{numArgs:1,allowedInText:!0,allowedInMath:!0,argTypes:["primitive"]},handler:function(e,t){var r=t[0],n=e.parser.mode;return"math"===n&&(e.parser.settings.reportNonstrict("mathVsTextAccents","LaTeX's accent "+e.funcName+" works only in text mode"),n="text"),{type:"accent",mode:n,label:e.funcName,isStretchy:!1,isShifty:!0,base:r}},htmlBuilder:ir,mathmlBuilder:ar}),a({type:"accentUnder",names:["\\underleftarrow","\\underrightarrow","\\underleftrightarrow","\\undergroup","\\underlinesegment","\\utilde"],props:{numArgs:1},handler:function(e,t){var r=e.parser,n=e.funcName,i=t[0];return{type:"accentUnder",mode:r.mode,label:n,base:i}},htmlBuilder:function(e,t){var r=Ft(e.base,t),n=nr(e,t),i="\\utilde"===e.label?.12:0,a=yt.makeVList({positionType:"top",positionData:r.height,children:[{type:"elem",elem:n,wrapperClasses:["svg-align"]},{type:"kern",size:i},{type:"elem",elem:r}]},t);return yt.makeSpan(["mord","accentunder"],[a],t)},mathmlBuilder:function(e,t){var r=rr(e.label),n=new Gt.MathNode("munder",[$t(e.base,t),r]);return n.setAttribute("accentunder","true"),n}});var sr=function(e){var t=new Gt.MathNode("mpadded",e?[e]:[]);return t.setAttribute("width","+0.6em"),t.setAttribute("lspace","0.3em"),t};a({type:"xArrow",names:["\\xleftarrow","\\xrightarrow","\\xLeftarrow","\\xRightarrow","\\xleftrightarrow","\\xLeftrightarrow","\\xhookleftarrow","\\xhookrightarrow","\\xmapsto","\\xrightharpoondown","\\xrightharpoonup","\\xleftharpoondown","\\xleftharpoonup","\\xrightleftharpoons","\\xleftrightharpoons","\\xlongequal","\\xtwoheadrightarrow","\\xtwoheadleftarrow","\\xtofrom","\\xrightleftarrows","\\xrightequilibrium","\\xleftequilibrium","\\\\cdrightarrow","\\\\cdleftarrow","\\\\cdlongequal"],props:{numArgs:1,numOptionalArgs:1},handler:function(e,t,r){var n=e.parser,i=e.funcName;return{type:"xArrow",mode:n.mode,label:i,body:t[0],below:r[0]}},htmlBuilder:function(e,t){var r,n=t.style,i=t.havingStyle(n.sup()),a=yt.wrapFragment(Ft(e.body,i,t),t),o="\\x"===e.label.slice(0,2)?"x":"cd";a.classes.push(o+"-arrow-pad"),e.below&&(i=t.havingStyle(n.sub()),(r=yt.wrapFragment(Ft(e.below,i,t),t)).classes.push(o+"-arrow-pad"));var s,l=nr(e,t),h=-t.fontMetrics().axisHeight+.5*l.height,c=-t.fontMetrics().axisHeight-.5*l.height-.111;if((a.depth>.25||"\\xleftequilibrium"===e.label)&&(c-=a.depth),r){var u=-t.fontMetrics().axisHeight+r.height+.5*l.height+.111;s=yt.makeVList({positionType:"individualShift",children:[{type:"elem",elem:a,shift:c},{type:"elem",elem:l,shift:h},{type:"elem",elem:r,shift:u}]},t)}else s=yt.makeVList({positionType:"individualShift",children:[{type:"elem",elem:a,shift:c},{type:"elem",elem:l,shift:h}]},t);return s.children[0].children[0].children[1].classes.push("svg-align"),yt.makeSpan(["mrel","x-arrow"],[s],t)},mathmlBuilder:function(e,t){var r,n=rr(e.label);if(n.setAttribute("minsize","x"===e.label.charAt(0)?"1.75em":"3.0em"),e.body){var i=sr($t(e.body,t));if(e.below){var a=sr($t(e.below,t));r=new Gt.MathNode("munderover",[n,a,i])}else r=new Gt.MathNode("mover",[n,i])}else if(e.below){var o=sr($t(e.below,t));r=new Gt.MathNode("munder",[n,o])}else r=sr(),r=new Gt.MathNode("mover",[n,r]);return r}});var lr=yt.makeSpan;a({type:"mclass",names:["\\mathord","\\mathbin","\\mathrel","\\mathopen","\\mathclose","\\mathpunct","\\mathinner"],props:{numArgs:1,primitive:!0},handler:function(e,t){var r=e.parser,n=e.funcName,i=t[0];return{type:"mclass",mode:r.mode,mclass:"m"+n.slice(5),body:Ct(i),isCharacterBox:I.isCharacterBox(i)}},htmlBuilder:p,mathmlBuilder:f});var hr=function(e){var t="ordgroup"===e.type&&e.body.length?e.body[0]:e;return"atom"!==t.type||"bin"!==t.family&&"rel"!==t.family?"mord":"m"+t.family};a({type:"mclass",names:["\\@binrel"],props:{numArgs:2},handler:function(e,t){return{type:"mclass",mode:e.parser.mode,mclass:hr(t[0]),body:Ct(t[1]),isCharacterBox:I.isCharacterBox(t[1])}}}),a({type:"mclass",names:["\\stackrel","\\overset","\\underset"],props:{numArgs:2},handler:function(e,t){var r,n=e.parser,i=e.funcName,a=t[1],o=t[0];r="\\stackrel"!==i?hr(a):"mrel";var s={type:"op",mode:a.mode,limits:!0,alwaysHandleSupSub:!0,parentIsSupSub:!1,symbol:!1,suppressBaseShift:"\\stackrel"!==i,body:Ct(a)},l={type:"supsub",mode:o.mode,base:s,sup:"\\underset"===i?null:o,sub:"\\underset"===i?o:null};return{type:"mclass",mode:n.mode,mclass:r,body:[l],isCharacterBox:I.isCharacterBox(l)}},htmlBuilder:p,mathmlBuilder:f}),a({type:"pmb",names:["\\pmb"],props:{numArgs:1,allowedInText:!0},handler:function(e,t){return{type:"pmb",mode:e.parser.mode,mclass:hr(t[0]),body:Ct(t[0])}},htmlBuilder:function(e,t){var r=It(e.body,t,!0),n=yt.makeSpan([e.mclass],r,t);return n.style.textShadow="0.02em 0.01em 0.04px",n},mathmlBuilder:function(e,t){var r=Yt(e.body,t),n=new Gt.MathNode("mstyle",r);return n.setAttribute("style","text-shadow: 0.02em 0.01em 0.04px"),n}});var cr={">":"\\\\cdrightarrow","<":"\\\\cdleftarrow","=":"\\\\cdlongequal",A:"\\uparrow",V:"\\downarrow","|":"\\Vert",".":"no arrow"},ur=function(e){return"textord"===e.type&&"@"===e.text};a({type:"cdlabel",names:["\\\\cdleft","\\\\cdright"],props:{numArgs:1},handler:function(e,t){var r=e.parser,n=e.funcName;return{type:"cdlabel",mode:r.mode,side:n.slice(4),label:t[0]}},htmlBuilder:function(e,t){var r=t.havingStyle(t.style.sup()),n=yt.wrapFragment(Ft(e.label,r,t),t);return n.classes.push("cd-label-"+e.side),n.style.bottom=ce(.8-n.depth),n.height=0,n.depth=0,n},mathmlBuilder:function(e,t){var r=new Gt.MathNode("mrow",[$t(e.label,t)]);return(r=new Gt.MathNode("mpadded",[r])).setAttribute("width","0"),"left"===e.side&&r.setAttribute("lspace","-1width"),r.setAttribute("voffset","0.7em"),(r=new Gt.MathNode("mstyle",[r])).setAttribute("displaystyle","false"),r.setAttribute("scriptlevel","1"),r}}),a({type:"cdlabelparent",names:["\\\\cdparent"],props:{numArgs:1},handler:function(e,t){return{type:"cdlabelparent",mode:e.parser.mode,fragment:t[0]}},htmlBuilder:function(e,t){var r=yt.wrapFragment(Ft(e.fragment,t),t);return r.classes.push("cd-vert-arrow"),r},mathmlBuilder:function(e,t){return new Gt.MathNode("mrow",[$t(e.fragment,t)])}}),a({type:"textord",names:["\\@char"],props:{numArgs:1,allowedInText:!0},handler:function(e,t){for(var r=e.parser,n=u(t[0],"ordgroup").body,i="",a=0;a=1114111)throw new B("\\@char with invalid code point "+i);return s<=65535?o=String.fromCharCode(s):(s-=65536,o=String.fromCharCode(55296+(s>>10),56320+(1023&s))),{type:"textord",mode:r.mode,text:o}}});var mr=function(e,t){var r=It(e.body,t.withColor(e.color),!1);return yt.makeFragment(r)},dr=function(e,t){var r=Yt(e.body,t.withColor(e.color)),n=new Gt.MathNode("mstyle",r);return n.setAttribute("mathcolor",e.color),n};a({type:"color",names:["\\textcolor"],props:{numArgs:2,allowedInText:!0,argTypes:["color","original"]},handler:function(e,t){var r=e.parser,n=u(t[0],"color-token").color,i=t[1];return{type:"color",mode:r.mode,color:n,body:Ct(i)}},htmlBuilder:mr,mathmlBuilder:dr}),a({type:"color",names:["\\color"],props:{numArgs:1,allowedInText:!0,argTypes:["color"]},handler:function(e,t){var r=e.parser,n=e.breakOnTokenText,i=u(t[0],"color-token").color;r.gullet.macros.set("\\current@color",i);var a=r.parseExpression(!0,n);return{type:"color",mode:r.mode,color:i,body:a}},htmlBuilder:mr,mathmlBuilder:dr}),a({type:"cr",names:["\\\\"],props:{numArgs:0,numOptionalArgs:0,allowedInText:!0},handler:function(e){var t=e.parser,r="["===t.gullet.future().text?t.parseSizeGroup(!0):null,n=!t.settings.displayMode||!t.settings.useStrictBehavior("newLineInDisplayMode","In LaTeX, \\\\ or \\newline does nothing in display mode");return{type:"cr",mode:t.mode,newLine:n,size:r&&u(r,"size").value}},htmlBuilder:function(e,t){var r=yt.makeSpan(["mspace"],[],t);return e.newLine&&(r.classes.push("newline"),e.size&&(r.style.marginTop=ce(he(e.size,t)))),r},mathmlBuilder:function(e,t){var r=new Gt.MathNode("mspace");return e.newLine&&(r.setAttribute("linebreak","newline"),e.size&&r.setAttribute("height",ce(he(e.size,t)))),r}});var pr={"\\global":"\\global","\\long":"\\\\globallong","\\\\globallong":"\\\\globallong","\\def":"\\gdef","\\gdef":"\\gdef","\\edef":"\\xdef","\\xdef":"\\xdef","\\let":"\\\\globallet","\\futurelet":"\\\\globalfuture"},fr=function(e){var t=e.text;if(/^(?:[\\{}$&#^_]|EOF)$/.test(t))throw new B("Expected a control sequence",e);return t},gr=function(e,t,r,n){var i=e.gullet.macros.get(r.text);null==i&&(r.noexpand=!0,i={tokens:[r],numArgs:0,unexpandable:!e.gullet.isExpandable(r.text)}),e.gullet.macros.set(t,i,n)};a({type:"internal",names:["\\global","\\long","\\\\globallong"],props:{numArgs:0,allowedInText:!0},handler:function(e){var t=e.parser,r=e.funcName;t.consumeSpaces();var n=t.fetch();if(pr[n.text])return"\\global"!==r&&"\\\\globallong"!==r||(n.text=pr[n.text]),u(t.parseFunction(),"internal");throw new B("Invalid token after macro prefix",n)}}),a({type:"internal",names:["\\def","\\gdef","\\edef","\\xdef"],props:{numArgs:0,allowedInText:!0,primitive:!0},handler:function(e){var t=e.parser,r=e.funcName,n=t.gullet.popToken(),i=n.text;if(/^(?:[\\{}$&#^_]|EOF)$/.test(i))throw new B("Expected a control sequence",n);for(var a,o=0,s=[[]];"{"!==t.gullet.future().text;)if("#"===(n=t.gullet.popToken()).text){if("{"===t.gullet.future().text){a=t.gullet.future(),s[o].push("{");break}if(n=t.gullet.popToken(),!/^[1-9]$/.test(n.text))throw new B('Invalid argument number "'+n.text+'"');if(parseInt(n.text)!==o+1)throw new B('Argument number "'+n.text+'" out of order');o++,s.push([])}else{if("EOF"===n.text)throw new B("Expected a macro definition");s[o].push(n.text)}var l=t.gullet.consumeArg().tokens;return a&&l.unshift(a),"\\edef"!==r&&"\\xdef"!==r||(l=t.gullet.expandTokens(l)).reverse(),t.gullet.macros.set(i,{tokens:l,numArgs:o,delimiters:s},r===pr[r]),{type:"internal",mode:t.mode}}}),a({type:"internal",names:["\\let","\\\\globallet"],props:{numArgs:0,allowedInText:!0,primitive:!0},handler:function(e){var t=e.parser,r=e.funcName,n=fr(t.gullet.popToken());t.gullet.consumeSpaces();var i=function(e){var t=e.gullet.popToken();return"="===t.text&&" "===(t=e.gullet.popToken()).text&&(t=e.gullet.popToken()),t}(t);return gr(t,n,i,"\\\\globallet"===r),{type:"internal",mode:t.mode}}}),a({type:"internal",names:["\\futurelet","\\\\globalfuture"],props:{numArgs:0,allowedInText:!0,primitive:!0},handler:function(e){var t=e.parser,r=e.funcName,n=fr(t.gullet.popToken()),i=t.gullet.popToken(),a=t.gullet.popToken();return gr(t,n,a,"\\\\globalfuture"===r),t.gullet.pushToken(a),t.gullet.pushToken(i),{type:"internal",mode:t.mode}}});var vr=function(e,t,n){var i=r(ze.math[e]&&ze.math[e].replace||e,t,n);if(!i)throw new Error("Unsupported symbol "+e+" and font size "+t+".");return i},yr=function(e,t,r,n){var i=r.havingBaseStyle(t),a=yt.makeSpan(n.concat(i.sizingClasses(r)),[e],r),o=i.sizeMultiplier/r.sizeMultiplier;return a.height*=o,a.depth*=o,a.maxFontSize=i.sizeMultiplier,a},br=function(e,t,r){var n=t.havingBaseStyle(r),i=(1-t.sizeMultiplier/n.sizeMultiplier)*t.fontMetrics().axisHeight;e.classes.push("delimcenter"),e.style.top=ce(i),e.height-=i,e.depth+=i},xr=function(e,t,r,n,i,a){var o=function(e,t,r,n){return yt.makeSymbol(e,"Size"+t+"-Regular",r,n)}(e,t,i,n),s=yr(yt.makeSpan(["delimsizing","size"+t],[o],n),U.TEXT,n,a);return r&&br(s,n,U.TEXT),s},wr=function(e,t,r){var n;return n="Size1-Regular"===t?"delim-size1":"delim-size4",{type:"elem",elem:yt.makeSpan(["delimsizinginner",n],[yt.makeSpan([],[yt.makeSymbol(e,t,r)])])}},kr=function(e,t,r){var n=Z["Size4-Regular"][e.charCodeAt(0)]?Z["Size4-Regular"][e.charCodeAt(0)][4]:Z["Size1-Regular"][e.charCodeAt(0)][4],i=new we("inner",function(e,t){switch(e){case"\u239c":return"M291 0 H417 V"+t+" H291z M291 0 H417 V"+t+" H291z";case"\u2223":return"M145 0 H188 V"+t+" H145z M145 0 H188 V"+t+" H145z";case"\u2225":return"M145 0 H188 V"+t+" H145z M145 0 H188 V"+t+" H145zM367 0 H410 V"+t+" H367z M367 0 H410 V"+t+" H367z";case"\u239f":return"M457 0 H583 V"+t+" H457z M457 0 H583 V"+t+" H457z";case"\u23a2":return"M319 0 H403 V"+t+" H319z M319 0 H403 V"+t+" H319z";case"\u23a5":return"M263 0 H347 V"+t+" H263z M263 0 H347 V"+t+" H263z";case"\u23aa":return"M384 0 H504 V"+t+" H384z M384 0 H504 V"+t+" H384z";case"\u23d0":return"M312 0 H355 V"+t+" H312z M312 0 H355 V"+t+" H312z";case"\u2016":return"M257 0 H300 V"+t+" H257z M257 0 H300 V"+t+" H257zM478 0 H521 V"+t+" H478z M478 0 H521 V"+t+" H478z";default:return""}}(e,Math.round(1e3*t))),a=new xe([i],{width:ce(n),height:ce(t),style:"width:"+ce(n),viewBox:"0 0 "+1e3*n+" "+Math.round(1e3*t),preserveAspectRatio:"xMinYMin"}),o=yt.makeSvgSpan([],[a],r);return o.height=t,o.style.height=ce(t),o.style.width=ce(n),{type:"elem",elem:o}},Sr={type:"kern",size:-.008},Ar=["|","\\lvert","\\rvert","\\vert"],Mr=["\\|","\\lVert","\\rVert","\\Vert"],zr=function(e,t,r,n,i,a){var o,s,l,h,c="",u=0;o=l=h=e,s=null;var m="Size1-Regular";"\\uparrow"===e?l=h="\u23d0":"\\Uparrow"===e?l=h="\u2016":"\\downarrow"===e?o=l="\u23d0":"\\Downarrow"===e?o=l="\u2016":"\\updownarrow"===e?(o="\\uparrow",l="\u23d0",h="\\downarrow"):"\\Updownarrow"===e?(o="\\Uparrow",l="\u2016",h="\\Downarrow"):I.contains(Ar,e)?(l="\u2223",c="vert",u=333):I.contains(Mr,e)?(l="\u2225",c="doublevert",u=556):"["===e||"\\lbrack"===e?(o="\u23a1",l="\u23a2",h="\u23a3",m="Size4-Regular",c="lbrack",u=667):"]"===e||"\\rbrack"===e?(o="\u23a4",l="\u23a5",h="\u23a6",m="Size4-Regular",c="rbrack",u=667):"\\lfloor"===e||"\u230a"===e?(l=o="\u23a2",h="\u23a3",m="Size4-Regular",c="lfloor",u=667):"\\lceil"===e||"\u2308"===e?(o="\u23a1",l=h="\u23a2",m="Size4-Regular",c="lceil",u=667):"\\rfloor"===e||"\u230b"===e?(l=o="\u23a5",h="\u23a6",m="Size4-Regular",c="rfloor",u=667):"\\rceil"===e||"\u2309"===e?(o="\u23a4",l=h="\u23a5",m="Size4-Regular",c="rceil",u=667):"("===e||"\\lparen"===e?(o="\u239b",l="\u239c",h="\u239d",m="Size4-Regular",c="lparen",u=875):")"===e||"\\rparen"===e?(o="\u239e",l="\u239f",h="\u23a0",m="Size4-Regular",c="rparen",u=875):"\\{"===e||"\\lbrace"===e?(o="\u23a7",s="\u23a8",h="\u23a9",l="\u23aa",m="Size4-Regular"):"\\}"===e||"\\rbrace"===e?(o="\u23ab",s="\u23ac",h="\u23ad",l="\u23aa",m="Size4-Regular"):"\\lgroup"===e||"\u27ee"===e?(o="\u23a7",h="\u23a9",l="\u23aa", +m="Size4-Regular"):"\\rgroup"===e||"\u27ef"===e?(o="\u23ab",h="\u23ad",l="\u23aa",m="Size4-Regular"):"\\lmoustache"===e||"\u23b0"===e?(o="\u23a7",h="\u23ad",l="\u23aa",m="Size4-Regular"):"\\rmoustache"!==e&&"\u23b1"!==e||(o="\u23ab",h="\u23a9",l="\u23aa",m="Size4-Regular");var d=vr(o,m,i),p=d.height+d.depth,f=vr(l,m,i),g=f.height+f.depth,v=vr(h,m,i),y=v.height+v.depth,b=0,x=1;if(null!==s){var w=vr(s,m,i);b=w.height+w.depth,x=2}var k=p+y+b,S=k+Math.max(0,Math.ceil((t-k)/(x*g)))*x*g,A=n.fontMetrics().axisHeight;r&&(A*=n.sizeMultiplier);var M=S/2-A,z=[];if(c.length>0){var T=S-p-y,C=Math.round(1e3*S),B=function(e,t){switch(e){case"lbrack":return"M403 1759 V84 H666 V0 H319 V1759 v"+t+" v1759 h347 v-84\nH403z M403 1759 V0 H319 V1759 v"+t+" v1759 h84z";case"rbrack":return"M347 1759 V0 H0 V84 H263 V1759 v"+t+" v1759 H0 v84 H347z\nM347 1759 V0 H263 V1759 v"+t+" v1759 h84z";case"vert":return"M145 15 v585 v"+t+" v585 c2.667,10,9.667,15,21,15\nc10,0,16.667,-5,20,-15 v-585 v"+-t+" v-585 c-2.667,-10,-9.667,-15,-21,-15\nc-10,0,-16.667,5,-20,15z M188 15 H145 v585 v"+t+" v585 h43z";case"doublevert":return"M145 15 v585 v"+t+" v585 c2.667,10,9.667,15,21,15\nc10,0,16.667,-5,20,-15 v-585 v"+-t+" v-585 c-2.667,-10,-9.667,-15,-21,-15\nc-10,0,-16.667,5,-20,15z M188 15 H145 v585 v"+t+" v585 h43z\nM367 15 v585 v"+t+" v585 c2.667,10,9.667,15,21,15\nc10,0,16.667,-5,20,-15 v-585 v"+-t+" v-585 c-2.667,-10,-9.667,-15,-21,-15\nc-10,0,-16.667,5,-20,15z M410 15 H367 v585 v"+t+" v585 h43z";case"lfloor":return"M319 602 V0 H403 V602 v"+t+" v1715 h263 v84 H319z\nMM319 602 V0 H403 V602 v"+t+" v1715 H319z";case"rfloor":return"M319 602 V0 H403 V602 v"+t+" v1799 H0 v-84 H319z\nMM319 602 V0 H403 V602 v"+t+" v1715 H319z";case"lceil":return"M403 1759 V84 H666 V0 H319 V1759 v"+t+" v602 h84z\nM403 1759 V0 H319 V1759 v"+t+" v602 h84z";case"rceil":return"M347 1759 V0 H0 V84 H263 V1759 v"+t+" v602 h84z\nM347 1759 V0 h-84 V1759 v"+t+" v602 h84z";case"lparen":return"M863,9c0,-2,-2,-5,-6,-9c0,0,-17,0,-17,0c-12.7,0,-19.3,0.3,-20,1\nc-5.3,5.3,-10.3,11,-15,17c-242.7,294.7,-395.3,682,-458,1162c-21.3,163.3,-33.3,349,\n-36,557 l0,"+(t+84)+"c0.2,6,0,26,0,60c2,159.3,10,310.7,24,454c53.3,528,210,\n949.7,470,1265c4.7,6,9.7,11.7,15,17c0.7,0.7,7,1,19,1c0,0,18,0,18,0c4,-4,6,-7,6,-9\nc0,-2.7,-3.3,-8.7,-10,-18c-135.3,-192.7,-235.5,-414.3,-300.5,-665c-65,-250.7,-102.5,\n-544.7,-112.5,-882c-2,-104,-3,-167,-3,-189\nl0,-"+(t+92)+"c0,-162.7,5.7,-314,17,-454c20.7,-272,63.7,-513,129,-723c65.3,\n-210,155.3,-396.3,270,-559c6.7,-9.3,10,-15.3,10,-18z";case"rparen":return"M76,0c-16.7,0,-25,3,-25,9c0,2,2,6.3,6,13c21.3,28.7,42.3,60.3,\n63,95c96.7,156.7,172.8,332.5,228.5,527.5c55.7,195,92.8,416.5,111.5,664.5\nc11.3,139.3,17,290.7,17,454c0,28,1.7,43,3.3,45l0,"+(t+9)+"\nc-3,4,-3.3,16.7,-3.3,38c0,162,-5.7,313.7,-17,455c-18.7,248,-55.8,469.3,-111.5,664\nc-55.7,194.7,-131.8,370.3,-228.5,527c-20.7,34.7,-41.7,66.3,-63,95c-2,3.3,-4,7,-6,11\nc0,7.3,5.7,11,17,11c0,0,11,0,11,0c9.3,0,14.3,-0.3,15,-1c5.3,-5.3,10.3,-11,15,-17\nc242.7,-294.7,395.3,-681.7,458,-1161c21.3,-164.7,33.3,-350.7,36,-558\nl0,-"+(t+144)+"c-2,-159.3,-10,-310.7,-24,-454c-53.3,-528,-210,-949.7,\n-470,-1265c-4.7,-6,-9.7,-11.7,-15,-17c-0.7,-0.7,-6.7,-1,-18,-1z";default:throw new Error("Unknown stretchy delimiter.")}}(c,Math.round(1e3*T)),L=new we(c,B),N=(u/1e3).toFixed(3)+"em",q=(C/1e3).toFixed(3)+"em",E=new xe([L],{width:N,height:q,viewBox:"0 0 "+u+" "+C}),O=yt.makeSvgSpan([],[E],n);O.height=C/1e3,O.style.width=N,O.style.height=q,z.push({type:"elem",elem:O})}else{if(z.push(wr(h,m,i)),z.push(Sr),null===s){var R=S-p-y+.016;z.push(kr(l,R,n))}else{var H=(S-p-y-b)/2+.016;z.push(kr(l,H,n)),z.push(Sr),z.push(wr(s,m,i)),z.push(Sr),z.push(kr(l,H,n))}z.push(Sr),z.push(wr(o,m,i))}var D=n.havingBaseStyle(U.TEXT),P=yt.makeVList({positionType:"bottom",positionData:M,children:z},D);return yr(yt.makeSpan(["delimsizing","mult"],[P],D),U.TEXT,n,a)},Tr=.08,Cr=function(e,t,r,n,i){var a=function(e,t,r){t*=1e3;var n="";switch(e){case"sqrtMain":n=function(e){return"M95,"+(622+e+X)+"\nc-2.7,0,-7.17,-2.7,-13.5,-8c-5.8,-5.3,-9.5,-10,-9.5,-14\nc0,-2,0.3,-3.3,1,-4c1.3,-2.7,23.83,-20.7,67.5,-54\nc44.2,-33.3,65.8,-50.3,66.5,-51c1.3,-1.3,3,-2,5,-2c4.7,0,8.7,3.3,12,10\ns173,378,173,378c0.7,0,35.3,-71,104,-213c68.7,-142,137.5,-285,206.5,-429\nc69,-144,104.5,-217.7,106.5,-221\nl"+e/2.075+" -"+e+"\nc5.3,-9.3,12,-14,20,-14\nH400000v"+(40+e)+"H845.2724\ns-225.272,467,-225.272,467s-235,486,-235,486c-2.7,4.7,-9,7,-19,7\nc-6,0,-10,-1,-12,-3s-194,-422,-194,-422s-65,47,-65,47z\nM"+(834+e)+" "+X+"h400000v"+(40+e)+"h-400000z"}(t);break;case"sqrtSize1":n=function(e){return"M263,"+(601+e+X)+"c0.7,0,18,39.7,52,119\nc34,79.3,68.167,158.7,102.5,238c34.3,79.3,51.8,119.3,52.5,120\nc340,-704.7,510.7,-1060.3,512,-1067\nl"+e/2.084+" -"+e+"\nc4.7,-7.3,11,-11,19,-11\nH40000v"+(40+e)+"H1012.3\ns-271.3,567,-271.3,567c-38.7,80.7,-84,175,-136,283c-52,108,-89.167,185.3,-111.5,232\nc-22.3,46.7,-33.8,70.3,-34.5,71c-4.7,4.7,-12.3,7,-23,7s-12,-1,-12,-1\ns-109,-253,-109,-253c-72.7,-168,-109.3,-252,-110,-252c-10.7,8,-22,16.7,-34,26\nc-22,17.3,-33.3,26,-34,26s-26,-26,-26,-26s76,-59,76,-59s76,-60,76,-60z\nM"+(1001+e)+" "+X+"h400000v"+(40+e)+"h-400000z"}(t);break;case"sqrtSize2":n=function(e){return"M983 "+(10+e+X)+"\nl"+e/3.13+" -"+e+"\nc4,-6.7,10,-10,18,-10 H400000v"+(40+e)+"\nH1013.1s-83.4,268,-264.1,840c-180.7,572,-277,876.3,-289,913c-4.7,4.7,-12.7,7,-24,7\ns-12,0,-12,0c-1.3,-3.3,-3.7,-11.7,-7,-25c-35.3,-125.3,-106.7,-373.3,-214,-744\nc-10,12,-21,25,-33,39s-32,39,-32,39c-6,-5.3,-15,-14,-27,-26s25,-30,25,-30\nc26.7,-32.7,52,-63,76,-91s52,-60,52,-60s208,722,208,722\nc56,-175.3,126.3,-397.3,211,-666c84.7,-268.7,153.8,-488.2,207.5,-658.5\nc53.7,-170.3,84.5,-266.8,92.5,-289.5z\nM"+(1001+e)+" "+X+"h400000v"+(40+e)+"h-400000z"}(t);break;case"sqrtSize3":n=function(e){return"M424,"+(2398+e+X)+"\nc-1.3,-0.7,-38.5,-172,-111.5,-514c-73,-342,-109.8,-513.3,-110.5,-514\nc0,-2,-10.7,14.3,-32,49c-4.7,7.3,-9.8,15.7,-15.5,25c-5.7,9.3,-9.8,16,-12.5,20\ns-5,7,-5,7c-4,-3.3,-8.3,-7.7,-13,-13s-13,-13,-13,-13s76,-122,76,-122s77,-121,77,-121\ns209,968,209,968c0,-2,84.7,-361.7,254,-1079c169.3,-717.3,254.7,-1077.7,256,-1081\nl"+e/4.223+" -"+e+"c4,-6.7,10,-10,18,-10 H400000\nv"+(40+e)+"H1014.6\ns-87.3,378.7,-272.6,1166c-185.3,787.3,-279.3,1182.3,-282,1185\nc-2,6,-10,9,-24,9\nc-8,0,-12,-0.7,-12,-2z M"+(1001+e)+" "+X+"\nh400000v"+(40+e)+"h-400000z"}(t);break;case"sqrtSize4":n=function(e){return"M473,"+(2713+e+X)+"\nc339.3,-1799.3,509.3,-2700,510,-2702 l"+e/5.298+" -"+e+"\nc3.3,-7.3,9.3,-11,18,-11 H400000v"+(40+e)+"H1017.7\ns-90.5,478,-276.2,1466c-185.7,988,-279.5,1483,-281.5,1485c-2,6,-10,9,-24,9\nc-8,0,-12,-0.7,-12,-2c0,-1.3,-5.3,-32,-16,-92c-50.7,-293.3,-119.7,-693.3,-207,-1200\nc0,-1.3,-5.3,8.7,-16,30c-10.7,21.3,-21.3,42.7,-32,64s-16,33,-16,33s-26,-26,-26,-26\ns76,-153,76,-153s77,-151,77,-151c0.7,0.7,35.7,202,105,604c67.3,400.7,102,602.7,104,\n606zM"+(1001+e)+" "+X+"h400000v"+(40+e)+"H1017.7z"}(t);break;case"sqrtTall":n=function(e,t,r){return"M702 "+(e+X)+"H400000"+(40+e)+"\nH742v"+(r-54-X-e)+"l-4 4-4 4c-.667.7 -2 1.5-4 2.5s-4.167 1.833-6.5 2.5-5.5 1-9.5 1\nh-12l-28-84c-16.667-52-96.667 -294.333-240-727l-212 -643 -85 170\nc-4-3.333-8.333-7.667-13 -13l-13-13l77-155 77-156c66 199.333 139 419.667\n219 661 l218 661zM702 "+X+"H400000v"+(40+e)+"H742z"}(t,0,r)}return n}(e,n,r),o=new we(e,a),s=new xe([o],{width:"400em",height:ce(t),viewBox:"0 0 400000 "+r,preserveAspectRatio:"xMinYMin slice"});return yt.makeSvgSpan(["hide-tail"],[s],i)},Br=["(","\\lparen",")","\\rparen","[","\\lbrack","]","\\rbrack","\\{","\\lbrace","\\}","\\rbrace","\\lfloor","\\rfloor","\u230a","\u230b","\\lceil","\\rceil","\u2308","\u2309","\\surd"],Lr=["\\uparrow","\\downarrow","\\updownarrow","\\Uparrow","\\Downarrow","\\Updownarrow","|","\\|","\\vert","\\Vert","\\lvert","\\rvert","\\lVert","\\rVert","\\lgroup","\\rgroup","\u27ee","\u27ef","\\lmoustache","\\rmoustache","\u23b0","\u23b1"],Nr=["<",">","\\langle","\\rangle","/","\\backslash","\\lt","\\gt"],qr=[0,1.2,1.8,2.4,3],Er=[{type:"small",style:U.SCRIPTSCRIPT},{type:"small",style:U.SCRIPT},{type:"small",style:U.TEXT},{type:"large",size:1},{type:"large",size:2},{type:"large",size:3},{type:"large",size:4}],Ir=[{type:"small",style:U.SCRIPTSCRIPT},{type:"small",style:U.SCRIPT},{type:"small",style:U.TEXT},{type:"stack"}],Or=[{type:"small",style:U.SCRIPTSCRIPT},{type:"small",style:U.SCRIPT},{type:"small",style:U.TEXT},{type:"large",size:1},{type:"large",size:2},{type:"large",size:3},{type:"large",size:4},{type:"stack"}],Rr=function(e){if("small"===e.type)return"Main-Regular";if("large"===e.type)return"Size"+e.size+"-Regular";if("stack"===e.type)return"Size4-Regular";throw new Error("Add support for delim type '"+e.type+"' here.")},Hr=function(e,t,r,n){for(var i=Math.min(2,3-n.style.size);it)return r[i]}return r[r.length-1]},Dr=function(e,t,r,n,i,a){var o;"<"===e||"\\lt"===e||"\u27e8"===e?e="\\langle":">"!==e&&"\\gt"!==e&&"\u27e9"!==e||(e="\\rangle"),o=I.contains(Nr,e)?Er:I.contains(Br,e)?Or:Ir;var s=Hr(e,t,o,n);return"small"===s.type?function(e,t,r,n,i,a){var o=yt.makeSymbol(e,"Main-Regular",i,n),s=yr(o,t,n,a);return r&&br(s,n,t),s}(e,s.style,r,n,i,a):"large"===s.type?xr(e,s.size,r,n,i,a):zr(e,t,r,n,i,a)},Pr={sqrtImage:function(e,t){var r,n,i=t.havingBaseSizing(),a=Hr("\\surd",e*i.sizeMultiplier,Or,i),o=i.sizeMultiplier,s=Math.max(0,t.minRuleThickness-t.fontMetrics().sqrtRuleThickness),l=0,h=0,c=0;return"small"===a.type?(e<1?o=1:e<1.4&&(o=.7),h=(1+s)/o,(r=Cr("sqrtMain",l=(1+s+Tr)/o,c=1e3+1e3*s+80,s,t)).style.minWidth="0.853em",n=.833/o):"large"===a.type?(c=1080*qr[a.size],h=(qr[a.size]+s)/o,l=(qr[a.size]+s+Tr)/o,(r=Cr("sqrtSize"+a.size,l,c,s,t)).style.minWidth="1.02em",n=1/o):(l=e+s+Tr,h=e+s,c=Math.floor(1e3*e+s)+80,(r=Cr("sqrtTall",l,c,s,t)).style.minWidth="0.742em",n=1.056),r.height=h,r.style.height=ce(l),{span:r,advanceWidth:n,ruleWidth:(t.fontMetrics().sqrtRuleThickness+s)*o}},sizedDelim:function(e,t,r,n,i){if("<"===e||"\\lt"===e||"\u27e8"===e?e="\\langle":">"!==e&&"\\gt"!==e&&"\u27e9"!==e||(e="\\rangle"),I.contains(Br,e)||I.contains(Nr,e))return xr(e,t,!1,r,n,i);if(I.contains(Lr,e))return zr(e,qr[t],!1,r,n,i);throw new B("Illegal delimiter: '"+e+"'")},sizeToMaxHeight:qr,customSizedDelim:Dr,leftRightDelim:function(e,t,r,n,i,a){var o=n.fontMetrics().axisHeight*n.sizeMultiplier,s=5/n.fontMetrics().ptPerEm,l=Math.max(t-o,r+o),h=Math.max(l/500*901,2*l-s);return Dr(e,h,!0,n,i,a)}},Fr={"\\bigl":{mclass:"mopen",size:1},"\\Bigl":{mclass:"mopen",size:2},"\\biggl":{mclass:"mopen",size:3},"\\Biggl":{mclass:"mopen",size:4},"\\bigr":{mclass:"mclose",size:1},"\\Bigr":{mclass:"mclose",size:2},"\\biggr":{mclass:"mclose",size:3},"\\Biggr":{mclass:"mclose",size:4},"\\bigm":{mclass:"mrel",size:1},"\\Bigm":{mclass:"mrel",size:2},"\\biggm":{mclass:"mrel",size:3},"\\Biggm":{mclass:"mrel",size:4},"\\big":{mclass:"mord",size:1},"\\Big":{mclass:"mord",size:2},"\\bigg":{mclass:"mord",size:3},"\\Bigg":{mclass:"mord",size:4}},_r=["(","\\lparen",")","\\rparen","[","\\lbrack","]","\\rbrack","\\{","\\lbrace","\\}","\\rbrace","\\lfloor","\\rfloor","\u230a","\u230b","\\lceil","\\rceil","\u2308","\u2309","<",">","\\langle","\u27e8","\\rangle","\u27e9","\\lt","\\gt","\\lvert","\\rvert","\\lVert","\\rVert","\\lgroup","\\rgroup","\u27ee","\u27ef","\\lmoustache","\\rmoustache","\u23b0","\u23b1","/","\\backslash","|","\\vert","\\|","\\Vert","\\uparrow","\\Uparrow","\\downarrow","\\Downarrow","\\updownarrow","\\Updownarrow","."];a({type:"delimsizing",names:["\\bigl","\\Bigl","\\biggl","\\Biggl","\\bigr","\\Bigr","\\biggr","\\Biggr","\\bigm","\\Bigm","\\biggm","\\Biggm","\\big","\\Big","\\bigg","\\Bigg"],props:{numArgs:1,argTypes:["primitive"]},handler:function(e,t){var r=v(t[0],e);return{type:"delimsizing",mode:e.parser.mode,size:Fr[e.funcName].size,mclass:Fr[e.funcName].mclass,delim:r.text}},htmlBuilder:function(e,t){return"."===e.delim?yt.makeSpan([e.mclass]):Pr.sizedDelim(e.delim,e.size,t,e.mode,[e.mclass])},mathmlBuilder:function(e){var t=[];"."!==e.delim&&t.push(jt(e.delim,e.mode));var r=new Gt.MathNode("mo",t);"mopen"===e.mclass||"mclose"===e.mclass?r.setAttribute("fence","true"):r.setAttribute("fence","false"),r.setAttribute("stretchy","true");var n=ce(Pr.sizeToMaxHeight[e.size]);return r.setAttribute("minsize",n),r.setAttribute("maxsize",n),r}}),a({type:"leftright-right",names:["\\right"],props:{numArgs:1,primitive:!0},handler:function(e,t){var r=e.parser.gullet.macros.get("\\current@color");if(r&&"string"!=typeof r)throw new B("\\current@color set to non-string in \\right");return{type:"leftright-right",mode:e.parser.mode,delim:v(t[0],e).text,color:r}}}),a({type:"leftright",names:["\\left"],props:{numArgs:1,primitive:!0},handler:function(e,t){var r=v(t[0],e),n=e.parser;++n.leftrightDepth;var i=n.parseExpression(!1);--n.leftrightDepth,n.expect("\\right",!1);var a=u(n.parseFunction(),"leftright-right");return{type:"leftright",mode:n.mode,body:i,left:r.text,right:a.delim,rightColor:a.color}},htmlBuilder:function(e,t){y(e);for(var r,n,i=It(e.body,t,!0,["mopen","mclose"]),a=0,o=0,s=!1,l=0;l-1?"mpadded":"menclose",[$t(e.body,t)]);switch(e.label){case"\\cancel":n.setAttribute("notation","updiagonalstrike");break;case"\\bcancel":n.setAttribute("notation","downdiagonalstrike");break;case"\\phase":n.setAttribute("notation","phasorangle");break;case"\\sout":n.setAttribute("notation","horizontalstrike");break;case"\\fbox":n.setAttribute("notation","box");break;case"\\angl":n.setAttribute("notation","actuarial");break;case"\\fcolorbox":case"\\colorbox":if(r=t.fontMetrics().fboxsep*t.fontMetrics().ptPerEm,n.setAttribute("width","+"+2*r+"pt"),n.setAttribute("height","+"+2*r+"pt"),n.setAttribute("lspace",r+"pt"),n.setAttribute("voffset",r+"pt"),"\\fcolorbox"===e.label){var i=Math.max(t.fontMetrics().fboxrule,t.minRuleThickness);n.setAttribute("style","border: "+i+"em solid "+String(e.borderColor))}break;case"\\xcancel":n.setAttribute("notation","updiagonalstrike downdiagonalstrike")}return e.backgroundColor&&n.setAttribute("mathbackground",e.backgroundColor),n};a({type:"enclose",names:["\\colorbox"],props:{numArgs:2,allowedInText:!0,argTypes:["color","text"]},handler:function(e,t){var r=e.parser,n=e.funcName,i=u(t[0],"color-token").color,a=t[1];return{type:"enclose",mode:r.mode,label:n,backgroundColor:i,body:a}},htmlBuilder:Vr,mathmlBuilder:Gr}),a({type:"enclose",names:["\\fcolorbox"],props:{numArgs:3,allowedInText:!0,argTypes:["color","color","text"]},handler:function(e,t){var r=e.parser,n=e.funcName,i=u(t[0],"color-token").color,a=u(t[1],"color-token").color,o=t[2];return{type:"enclose",mode:r.mode,label:n,backgroundColor:a,borderColor:i,body:o}},htmlBuilder:Vr,mathmlBuilder:Gr}),a({type:"enclose",names:["\\fbox"],props:{numArgs:1,argTypes:["hbox"],allowedInText:!0},handler:function(e,t){return{type:"enclose",mode:e.parser.mode,label:"\\fbox",body:t[0]}}}),a({type:"enclose",names:["\\cancel","\\bcancel","\\xcancel","\\sout","\\phase"],props:{numArgs:1},handler:function(e,t){var r=e.parser,n=e.funcName,i=t[0];return{type:"enclose",mode:r.mode,label:n,body:i}},htmlBuilder:Vr,mathmlBuilder:Gr}),a({type:"enclose",names:["\\angl"],props:{numArgs:1,argTypes:["hbox"],allowedInText:!1},handler:function(e,t){return{type:"enclose",mode:e.parser.mode,label:"\\angl",body:t[0]}}});var jr={},Ur={},Wr=function(){function e(e,t,r){this.lexer=void 0,this.start=void 0,this.end=void 0,this.lexer=e,this.start=t,this.end=r}return e.range=function(t,r){return r?t&&t.loc&&r.loc&&t.loc.lexer===r.loc.lexer?new e(t.loc.lexer,t.loc.start,r.loc.end):null:t&&t.loc},e}(),Yr=function(){function e(e,t){this.text=void 0,this.loc=void 0,this.noexpand=void 0,this.treatAsRelax=void 0,this.text=e,this.loc=t}return e.prototype.range=function(t,r){return new e(r,Wr.range(this,t))},e}(),Xr=function(e){if(!e.parser.settings.displayMode)throw new B("{"+e.envName+"} can be used only in display mode.")},$r=function(e,t){function r(e){for(var t=0;t0&&(y+=.25),h.push({pos:y,isDashed:e[t]})}var n,i,a=e.body.length,o=e.hLinesBeforeRow,s=0,l=new Array(a),h=[],c=Math.max(t.fontMetrics().arrayRuleWidth,t.minRuleThickness),u=1/t.fontMetrics().ptPerEm,m=5*u;e.colSeparationType&&"small"===e.colSeparationType&&(m=t.havingStyle(U.SCRIPT).sizeMultiplier/t.sizeMultiplier*.2778);var d="CD"===e.colSeparationType?he({number:3,unit:"ex"},t):12*u,p=3*u,f=e.arraystretch*d,g=.7*f,v=.3*f,y=0;for(r(o[0]),n=0;n0&&(w<(M+=v)&&(w=M),M=0),e.addJot&&(w+=p),k.height=x,k.depth=w,y+=x,k.pos=y,y+=w+M,l[n]=k,r(o[n+1])}var z,T,C=y/2+t.fontMetrics().axisHeight,L=e.cols||[],N=[],q=[];if(e.tags&&e.tags.some(function(e){return e}))for(n=0;n=s)){var G=void 0;(i>0||e.hskipBeforeAndAfter)&&0!==(G=I.deflt(D.pregap,m))&&((z=yt.makeSpan(["arraycolsep"],[])).style.width=ce(G),N.push(z));var j=[];for(n=0;n0){for(var $=yt.makeLineSpan("hline",t,c),K=yt.makeLineSpan("hdashline",t,c),Z=[{type:"elem",elem:l,shift:0}];h.length>0;){var J=h.pop(),Q=J.pos-C;J.isDashed?Z.push({type:"elem",elem:K,shift:Q}):Z.push({type:"elem",elem:$,shift:Q})}l=yt.makeVList({positionType:"individualShift",children:Z},t)}if(0===q.length)return yt.makeSpan(["mord"],[l],t);var ee=yt.makeVList({positionType:"individualShift",children:q},t);return ee=yt.makeSpan(["tag"],[ee],t),yt.makeFragment([l,ee])},Kr={c:"center ",l:"left ",r:"right "},Zr=function(e,t){for(var r=[],n=new Gt.MathNode("mtd",[],["mtr-glue"]),i=new Gt.MathNode("mtd",[],["mml-eqn-num"]),a=0;a0){var d=e.cols,p="",f=!1,g=0,v=d.length;"separator"===d[0].type&&(u+="top ",g=1),"separator"===d[d.length-1].type&&(u+="bottom ",v-=1);for(var y=g;y0?"left ":"",u+=S[S.length-1].length>0?"right ":"";for(var A=1;A-1?"alignat":"align",a="split"===e.envName,o=S(e.parser,{cols:n,addJot:!0,autoTag:a?void 0:k(e.envName),emptySingleRow:!0,colSeparationType:i,maxNumCols:a?2:void 0,leqno:e.parser.settings.leqno},"display"),s=0,l={type:"ordgroup",mode:e.mode,body:[]};if(t[0]&&"ordgroup"===t[0].type){for(var h="",c=0;c0&&m&&(f=1),n[d]={type:"align",align:p,pregap:f,postgap:0}}return o.colSeparationType=m?"align":"alignat",o};b({type:"array",names:["array","darray"],props:{numArgs:1},handler:function(e,t){var r=(d(t[0])?[t[0]]:u(t[0],"ordgroup").body).map(function(e){var t=m(e).text;if(-1!=="lcr".indexOf(t))return{type:"align",align:t};if("|"===t)return{type:"separator",separator:"|"};if(":"===t)return{type:"separator",separator:":"};throw new B("Unknown column alignment: "+t,e)}),n={cols:r,hskipBeforeAndAfter:!0,maxNumCols:r.length};return S(e.parser,n,A(e.envName))},htmlBuilder:$r,mathmlBuilder:Zr}),b({type:"array",names:["matrix","pmatrix","bmatrix","Bmatrix","vmatrix","Vmatrix","matrix*","pmatrix*","bmatrix*","Bmatrix*","vmatrix*","Vmatrix*"],props:{numArgs:0},handler:function(e){var t={matrix:null,pmatrix:["(",")"],bmatrix:["[","]"],Bmatrix:["\\{","\\}"],vmatrix:["|","|"],Vmatrix:["\\Vert","\\Vert"]}[e.envName.replace("*","")],r="c",n={hskipBeforeAndAfter:!1,cols:[{type:"align",align:r}]};if("*"===e.envName.charAt(e.envName.length-1)){var i=e.parser;if(i.consumeSpaces(),"["===i.fetch().text){if(i.consume(),i.consumeSpaces(),r=i.fetch().text,-1==="lcr".indexOf(r))throw new B("Expected l or c or r",i.nextToken);i.consume(),i.consumeSpaces(),i.expect("]"),i.consume(),n.cols=[{type:"align",align:r}]}}var a=S(e.parser,n,A(e.envName)),o=Math.max.apply(Math,[0].concat(a.body.map(function(e){return e.length})));return a.cols=new Array(o).fill({type:"align",align:r}),t?{type:"leftright",mode:e.mode,body:[a],left:t[0],right:t[1],rightColor:void 0}:a},htmlBuilder:$r,mathmlBuilder:Zr}),b({type:"array",names:["smallmatrix"],props:{numArgs:0},handler:function(e){var t=S(e.parser,{arraystretch:.5},"script");return t.colSeparationType="small",t},htmlBuilder:$r,mathmlBuilder:Zr}),b({type:"array",names:["subarray"],props:{numArgs:1},handler:function(e,t){var r=(d(t[0])?[t[0]]:u(t[0],"ordgroup").body).map(function(e){var t=m(e).text;if(-1!=="lc".indexOf(t))return{type:"align",align:t};throw new B("Unknown column alignment: "+t,e)});if(r.length>1)throw new B("{subarray} can contain only one column");var n={cols:r,hskipBeforeAndAfter:!1,arraystretch:.5};if((n=S(e.parser,n,"script")).body.length>0&&n.body[0].length>1)throw new B("{subarray} can contain only one column");return n},htmlBuilder:$r,mathmlBuilder:Zr}),b({type:"array",names:["cases","dcases","rcases","drcases"],props:{numArgs:0},handler:function(e){var t=S(e.parser,{arraystretch:1.2,cols:[{type:"align",align:"l",pregap:0,postgap:1},{type:"align",align:"l",pregap:0,postgap:0}]},A(e.envName));return{type:"leftright",mode:e.mode,body:[t],left:e.envName.indexOf("r")>-1?".":"\\{",right:e.envName.indexOf("r")>-1?"\\}":".",rightColor:void 0}},htmlBuilder:$r,mathmlBuilder:Zr}),b({type:"array",names:["align","align*","aligned","split"],props:{numArgs:0},handler:Jr,htmlBuilder:$r,mathmlBuilder:Zr}),b({type:"array",names:["gathered","gather","gather*"],props:{numArgs:0},handler:function(e){I.contains(["gather","gather*"],e.envName)&&Xr(e);var t={cols:[{type:"align",align:"c"}],addJot:!0,colSeparationType:"gather",autoTag:k(e.envName),emptySingleRow:!0,leqno:e.parser.settings.leqno};return S(e.parser,t,"display")},htmlBuilder:$r,mathmlBuilder:Zr}),b({type:"array",names:["alignat","alignat*","alignedat"],props:{numArgs:1},handler:Jr,htmlBuilder:$r,mathmlBuilder:Zr}),b({type:"array",names:["equation","equation*"],props:{numArgs:0},handler:function(e){Xr(e);var t={autoTag:k(e.envName),emptySingleRow:!0,singleRow:!0,maxNumCols:1,leqno:e.parser.settings.leqno};return S(e.parser,t,"display")},htmlBuilder:$r,mathmlBuilder:Zr}),b({type:"array",names:["CD"],props:{numArgs:0},handler:function(e){return Xr(e),function(e){var t=[];for(e.gullet.beginGroup(),e.gullet.macros.set("\\cr","\\\\\\relax"),e.gullet.beginGroup();;){t.push(e.parseExpression(!1,"\\\\")),e.gullet.endGroup(),e.gullet.beginGroup();var r=e.fetch().text;if("&"!==r&&"\\\\"!==r){if("\\end"===r){0===t[t.length-1].length&&t.pop();break}throw new B("Expected \\\\ or \\cr or \\end",e.nextToken)}e.consume()}for(var n,i,a=[],o=[a],s=0;s-1);else{if(!("<>AV".indexOf(u)>-1))throw new B('Expected one of "<>AV=|." after @',l[c]);for(var p=0;p<2;p++){for(var f=!0,v=c+1;v=U.SCRIPT.id?r.text():U.DISPLAY:"text"===e&&r.size===U.DISPLAY.size?r=U.TEXT:"script"===e?r=U.SCRIPT:"scriptscript"===e&&(r=U.SCRIPTSCRIPT),r},an=function(e,t){var r,n=nn(e.size,t.style),i=n.fracNum(),a=n.fracDen();r=t.havingStyle(i);var o=Ft(e.numer,r,t);if(e.continued){var s=8.5/t.fontMetrics().ptPerEm,l=3.5/t.fontMetrics().ptPerEm;o.height=o.height0?3*u:7*u,p=t.fontMetrics().denom1):(c>0?(m=t.fontMetrics().num2,d=u):(m=t.fontMetrics().num3,d=3*u),p=t.fontMetrics().denom2),h){var x=t.fontMetrics().axisHeight;m-o.depth-(x+.5*c)0&&(t="."===(t=e)?null:t),t};a({type:"genfrac",names:["\\genfrac"],props:{numArgs:6,allowedInArgument:!0,argTypes:["math","math","size","text","math","math"]},handler:function(e,t){var r,n=e.parser,i=t[4],a=t[5],o=Tt(t[0]),s="atom"===o.type&&"open"===o.family?ln(o.text):null,l=Tt(t[1]),h="atom"===l.type&&"close"===l.family?ln(l.text):null,c=u(t[2],"size"),m=null;r=!!c.isBlank||(m=c.value).number>0;var d="auto",p=t[3];if("ordgroup"===p.type){if(p.body.length>0){var f=u(p.body[0],"textord");d=sn[Number(f.text)]}}else p=u(p,"textord"),d=sn[Number(p.text)];return{type:"genfrac",mode:n.mode,numer:i,denom:a,continued:!1,hasBarLine:r,barSize:m,leftDelim:s,rightDelim:h,size:d}},htmlBuilder:an,mathmlBuilder:on}),a({type:"infix",names:["\\above"],props:{numArgs:1,argTypes:["size"],infix:!0},handler:function(e,t){var r=e.parser,n=(e.funcName,e.token);return{type:"infix",mode:r.mode,replaceWith:"\\\\abovefrac",size:u(t[0],"size").value,token:n}}}),a({type:"genfrac",names:["\\\\abovefrac"],props:{numArgs:3,argTypes:["math","size","math"]},handler:function(e,t){var r=e.parser,n=(e.funcName,t[0]),i=function(e){if(!e)throw new Error("Expected non-null, but got "+String(e));return e}(u(t[1],"infix").size),a=t[2],o=i.number>0;return{type:"genfrac",mode:r.mode,numer:n,denom:a,continued:!1,hasBarLine:o,barSize:i,leftDelim:null,rightDelim:null,size:"auto"}},htmlBuilder:an,mathmlBuilder:on});var hn=function(e,t){var r,n,i=t.style;"supsub"===e.type?(r=e.sup?Ft(e.sup,t.havingStyle(i.sup()),t):Ft(e.sub,t.havingStyle(i.sub()),t),n=u(e.base,"horizBrace")):n=u(e,"horizBrace");var a,o=Ft(n.base,t.havingBaseStyle(U.DISPLAY)),s=nr(n,t);if(n.isOver?(a=yt.makeVList({positionType:"firstBaseline",children:[{type:"elem",elem:o},{type:"kern",size:.1},{type:"elem",elem:s}]},t)).children[0].children[0].children[1].classes.push("svg-align"):(a=yt.makeVList({positionType:"bottom",positionData:o.depth+.1+s.height,children:[{type:"elem",elem:s},{type:"kern",size:.1},{type:"elem",elem:o}]},t)).children[0].children[0].children[0].classes.push("svg-align"),r){var l=yt.makeSpan(["mord",n.isOver?"mover":"munder"],[a],t);a=n.isOver?yt.makeVList({positionType:"firstBaseline",children:[{type:"elem",elem:l},{type:"kern",size:.2},{type:"elem",elem:r}]},t):yt.makeVList({positionType:"bottom",positionData:l.depth+.2+r.height+r.depth,children:[{type:"elem",elem:r},{type:"kern",size:.2},{type:"elem",elem:l}]},t)}return yt.makeSpan(["mord",n.isOver?"mover":"munder"],[a],t)};a({type:"horizBrace",names:["\\overbrace","\\underbrace"],props:{numArgs:1},handler:function(e,t){var r=e.parser,n=e.funcName;return{type:"horizBrace",mode:r.mode,label:n,isOver:/^\\over/.test(n),base:t[0]}},htmlBuilder:hn,mathmlBuilder:function(e,t){var r=rr(e.label);return new Gt.MathNode(e.isOver?"mover":"munder",[$t(e.base,t),r])}}),a({type:"href",names:["\\href"],props:{numArgs:2,argTypes:["url","original"],allowedInText:!0},handler:function(e,t){var r=e.parser,n=t[1],i=u(t[0],"url").url;return r.settings.isTrusted({command:"\\href",url:i})?{type:"href",mode:r.mode,href:i,body:Ct(n)}:r.formatUnsupportedCmd("\\href")},htmlBuilder:function(e,t){var r=It(e.body,t,!1);return yt.makeAnchor(e.href,[],r,t)},mathmlBuilder:function(e,t){var r=Xt(e.body,t);return r instanceof _t||(r=new _t("mrow",[r])),r.setAttribute("href",e.href),r}}),a({type:"href",names:["\\url"],props:{numArgs:1,argTypes:["url"],allowedInText:!0},handler:function(e,t){var r=e.parser,n=u(t[0],"url").url;if(!r.settings.isTrusted({command:"\\url",url:n}))return r.formatUnsupportedCmd("\\url");for(var i=[],a=0;a0&&(n=he(e.totalheight,t)-r);var i=0;e.width.number>0&&(i=he(e.width,t));var a={height:ce(r+n)};i>0&&(a.width=ce(i)),n>0&&(a.verticalAlign=ce(-n));var o=new ve(e.src,e.alt,a);return o.height=r,o.depth=n,o},mathmlBuilder:function(e,t){var r=new Gt.MathNode("mglyph",[]);r.setAttribute("alt",e.alt);var n=he(e.height,t),i=0;if(e.totalheight.number>0&&(i=he(e.totalheight,t)-n,r.setAttribute("valign",ce(-i))),r.setAttribute("height",ce(n+i)),e.width.number>0){var a=he(e.width,t);r.setAttribute("width",ce(a))}return r.setAttribute("src",e.src),r}}),a({type:"kern",names:["\\kern","\\mkern","\\hskip","\\mskip"],props:{numArgs:1,argTypes:["size"],primitive:!0,allowedInText:!0},handler:function(e,t){var r=e.parser,n=e.funcName,i=u(t[0],"size");if(r.settings.strict){var a="m"===n[1],o="mu"===i.value.unit;a?(o||r.settings.reportNonstrict("mathVsTextUnits","LaTeX's "+n+" supports only mu units, not "+i.value.unit+" units"),"math"!==r.mode&&r.settings.reportNonstrict("mathVsTextUnits","LaTeX's "+n+" works only in math mode")):o&&r.settings.reportNonstrict("mathVsTextUnits","LaTeX's "+n+" doesn't support mu units")}return{type:"kern",mode:r.mode,dimension:i.value}},htmlBuilder:function(e,t){return yt.makeGlue(e.dimension,t)},mathmlBuilder:function(e,t){var r=he(e.dimension,t);return new Gt.SpaceNode(r)}}),a({type:"lap",names:["\\mathllap","\\mathrlap","\\mathclap"],props:{numArgs:1,allowedInText:!0},handler:function(e,t){var r=e.parser,n=e.funcName,i=t[0];return{type:"lap",mode:r.mode,alignment:n.slice(5),body:i}},htmlBuilder:function(e,t){var r;"clap"===e.alignment?(r=yt.makeSpan([],[Ft(e.body,t)]),r=yt.makeSpan(["inner"],[r],t)):r=yt.makeSpan(["inner"],[Ft(e.body,t)]);var n=yt.makeSpan(["fix"],[]),i=yt.makeSpan([e.alignment],[r,n],t),a=yt.makeSpan(["strut"]);return a.style.height=ce(i.height+i.depth),i.depth&&(a.style.verticalAlign=ce(-i.depth)),i.children.unshift(a),i=yt.makeSpan(["thinbox"],[i],t),yt.makeSpan(["mord","vbox"],[i],t)},mathmlBuilder:function(e,t){var r=new Gt.MathNode("mpadded",[$t(e.body,t)]);if("rlap"!==e.alignment){var n="llap"===e.alignment?"-1":"-0.5";r.setAttribute("lspace",n+"width")}return r.setAttribute("width","0px"),r}}),a({type:"styling",names:["\\(","$"],props:{numArgs:0,allowedInText:!0,allowedInMath:!1},handler:function(e){var t=e.funcName,r=e.parser,n=r.mode;r.switchMode("math");var i="\\("===t?"\\)":"$",a=r.parseExpression(!1,i);return r.expect(i),r.switchMode(n),{type:"styling",mode:r.mode,style:"text",body:a}}}),a({type:"text",names:["\\)","\\]"],props:{numArgs:0,allowedInText:!0,allowedInMath:!1},handler:function(e){throw new B("Mismatched "+e.funcName)}});var un=function(e,t){switch(t.style.size){case U.DISPLAY.size:return e.display;case U.TEXT.size:return e.text;case U.SCRIPT.size:return e.script;case U.SCRIPTSCRIPT.size:return e.scriptscript;default:return e.text}};a({type:"mathchoice",names:["\\mathchoice"],props:{numArgs:4,primitive:!0},handler:function(e,t){return{type:"mathchoice",mode:e.parser.mode,display:Ct(t[0]),text:Ct(t[1]),script:Ct(t[2]),scriptscript:Ct(t[3])}},htmlBuilder:function(e,t){var r=un(e,t),n=It(r,t,!1);return yt.makeFragment(n)},mathmlBuilder:function(e,t){var r=un(e,t);return Xt(r,t)}});var mn=function(e,t,r,n,i,a,o){e=yt.makeSpan([],[e]);var s,l,h,c=r&&I.isCharacterBox(r);if(t){var u=Ft(t,n.havingStyle(i.sup()),n);l={elem:u,kern:Math.max(n.fontMetrics().bigOpSpacing1,n.fontMetrics().bigOpSpacing3-u.depth)}}if(r){var m=Ft(r,n.havingStyle(i.sub()),n);s={elem:m,kern:Math.max(n.fontMetrics().bigOpSpacing2,n.fontMetrics().bigOpSpacing4-m.height)}}if(l&&s){var d=n.fontMetrics().bigOpSpacing5+s.elem.height+s.elem.depth+s.kern+e.depth+o;h=yt.makeVList({positionType:"bottom",positionData:d,children:[{type:"kern",size:n.fontMetrics().bigOpSpacing5},{type:"elem",elem:s.elem,marginLeft:ce(-a)},{type:"kern",size:s.kern},{type:"elem",elem:e},{type:"kern",size:l.kern},{type:"elem",elem:l.elem,marginLeft:ce(a)},{type:"kern",size:n.fontMetrics().bigOpSpacing5}]},n)}else if(s){var p=e.height-o;h=yt.makeVList({positionType:"top",positionData:p,children:[{type:"kern",size:n.fontMetrics().bigOpSpacing5},{type:"elem",elem:s.elem,marginLeft:ce(-a)},{type:"kern",size:s.kern},{type:"elem",elem:e}]},n)}else{if(!l)return e;var f=e.depth+o;h=yt.makeVList({positionType:"bottom",positionData:f,children:[{type:"elem",elem:e},{type:"kern",size:l.kern},{type:"elem",elem:l.elem,marginLeft:ce(a)},{type:"kern",size:n.fontMetrics().bigOpSpacing5}]},n)}var g=[h];if(s&&0!==a&&!c){var v=yt.makeSpan(["mspace"],[],n);v.style.marginRight=ce(a),g.unshift(v)}return yt.makeSpan(["mop","op-limits"],g,n)},dn=["\\smallint"],pn=function(e,t){var r,n,i,a=!1;"supsub"===e.type?(r=e.sup,n=e.sub,i=u(e.base,"op"),a=!0):i=u(e,"op");var o,s=t.style,l=!1;if(s.size===U.DISPLAY.size&&i.symbol&&!I.contains(dn,i.name)&&(l=!0),i.symbol){var h=l?"Size2-Regular":"Size1-Regular",c="";if("\\oiint"!==i.name&&"\\oiiint"!==i.name||(c=i.name.slice(1),i.name="oiint"===c?"\\iint":"\\iiint"),o=yt.makeSymbol(i.name,h,"math",t,["mop","op-symbol",l?"large-op":"small-op"]),c.length>0){var m=o.italic,d=yt.staticSvg(c+"Size"+(l?"2":"1"),t);o=yt.makeVList({positionType:"individualShift",children:[{type:"elem",elem:o,shift:0},{type:"elem",elem:d,shift:l?.08:0}]},t),i.name="\\"+c,o.classes.unshift("mop"),o.italic=m}}else if(i.body){var p=It(i.body,t,!0);1===p.length&&p[0]instanceof be?(o=p[0]).classes[0]="mop":o=yt.makeSpan(["mop"],p,t)}else{for(var f=[],g=1;g0){for(var s=i.body.map(function(e){var t=e.text;return"string"==typeof t?{type:"textord",mode:e.mode,text:t}:e}),l=It(s,t.withFont("mathrm"),!0),h=0;h=0?s.setAttribute("height",ce(i)):(s.setAttribute("height",ce(i)),s.setAttribute("depth",ce(-i))),s.setAttribute("voffset",ce(i)),s}});var bn=["\\tiny","\\sixptsize","\\scriptsize","\\footnotesize","\\small","\\normalsize","\\large","\\Large","\\LARGE","\\huge","\\Huge"];a({type:"sizing",names:bn,props:{numArgs:0,allowedInText:!0},handler:function(e){var t=e.breakOnTokenText,r=e.funcName,n=e.parser,i=n.parseExpression(!1,t);return{type:"sizing",mode:n.mode,size:bn.indexOf(r)+1,body:i}},htmlBuilder:function(e,t){var r=t.havingSize(e.size);return M(e.body,r,t)},mathmlBuilder:function(e,t){var r=t.havingSize(e.size),n=Yt(e.body,r),i=new Gt.MathNode("mstyle",n);return i.setAttribute("mathsize",ce(r.sizeMultiplier)),i}}),a({type:"smash",names:["\\smash"],props:{numArgs:1,numOptionalArgs:1,allowedInText:!0},handler:function(e,t,r){var n=e.parser,i=!1,a=!1,o=r[0]&&u(r[0],"ordgroup");if(o)for(var s="",l=0;lr.height+r.depth+a&&(a=(a+u-r.height-r.depth)/2);var m=l.height-r.height-a-h;r.style.paddingLeft=ce(c);var d=yt.makeVList({positionType:"firstBaseline",children:[{type:"elem",elem:r,wrapperClasses:["svg-align"]},{type:"kern",size:-(r.height+m)},{type:"elem",elem:l},{type:"kern",size:h}]},t);if(e.index){var p=t.havingStyle(U.SCRIPTSCRIPT),f=Ft(e.index,p,t),g=.6*(d.height-d.depth),v=yt.makeVList({positionType:"shift",positionData:-g,children:[{type:"elem",elem:f}]},t),y=yt.makeSpan(["root"],[v]);return yt.makeSpan(["mord","sqrt"],[y,d],t)}return yt.makeSpan(["mord","sqrt"],[d],t)},mathmlBuilder:function(e,t){var r=e.body,n=e.index;return n?new Gt.MathNode("mroot",[$t(r,t),$t(n,t)]):new Gt.MathNode("msqrt",[$t(r,t)])}});var xn={display:U.DISPLAY,text:U.TEXT,script:U.SCRIPT,scriptscript:U.SCRIPTSCRIPT};a({type:"styling",names:["\\displaystyle","\\textstyle","\\scriptstyle","\\scriptscriptstyle"],props:{numArgs:0,allowedInText:!0,primitive:!0},handler:function(e){var t=e.breakOnTokenText,r=e.funcName,n=e.parser,i=n.parseExpression(!0,t),a=r.slice(1,r.length-5);return{type:"styling",mode:n.mode,style:a,body:i}},htmlBuilder:function(e,t){var r=xn[e.style],n=t.havingStyle(r).withFont("");return M(e.body,n,t)},mathmlBuilder:function(e,t){var r=xn[e.style],n=t.havingStyle(r),i=Yt(e.body,n),a=new Gt.MathNode("mstyle",i),o={display:["0","true"],text:["0","false"],script:["1","false"],scriptscript:["2","false"]}[e.style];return a.setAttribute("scriptlevel",o[0]),a.setAttribute("displaystyle",o[1]),a}});var wn=function(e,t){var r=e.base;return r?"op"===r.type?r.limits&&(t.style.size===U.DISPLAY.size||r.alwaysHandleSupSub)?pn:null:"operatorname"===r.type?r.alwaysHandleSupSub&&(t.style.size===U.DISPLAY.size||r.limits)?yn:null:"accent"===r.type?I.isCharacterBox(r.base)?ir:null:"horizBrace"===r.type&&!e.sub===r.isOver?hn:null:null};o({type:"supsub",htmlBuilder:function(e,t){var r=wn(e,t);if(r)return r(e,t);var n,i,a,o=e.base,s=e.sup,l=e.sub,h=Ft(o,t),c=t.fontMetrics(),u=0,m=0,d=o&&I.isCharacterBox(o);if(s){var p=t.havingStyle(t.style.sup());n=Ft(s,p,t),d||(u=h.height-p.fontMetrics().supDrop*p.sizeMultiplier/t.sizeMultiplier)}if(l){var f=t.havingStyle(t.style.sub());i=Ft(l,f,t),d||(m=h.depth+f.fontMetrics().subDrop*f.sizeMultiplier/t.sizeMultiplier)}a=t.style===U.DISPLAY?c.sup1:t.style.cramped?c.sup3:c.sup2;var g,v=t.sizeMultiplier,y=ce(.5/c.ptPerEm/v),b=null;if(i){var x=e.base&&"op"===e.base.type&&e.base.name&&("\\oiint"===e.base.name||"\\oiiint"===e.base.name);(h instanceof be||x)&&(b=ce(-h.italic))}if(n&&i){u=Math.max(u,a,n.depth+.25*c.xHeight),m=Math.max(m,c.sub2);var w=4*c.defaultRuleThickness;if(u-n.depth-(i.height-m)0&&(u+=k,m-=k)}var S=[{type:"elem",elem:i,shift:m,marginRight:y,marginLeft:b},{type:"elem",elem:n,shift:-u,marginRight:y}];g=yt.makeVList({positionType:"individualShift",children:S},t)}else if(i){m=Math.max(m,c.sub1,i.height-.8*c.xHeight);var A=[{type:"elem",elem:i,marginLeft:b,marginRight:y}];g=yt.makeVList({positionType:"shift",positionData:m,children:A},t)}else{if(!n)throw new Error("supsub must have either sup or sub.");u=Math.max(u,a,n.depth+.25*c.xHeight),g=yt.makeVList({positionType:"shift",positionData:-u,children:[{type:"elem",elem:n,marginRight:y}]},t)}var M=Dt(h,"right")||"mord";return yt.makeSpan([M],[h,yt.makeSpan(["msupsub"],[g])],t)},mathmlBuilder:function(e,t){var r,n=!1;e.base&&"horizBrace"===e.base.type&&!!e.sup===e.base.isOver&&(n=!0,r=e.base.isOver),!e.base||"op"!==e.base.type&&"operatorname"!==e.base.type||(e.base.parentIsSupSub=!0);var i,a=[$t(e.base,t)];if(e.sub&&a.push($t(e.sub,t)),e.sup&&a.push($t(e.sup,t)),n)i=r?"mover":"munder";else if(e.sub)if(e.sup){var o=e.base;i=o&&"op"===o.type&&o.limits&&t.style===U.DISPLAY||o&&"operatorname"===o.type&&o.alwaysHandleSupSub&&(t.style===U.DISPLAY||o.limits)?"munderover":"msubsup"}else{var s=e.base;i=s&&"op"===s.type&&s.limits&&(t.style===U.DISPLAY||s.alwaysHandleSupSub)||s&&"operatorname"===s.type&&s.alwaysHandleSupSub&&(s.limits||t.style===U.DISPLAY)?"munder":"msub"}else{var l=e.base;i=l&&"op"===l.type&&l.limits&&(t.style===U.DISPLAY||l.alwaysHandleSupSub)||l&&"operatorname"===l.type&&l.alwaysHandleSupSub&&(l.limits||t.style===U.DISPLAY)?"mover":"msup"}return new Gt.MathNode(i,a)}}),o({type:"atom",htmlBuilder:function(e,t){return yt.mathsym(e.text,e.mode,t,["m"+e.family])},mathmlBuilder:function(e,t){var r=new Gt.MathNode("mo",[jt(e.text,e.mode)]);if("bin"===e.family){var n=Wt(e,t);"bold-italic"===n&&r.setAttribute("mathvariant",n)}else"punct"===e.family?r.setAttribute("separator","true"):"open"!==e.family&&"close"!==e.family||r.setAttribute("stretchy","false");return r}});var kn={mi:"italic",mn:"normal",mtext:"normal"};o({type:"mathord",htmlBuilder:function(e,t){return yt.makeOrd(e,t,"mathord")},mathmlBuilder:function(e,t){var r=new Gt.MathNode("mi",[jt(e.text,e.mode,t)]),n=Wt(e,t)||"italic";return n!==kn[r.type]&&r.setAttribute("mathvariant",n),r}}),o({type:"textord",htmlBuilder:function(e,t){return yt.makeOrd(e,t,"textord")},mathmlBuilder:function(e,t){var r,n=jt(e.text,e.mode,t),i=Wt(e,t)||"normal";return r="text"===e.mode?new Gt.MathNode("mtext",[n]):/[0-9]/.test(e.text)?new Gt.MathNode("mn",[n]):"\\prime"===e.text?new Gt.MathNode("mo",[n]):new Gt.MathNode("mi",[n]),i!==kn[r.type]&&r.setAttribute("mathvariant",i),r}});var Sn={"\\nobreak":"nobreak","\\allowbreak":"allowbreak"},An={" ":{},"\\ ":{},"~":{className:"nobreak"},"\\space":{},"\\nobreakspace":{className:"nobreak"}};o({type:"spacing",htmlBuilder:function(e,t){if(An.hasOwnProperty(e.text)){var r=An[e.text].className||"";if("text"===e.mode){var n=yt.makeOrd(e,t,"textord");return n.classes.push(r),n}return yt.makeSpan(["mspace",r],[yt.mathsym(e.text,e.mode,t)],t)}if(Sn.hasOwnProperty(e.text))return yt.makeSpan(["mspace",Sn[e.text]],[],t);throw new B('Unknown type of space "'+e.text+'"')},mathmlBuilder:function(e){if(!An.hasOwnProperty(e.text)){if(Sn.hasOwnProperty(e.text))return new Gt.MathNode("mspace");throw new B('Unknown type of space "'+e.text+'"')}return new Gt.MathNode("mtext",[new Gt.TextNode("\xa0")])}});var Mn=function(){ +var e=new Gt.MathNode("mtd",[]);return e.setAttribute("width","50%"),e};o({type:"tag",mathmlBuilder:function(e,t){var r=new Gt.MathNode("mtable",[new Gt.MathNode("mtr",[Mn(),new Gt.MathNode("mtd",[Xt(e.body,t)]),Mn(),new Gt.MathNode("mtd",[Xt(e.tag,t)])])]);return r.setAttribute("width","100%"),r}});var zn={"\\text":void 0,"\\textrm":"textrm","\\textsf":"textsf","\\texttt":"texttt","\\textnormal":"textrm"},Tn={"\\textbf":"textbf","\\textmd":"textmd"},Cn={"\\textit":"textit","\\textup":"textup"},Bn=function(e,t){var r=e.font;return r?zn[r]?t.withTextFontFamily(zn[r]):Tn[r]?t.withTextFontWeight(Tn[r]):t.withTextFontShape(Cn[r]):t};a({type:"text",names:["\\text","\\textrm","\\textsf","\\texttt","\\textnormal","\\textbf","\\textmd","\\textit","\\textup"],props:{numArgs:1,argTypes:["text"],allowedInArgument:!0,allowedInText:!0},handler:function(e,t){var r=e.parser,n=e.funcName,i=t[0];return{type:"text",mode:r.mode,body:Ct(i),font:n}},htmlBuilder:function(e,t){var r=Bn(e,t),n=It(e.body,r,!0);return yt.makeSpan(["mord","text"],n,r)},mathmlBuilder:function(e,t){var r=Bn(e,t);return Xt(e.body,r)}}),a({type:"underline",names:["\\underline"],props:{numArgs:1,allowedInText:!0},handler:function(e,t){return{type:"underline",mode:e.parser.mode,body:t[0]}},htmlBuilder:function(e,t){var r=Ft(e.body,t),n=yt.makeLineSpan("underline-line",t),i=t.fontMetrics().defaultRuleThickness,a=yt.makeVList({positionType:"top",positionData:r.height,children:[{type:"kern",size:i},{type:"elem",elem:n},{type:"kern",size:3*i},{type:"elem",elem:r}]},t);return yt.makeSpan(["mord","underline"],[a],t)},mathmlBuilder:function(e,t){var r=new Gt.MathNode("mo",[new Gt.TextNode("\u203e")]);r.setAttribute("stretchy","true");var n=new Gt.MathNode("munder",[$t(e.body,t),r]);return n.setAttribute("accentunder","true"),n}}),a({type:"vcenter",names:["\\vcenter"],props:{numArgs:1,argTypes:["original"],allowedInText:!1},handler:function(e,t){return{type:"vcenter",mode:e.parser.mode,body:t[0]}},htmlBuilder:function(e,t){var r=Ft(e.body,t),n=t.fontMetrics().axisHeight,i=.5*(r.height-n-(r.depth+n));return yt.makeVList({positionType:"shift",positionData:i,children:[{type:"elem",elem:r}]},t)},mathmlBuilder:function(e,t){return new Gt.MathNode("mpadded",[$t(e.body,t)],["vcenter"])}}),a({type:"verb",names:["\\verb"],props:{numArgs:0,allowedInText:!0},handler:function(){throw new B("\\verb ended by end of line instead of matching delimiter")},htmlBuilder:function(e,t){for(var r=Ln(e),n=[],i=t.havingStyle(t.style.text()),a=0;a0;)this.endGroup()},t.has=function(e){return this.current.hasOwnProperty(e)||this.builtins.hasOwnProperty(e)},t.get=function(e){return this.current.hasOwnProperty(e)?this.current[e]:this.builtins[e]},t.set=function(e,t,r){if(void 0===r&&(r=!1),r){for(var n=0;n0&&(this.undefStack[this.undefStack.length-1][e]=t)}else{var i=this.undefStack[this.undefStack.length-1];i&&!i.hasOwnProperty(e)&&(i[e]=this.current[e])}null==t?delete this.current[e]:this.current[e]=t},e}(),Pn=Ur;x("\\noexpand",function(e){var t=e.popToken();return e.isExpandable(t.text)&&(t.noexpand=!0,t.treatAsRelax=!0),{tokens:[t],numArgs:0}}),x("\\expandafter",function(e){var t=e.popToken();return e.expandOnce(!0),{tokens:[t],numArgs:0}}),x("\\@firstoftwo",function(e){return{tokens:e.consumeArgs(2)[0],numArgs:0}}),x("\\@secondoftwo",function(e){return{tokens:e.consumeArgs(2)[1],numArgs:0}}),x("\\@ifnextchar",function(e){var t=e.consumeArgs(3);e.consumeSpaces();var r=e.future();return 1===t[0].length&&t[0][0].text===r.text?{tokens:t[1],numArgs:0}:{tokens:t[2],numArgs:0}}),x("\\@ifstar","\\@ifnextchar *{\\@firstoftwo{#1}}"),x("\\TextOrMath",function(e){var t=e.consumeArgs(2);return"text"===e.mode?{tokens:t[0],numArgs:0}:{tokens:t[1],numArgs:0}});var Fn={0:0,1:1,2:2,3:3,4:4,5:5,6:6,7:7,8:8,9:9,a:10,A:10,b:11,B:11,c:12,C:12,d:13,D:13,e:14,E:14,f:15,F:15};x("\\char",function(e){var t,r=e.popToken(),n="";if("'"===r.text)t=8,r=e.popToken();else if('"'===r.text)t=16,r=e.popToken();else if("`"===r.text)if("\\"===(r=e.popToken()).text[0])n=r.text.charCodeAt(1);else{if("EOF"===r.text)throw new B("\\char` missing argument");n=r.text.charCodeAt(0)}else t=10;if(t){if(null==(n=Fn[r.text])||n>=t)throw new B("Invalid base-"+t+" digit "+r.text);for(var i;null!=(i=Fn[e.future().text])&&i":"\\dotsb","-":"\\dotsb","*":"\\dotsb",":":"\\dotsb","\\DOTSB":"\\dotsb","\\coprod":"\\dotsb","\\bigvee":"\\dotsb","\\bigwedge":"\\dotsb","\\biguplus":"\\dotsb","\\bigcap":"\\dotsb","\\bigcup":"\\dotsb","\\prod":"\\dotsb","\\sum":"\\dotsb","\\bigotimes":"\\dotsb","\\bigoplus":"\\dotsb","\\bigodot":"\\dotsb","\\bigsqcup":"\\dotsb","\\And":"\\dotsb","\\longrightarrow":"\\dotsb","\\Longrightarrow":"\\dotsb","\\longleftarrow":"\\dotsb","\\Longleftarrow":"\\dotsb","\\longleftrightarrow":"\\dotsb","\\Longleftrightarrow":"\\dotsb","\\mapsto":"\\dotsb","\\longmapsto":"\\dotsb","\\hookrightarrow":"\\dotsb","\\doteq":"\\dotsb","\\mathbin":"\\dotsb","\\mathrel":"\\dotsb","\\relbar":"\\dotsb","\\Relbar":"\\dotsb","\\xrightarrow":"\\dotsb","\\xleftarrow":"\\dotsb","\\DOTSI":"\\dotsi","\\int":"\\dotsi","\\oint":"\\dotsi","\\iint":"\\dotsi","\\iiint":"\\dotsi","\\iiiint":"\\dotsi","\\idotsint":"\\dotsi","\\DOTSX":"\\dotsx"};x("\\dots",function(e){var t="\\dotso",r=e.expandAfterFuture().text;return r in Vn?t=Vn[r]:("\\not"===r.slice(0,4)||r in ze.math&&I.contains(["bin","rel"],ze.math[r].group))&&(t="\\dotsb"),t});var Gn={")":!0,"]":!0,"\\rbrack":!0,"\\}":!0,"\\rbrace":!0,"\\rangle":!0,"\\rceil":!0,"\\rfloor":!0,"\\rgroup":!0,"\\rmoustache":!0,"\\right":!0,"\\bigr":!0,"\\biggr":!0,"\\Bigr":!0,"\\Biggr":!0,$:!0,";":!0,".":!0,",":!0};x("\\dotso",function(e){return e.future().text in Gn?"\\ldots\\,":"\\ldots"}),x("\\dotsc",function(e){var t=e.future().text;return t in Gn&&","!==t?"\\ldots\\,":"\\ldots"}),x("\\cdots",function(e){return e.future().text in Gn?"\\@cdots\\,":"\\@cdots"}),x("\\dotsb","\\cdots"),x("\\dotsm","\\cdots"),x("\\dotsi","\\!\\cdots"),x("\\dotsx","\\ldots\\,"),x("\\DOTSI","\\relax"),x("\\DOTSB","\\relax"),x("\\DOTSX","\\relax"),x("\\tmspace","\\TextOrMath{\\kern#1#3}{\\mskip#1#2}\\relax"),x("\\,","\\tmspace+{3mu}{.1667em}"),x("\\thinspace","\\,"),x("\\>","\\mskip{4mu}"),x("\\:","\\tmspace+{4mu}{.2222em}"),x("\\medspace","\\:"),x("\\;","\\tmspace+{5mu}{.2777em}"),x("\\thickspace","\\;"),x("\\!","\\tmspace-{3mu}{.1667em}"),x("\\negthinspace","\\!"),x("\\negmedspace","\\tmspace-{4mu}{.2222em}"),x("\\negthickspace","\\tmspace-{5mu}{.277em}"),x("\\enspace","\\kern.5em "),x("\\enskip","\\hskip.5em\\relax"),x("\\quad","\\hskip1em\\relax"),x("\\qquad","\\hskip2em\\relax"),x("\\tag","\\@ifstar\\tag@literal\\tag@paren"),x("\\tag@paren","\\tag@literal{({#1})}"),x("\\tag@literal",function(e){if(e.macros.get("\\df@tag"))throw new B("Multiple \\tag");return"\\gdef\\df@tag{\\text{#1}}"}),x("\\bmod","\\mathchoice{\\mskip1mu}{\\mskip1mu}{\\mskip5mu}{\\mskip5mu}\\mathbin{\\rm mod}\\mathchoice{\\mskip1mu}{\\mskip1mu}{\\mskip5mu}{\\mskip5mu}"),x("\\pod","\\allowbreak\\mathchoice{\\mkern18mu}{\\mkern8mu}{\\mkern8mu}{\\mkern8mu}(#1)"),x("\\pmod","\\pod{{\\rm mod}\\mkern6mu#1}"),x("\\mod","\\allowbreak\\mathchoice{\\mkern18mu}{\\mkern12mu}{\\mkern12mu}{\\mkern12mu}{\\rm mod}\\,\\,#1"),x("\\newline","\\\\\\relax"),x("\\TeX","\\textrm{\\html@mathml{T\\kern-.1667em\\raisebox{-.5ex}{E}\\kern-.125emX}{TeX}}");var jn=ce(Z["Main-Regular"]["T".charCodeAt(0)][1]-.7*Z["Main-Regular"]["A".charCodeAt(0)][1]);x("\\LaTeX","\\textrm{\\html@mathml{L\\kern-.36em\\raisebox{"+jn+"}{\\scriptstyle A}\\kern-.15em\\TeX}{LaTeX}}"),x("\\KaTeX","\\textrm{\\html@mathml{K\\kern-.17em\\raisebox{"+jn+"}{\\scriptstyle A}\\kern-.15em\\TeX}{KaTeX}}"),x("\\hspace","\\@ifstar\\@hspacer\\@hspace"),x("\\@hspace","\\hskip #1\\relax"),x("\\@hspacer","\\rule{0pt}{0pt}\\hskip #1\\relax"),x("\\ordinarycolon",":"),x("\\vcentcolon","\\mathrel{\\mathop\\ordinarycolon}"),x("\\dblcolon",'\\html@mathml{\\mathrel{\\vcentcolon\\mathrel{\\mkern-.9mu}\\vcentcolon}}{\\mathop{\\char"2237}}'),x("\\coloneqq",'\\html@mathml{\\mathrel{\\vcentcolon\\mathrel{\\mkern-1.2mu}=}}{\\mathop{\\char"2254}}'),x("\\Coloneqq",'\\html@mathml{\\mathrel{\\dblcolon\\mathrel{\\mkern-1.2mu}=}}{\\mathop{\\char"2237\\char"3d}}'),x("\\coloneq",'\\html@mathml{\\mathrel{\\vcentcolon\\mathrel{\\mkern-1.2mu}\\mathrel{-}}}{\\mathop{\\char"3a\\char"2212}}'),x("\\Coloneq",'\\html@mathml{\\mathrel{\\dblcolon\\mathrel{\\mkern-1.2mu}\\mathrel{-}}}{\\mathop{\\char"2237\\char"2212}}'),x("\\eqqcolon",'\\html@mathml{\\mathrel{=\\mathrel{\\mkern-1.2mu}\\vcentcolon}}{\\mathop{\\char"2255}}'),x("\\Eqqcolon",'\\html@mathml{\\mathrel{=\\mathrel{\\mkern-1.2mu}\\dblcolon}}{\\mathop{\\char"3d\\char"2237}}'),x("\\eqcolon",'\\html@mathml{\\mathrel{\\mathrel{-}\\mathrel{\\mkern-1.2mu}\\vcentcolon}}{\\mathop{\\char"2239}}'),x("\\Eqcolon",'\\html@mathml{\\mathrel{\\mathrel{-}\\mathrel{\\mkern-1.2mu}\\dblcolon}}{\\mathop{\\char"2212\\char"2237}}'),x("\\colonapprox",'\\html@mathml{\\mathrel{\\vcentcolon\\mathrel{\\mkern-1.2mu}\\approx}}{\\mathop{\\char"3a\\char"2248}}'),x("\\Colonapprox",'\\html@mathml{\\mathrel{\\dblcolon\\mathrel{\\mkern-1.2mu}\\approx}}{\\mathop{\\char"2237\\char"2248}}'),x("\\colonsim",'\\html@mathml{\\mathrel{\\vcentcolon\\mathrel{\\mkern-1.2mu}\\sim}}{\\mathop{\\char"3a\\char"223c}}'),x("\\Colonsim",'\\html@mathml{\\mathrel{\\dblcolon\\mathrel{\\mkern-1.2mu}\\sim}}{\\mathop{\\char"2237\\char"223c}}'),x("\u2237","\\dblcolon"),x("\u2239","\\eqcolon"),x("\u2254","\\coloneqq"),x("\u2255","\\eqqcolon"),x("\u2a74","\\Coloneqq"),x("\\ratio","\\vcentcolon"),x("\\coloncolon","\\dblcolon"),x("\\colonequals","\\coloneqq"),x("\\coloncolonequals","\\Coloneqq"),x("\\equalscolon","\\eqqcolon"),x("\\equalscoloncolon","\\Eqqcolon"),x("\\colonminus","\\coloneq"),x("\\coloncolonminus","\\Coloneq"),x("\\minuscolon","\\eqcolon"),x("\\minuscoloncolon","\\Eqcolon"),x("\\coloncolonapprox","\\Colonapprox"),x("\\coloncolonsim","\\Colonsim"),x("\\simcolon","\\mathrel{\\sim\\mathrel{\\mkern-1.2mu}\\vcentcolon}"),x("\\simcoloncolon","\\mathrel{\\sim\\mathrel{\\mkern-1.2mu}\\dblcolon}"),x("\\approxcolon","\\mathrel{\\approx\\mathrel{\\mkern-1.2mu}\\vcentcolon}"),x("\\approxcoloncolon","\\mathrel{\\approx\\mathrel{\\mkern-1.2mu}\\dblcolon}"),x("\\notni","\\html@mathml{\\not\\ni}{\\mathrel{\\char`\u220c}}"),x("\\limsup","\\DOTSB\\operatorname*{lim\\,sup}"),x("\\liminf","\\DOTSB\\operatorname*{lim\\,inf}"),x("\\injlim","\\DOTSB\\operatorname*{inj\\,lim}"),x("\\projlim","\\DOTSB\\operatorname*{proj\\,lim}"),x("\\varlimsup","\\DOTSB\\operatorname*{\\overline{lim}}"),x("\\varliminf","\\DOTSB\\operatorname*{\\underline{lim}}"),x("\\varinjlim","\\DOTSB\\operatorname*{\\underrightarrow{lim}}"),x("\\varprojlim","\\DOTSB\\operatorname*{\\underleftarrow{lim}}"),x("\\gvertneqq","\\html@mathml{\\@gvertneqq}{\u2269}"),x("\\lvertneqq","\\html@mathml{\\@lvertneqq}{\u2268}"),x("\\ngeqq","\\html@mathml{\\@ngeqq}{\u2271}"),x("\\ngeqslant","\\html@mathml{\\@ngeqslant}{\u2271}"),x("\\nleqq","\\html@mathml{\\@nleqq}{\u2270}"),x("\\nleqslant","\\html@mathml{\\@nleqslant}{\u2270}"),x("\\nshortmid","\\html@mathml{\\@nshortmid}{\u2224}"),x("\\nshortparallel","\\html@mathml{\\@nshortparallel}{\u2226}"),x("\\nsubseteqq","\\html@mathml{\\@nsubseteqq}{\u2288}"),x("\\nsupseteqq","\\html@mathml{\\@nsupseteqq}{\u2289}"),x("\\varsubsetneq","\\html@mathml{\\@varsubsetneq}{\u228a}"),x("\\varsubsetneqq","\\html@mathml{\\@varsubsetneqq}{\u2acb}"),x("\\varsupsetneq","\\html@mathml{\\@varsupsetneq}{\u228b}"),x("\\varsupsetneqq","\\html@mathml{\\@varsupsetneqq}{\u2acc}"),x("\\imath","\\html@mathml{\\@imath}{\u0131}"),x("\\jmath","\\html@mathml{\\@jmath}{\u0237}"),x("\\llbracket","\\html@mathml{\\mathopen{[\\mkern-3.2mu[}}{\\mathopen{\\char`\u27e6}}"),x("\\rrbracket","\\html@mathml{\\mathclose{]\\mkern-3.2mu]}}{\\mathclose{\\char`\u27e7}}"),x("\u27e6","\\llbracket"),x("\u27e7","\\rrbracket"),x("\\lBrace","\\html@mathml{\\mathopen{\\{\\mkern-3.2mu[}}{\\mathopen{\\char`\u2983}}"),x("\\rBrace","\\html@mathml{\\mathclose{]\\mkern-3.2mu\\}}}{\\mathclose{\\char`\u2984}}"),x("\u2983","\\lBrace"),x("\u2984","\\rBrace"),x("\\minuso","\\mathbin{\\html@mathml{{\\mathrlap{\\mathchoice{\\kern{0.145em}}{\\kern{0.145em}}{\\kern{0.1015em}}{\\kern{0.0725em}}\\circ}{-}}}{\\char`\u29b5}}"),x("\u29b5","\\minuso"),x("\\darr","\\downarrow"),x("\\dArr","\\Downarrow"),x("\\Darr","\\Downarrow"),x("\\lang","\\langle"),x("\\rang","\\rangle"),x("\\uarr","\\uparrow"),x("\\uArr","\\Uparrow"),x("\\Uarr","\\Uparrow"),x("\\N","\\mathbb{N}"),x("\\R","\\mathbb{R}"),x("\\Z","\\mathbb{Z}"),x("\\alef","\\aleph"),x("\\alefsym","\\aleph"),x("\\Alpha","\\mathrm{A}"),x("\\Beta","\\mathrm{B}"),x("\\bull","\\bullet"),x("\\Chi","\\mathrm{X}"),x("\\clubs","\\clubsuit"),x("\\cnums","\\mathbb{C}"),x("\\Complex","\\mathbb{C}"),x("\\Dagger","\\ddagger"),x("\\diamonds","\\diamondsuit"),x("\\empty","\\emptyset"),x("\\Epsilon","\\mathrm{E}"),x("\\Eta","\\mathrm{H}"),x("\\exist","\\exists"),x("\\harr","\\leftrightarrow"),x("\\hArr","\\Leftrightarrow"),x("\\Harr","\\Leftrightarrow"),x("\\hearts","\\heartsuit"),x("\\image","\\Im"),x("\\infin","\\infty"),x("\\Iota","\\mathrm{I}"),x("\\isin","\\in"),x("\\Kappa","\\mathrm{K}"),x("\\larr","\\leftarrow"),x("\\lArr","\\Leftarrow"),x("\\Larr","\\Leftarrow"),x("\\lrarr","\\leftrightarrow"),x("\\lrArr","\\Leftrightarrow"),x("\\Lrarr","\\Leftrightarrow"),x("\\Mu","\\mathrm{M}"),x("\\natnums","\\mathbb{N}"),x("\\Nu","\\mathrm{N}"),x("\\Omicron","\\mathrm{O}"),x("\\plusmn","\\pm"),x("\\rarr","\\rightarrow"),x("\\rArr","\\Rightarrow"),x("\\Rarr","\\Rightarrow"),x("\\real","\\Re"),x("\\reals","\\mathbb{R}"),x("\\Reals","\\mathbb{R}"),x("\\Rho","\\mathrm{P}"),x("\\sdot","\\cdot"),x("\\sect","\\S"),x("\\spades","\\spadesuit"),x("\\sub","\\subset"),x("\\sube","\\subseteq"),x("\\supe","\\supseteq"),x("\\Tau","\\mathrm{T}"),x("\\thetasym","\\vartheta"),x("\\weierp","\\wp"),x("\\Zeta","\\mathrm{Z}"),x("\\argmin","\\DOTSB\\operatorname*{arg\\,min}"),x("\\argmax","\\DOTSB\\operatorname*{arg\\,max}"),x("\\plim","\\DOTSB\\mathop{\\operatorname{plim}}\\limits"),x("\\bra","\\mathinner{\\langle{#1}|}"),x("\\ket","\\mathinner{|{#1}\\rangle}"),x("\\braket","\\mathinner{\\langle{#1}\\rangle}"),x("\\Bra","\\left\\langle#1\\right|"),x("\\Ket","\\left|#1\\right\\rangle");var Un=function(e){return function(t){var r=t.consumeArg().tokens,n=t.consumeArg().tokens,i=t.consumeArg().tokens,a=t.consumeArg().tokens,o=t.macros.get("|"),s=t.macros.get("\\|");t.macros.beginGroup();var l=function(t){return function(r){e&&(r.macros.set("|",o),i.length&&r.macros.set("\\|",s));var a=t;return!t&&i.length&&"|"===r.future().text&&(r.popToken(),a=!0),{tokens:a?i:n,numArgs:0}}};t.macros.set("|",l(!1)),i.length&&t.macros.set("\\|",l(!0));var h=t.consumeArg().tokens,c=t.expandTokens([].concat(a,h,r));return t.macros.endGroup(),{tokens:c.reverse(),numArgs:0}}};x("\\bra@ket",Un(!1)),x("\\bra@set",Un(!0)),x("\\Braket","\\bra@ket{\\left\\langle}{\\,\\middle\\vert\\,}{\\,\\middle\\vert\\,}{\\right\\rangle}"),x("\\Set","\\bra@set{\\left\\{\\:}{\\;\\middle\\vert\\;}{\\;\\middle\\Vert\\;}{\\:\\right\\}}"),x("\\set","\\bra@set{\\{\\,}{\\mid}{}{\\,\\}}"),x("\\angln","{\\angl n}"),x("\\blue","\\textcolor{##6495ed}{#1}"),x("\\orange","\\textcolor{##ffa500}{#1}"),x("\\pink","\\textcolor{##ff00af}{#1}"),x("\\red","\\textcolor{##df0030}{#1}"),x("\\green","\\textcolor{##28ae7b}{#1}"),x("\\gray","\\textcolor{gray}{#1}"),x("\\purple","\\textcolor{##9d38bd}{#1}"),x("\\blueA","\\textcolor{##ccfaff}{#1}"),x("\\blueB","\\textcolor{##80f6ff}{#1}"),x("\\blueC","\\textcolor{##63d9ea}{#1}"),x("\\blueD","\\textcolor{##11accd}{#1}"),x("\\blueE","\\textcolor{##0c7f99}{#1}"),x("\\tealA","\\textcolor{##94fff5}{#1}"),x("\\tealB","\\textcolor{##26edd5}{#1}"),x("\\tealC","\\textcolor{##01d1c1}{#1}"),x("\\tealD","\\textcolor{##01a995}{#1}"),x("\\tealE","\\textcolor{##208170}{#1}"),x("\\greenA","\\textcolor{##b6ffb0}{#1}"),x("\\greenB","\\textcolor{##8af281}{#1}"),x("\\greenC","\\textcolor{##74cf70}{#1}"),x("\\greenD","\\textcolor{##1fab54}{#1}"),x("\\greenE","\\textcolor{##0d923f}{#1}"),x("\\goldA","\\textcolor{##ffd0a9}{#1}"),x("\\goldB","\\textcolor{##ffbb71}{#1}"),x("\\goldC","\\textcolor{##ff9c39}{#1}"),x("\\goldD","\\textcolor{##e07d10}{#1}"),x("\\goldE","\\textcolor{##a75a05}{#1}"),x("\\redA","\\textcolor{##fca9a9}{#1}"),x("\\redB","\\textcolor{##ff8482}{#1}"),x("\\redC","\\textcolor{##f9685d}{#1}"),x("\\redD","\\textcolor{##e84d39}{#1}"),x("\\redE","\\textcolor{##bc2612}{#1}"),x("\\maroonA","\\textcolor{##ffbde0}{#1}"),x("\\maroonB","\\textcolor{##ff92c6}{#1}"),x("\\maroonC","\\textcolor{##ed5fa6}{#1}"),x("\\maroonD","\\textcolor{##ca337c}{#1}"),x("\\maroonE","\\textcolor{##9e034e}{#1}"),x("\\purpleA","\\textcolor{##ddd7ff}{#1}"),x("\\purpleB","\\textcolor{##c6b9fc}{#1}"),x("\\purpleC","\\textcolor{##aa87ff}{#1}"),x("\\purpleD","\\textcolor{##7854ab}{#1}"),x("\\purpleE","\\textcolor{##543b78}{#1}"),x("\\mintA","\\textcolor{##f5f9e8}{#1}"),x("\\mintB","\\textcolor{##edf2df}{#1}"),x("\\mintC","\\textcolor{##e0e5cc}{#1}"),x("\\grayA","\\textcolor{##f6f7f7}{#1}"),x("\\grayB","\\textcolor{##f0f1f2}{#1}"),x("\\grayC","\\textcolor{##e3e5e6}{#1}"),x("\\grayD","\\textcolor{##d6d8da}{#1}"),x("\\grayE","\\textcolor{##babec2}{#1}"),x("\\grayF","\\textcolor{##888d93}{#1}"),x("\\grayG","\\textcolor{##626569}{#1}"),x("\\grayH","\\textcolor{##3b3e40}{#1}"),x("\\grayI","\\textcolor{##21242c}{#1}"),x("\\kaBlue","\\textcolor{##314453}{#1}"),x("\\kaGreen","\\textcolor{##71B307}{#1}");var Wn={"^":!0,_:!0,"\\limits":!0,"\\nolimits":!0},Yn=function(){function e(e,t,r){this.settings=void 0,this.expansionCount=void 0,this.lexer=void 0,this.macros=void 0,this.stack=void 0,this.mode=void 0,this.settings=t,this.expansionCount=0,this.feed(e),this.macros=new Dn(Pn,t.macros),this.mode=r,this.stack=[]}var t=e.prototype;return t.feed=function(e){this.lexer=new Hn(e,this.settings)},t.switchMode=function(e){this.mode=e},t.beginGroup=function(){this.macros.beginGroup()},t.endGroup=function(){this.macros.endGroup()},t.endGroups=function(){this.macros.endGroups()},t.future=function(){return 0===this.stack.length&&this.pushToken(this.lexer.lex()),this.stack[this.stack.length-1]},t.popToken=function(){return this.future(),this.stack.pop()},t.pushToken=function(e){this.stack.push(e)},t.pushTokens=function(e){var t;(t=this.stack).push.apply(t,e)},t.scanArgument=function(e){var t,r,n;if(e){if(this.consumeSpaces(),"["!==this.future().text)return null;t=this.popToken();var i=this.consumeArg(["]"]);n=i.tokens,r=i.end}else{var a=this.consumeArg();n=a.tokens,t=a.start,r=a.end}return this.pushToken(new Yr("EOF",r.loc)),this.pushTokens(n),t.range(r,"")},t.consumeSpaces=function(){for(;" "===this.future().text;)this.stack.pop()},t.consumeArg=function(e){var t=[],r=e&&e.length>0;r||this.consumeSpaces();var n,i=this.future(),a=0,o=0;do{if(n=this.popToken(),t.push(n),"{"===n.text)++a;else if("}"===n.text){if(-1==--a)throw new B("Extra }",n)}else if("EOF"===n.text)throw new B("Unexpected end of input in a macro argument, expected '"+(e&&r?e[o]:"}")+"'",n);if(e&&r)if((0===a||1===a&&"{"===e[o])&&n.text===e[o]){if(++o===e.length){t.splice(-o,o);break}}else o=0}while(0!==a||r);return"{"===i.text&&"}"===t[t.length-1].text&&(t.pop(),t.shift()),t.reverse(),{tokens:t,start:i,end:n}},t.consumeArgs=function(e,t){if(t){if(t.length!==e+1)throw new B("The length of delimiters doesn't match the number of args!");for(var r=t[0],n=0;nthis.settings.maxExpand)throw new B("Too many expansions: infinite loop or need to increase maxExpand setting");var i=n.tokens,a=this.consumeArgs(n.numArgs,n.delimiters);if(n.numArgs)for(var o=(i=i.slice()).length-1;o>=0;--o){var s=i[o];if("#"===s.text){if(0===o)throw new B("Incomplete placeholder at end of macro body",s);if("#"===(s=i[--o]).text)i.splice(o+1,1);else{if(!/^[1-9]$/.test(s.text))throw new B("Not a valid argument number",s);var l;(l=i).splice.apply(l,[o,2].concat(a[+s.text-1]))}}}return this.pushTokens(i),i.length},t.expandAfterFuture=function(){return this.expandOnce(),this.future()},t.expandNextToken=function(){for(;;)if(!1===this.expandOnce()){var e=this.stack.pop();return e.treatAsRelax&&(e.text="\\relax"),e}throw new Error},t.expandMacro=function(e){return this.macros.has(e)?this.expandTokens([new Yr(e)]):void 0},t.expandTokens=function(e){var t=[],r=this.stack.length;for(this.pushTokens(e);this.stack.length>r;)if(!1===this.expandOnce(!0)){var n=this.stack.pop();n.treatAsRelax&&(n.noexpand=!1,n.treatAsRelax=!1),t.push(n)}return t},t.expandMacroAsText=function(e){var t=this.expandMacro(e);return t?t.map(function(e){return e.text}).join(""):t},t._getExpansion=function(e){var t=this.macros.get(e);if(null==t)return t;if(1===e.length){var r=this.lexer.catcodes[e];if(null!=r&&13!==r)return}var n="function"==typeof t?t(this):t;if("string"==typeof n){var i=0;if(-1!==n.indexOf("#"))for(var a=n.replace(/##/g,"");-1!==a.indexOf("#"+(i+1));)++i;for(var o=new Hn(n,this.settings),s=[],l=o.lex();"EOF"!==l.text;)s.push(l),l=o.lex();return s.reverse(),{tokens:s,numArgs:i}}return n},t.isDefined=function(e){return this.macros.has(e)||Nn.hasOwnProperty(e)||ze.math.hasOwnProperty(e)||ze.text.hasOwnProperty(e)||Wn.hasOwnProperty(e)},t.isExpandable=function(e){var t=this.macros.get(e);return null!=t?"string"==typeof t||"function"==typeof t||!t.unexpandable:Nn.hasOwnProperty(e)&&!Nn[e].primitive},e}(),Xn=/^[\u208a\u208b\u208c\u208d\u208e\u2080\u2081\u2082\u2083\u2084\u2085\u2086\u2087\u2088\u2089\u2090\u2091\u2095\u1d62\u2c7c\u2096\u2097\u2098\u2099\u2092\u209a\u1d63\u209b\u209c\u1d64\u1d65\u2093\u1d66\u1d67\u1d68\u1d69\u1d6a]/,$n=Object.freeze({"\u208a":"+","\u208b":"-","\u208c":"=","\u208d":"(","\u208e":")","\u2080":"0","\u2081":"1","\u2082":"2","\u2083":"3","\u2084":"4","\u2085":"5","\u2086":"6","\u2087":"7","\u2088":"8","\u2089":"9","\u2090":"a","\u2091":"e","\u2095":"h","\u1d62":"i","\u2c7c":"j","\u2096":"k","\u2097":"l","\u2098":"m","\u2099":"n","\u2092":"o","\u209a":"p","\u1d63":"r","\u209b":"s","\u209c":"t","\u1d64":"u","\u1d65":"v","\u2093":"x","\u1d66":"\u03b2","\u1d67":"\u03b3","\u1d68":"\u03c1","\u1d69":"\u03d5","\u1d6a":"\u03c7","\u207a":"+","\u207b":"-","\u207c":"=","\u207d":"(","\u207e":")","\u2070":"0","\xb9":"1","\xb2":"2","\xb3":"3","\u2074":"4","\u2075":"5","\u2076":"6","\u2077":"7","\u2078":"8","\u2079":"9","\u1d2c":"A","\u1d2e":"B","\u1d30":"D","\u1d31":"E","\u1d33":"G","\u1d34":"H","\u1d35":"I","\u1d36":"J","\u1d37":"K","\u1d38":"L","\u1d39":"M","\u1d3a":"N","\u1d3c":"O","\u1d3e":"P","\u1d3f":"R","\u1d40":"T","\u1d41":"U","\u2c7d":"V","\u1d42":"W","\u1d43":"a","\u1d47":"b","\u1d9c":"c","\u1d48":"d","\u1d49":"e","\u1da0":"f","\u1d4d":"g","\u02b0":"h","\u2071":"i","\u02b2":"j","\u1d4f":"k","\u02e1":"l","\u1d50":"m","\u207f":"n","\u1d52":"o","\u1d56":"p","\u02b3":"r","\u02e2":"s","\u1d57":"t","\u1d58":"u","\u1d5b":"v","\u02b7":"w","\u02e3":"x","\u02b8":"y","\u1dbb":"z","\u1d5d":"\u03b2","\u1d5e":"\u03b3","\u1d5f":"\u03b4","\u1d60":"\u03d5","\u1d61":"\u03c7","\u1dbf":"\u03b8"}),Kn={"\u0301":{text:"\\'",math:"\\acute"},"\u0300":{text:"\\`",math:"\\grave"},"\u0308":{text:'\\"',math:"\\ddot"},"\u0303":{text:"\\~",math:"\\tilde"},"\u0304":{text:"\\=",math:"\\bar"},"\u0306":{text:"\\u",math:"\\breve"},"\u030c":{text:"\\v",math:"\\check"},"\u0302":{text:"\\^",math:"\\hat"},"\u0307":{text:"\\.",math:"\\dot"},"\u030a":{text:"\\r",math:"\\mathring"},"\u030b":{text:"\\H"},"\u0327":{text:"\\c"}},Zn={"\xe1":"a\u0301","\xe0":"a\u0300","\xe4":"a\u0308","\u01df":"a\u0308\u0304","\xe3":"a\u0303","\u0101":"a\u0304","\u0103":"a\u0306","\u1eaf":"a\u0306\u0301","\u1eb1":"a\u0306\u0300","\u1eb5":"a\u0306\u0303","\u01ce":"a\u030c","\xe2":"a\u0302","\u1ea5":"a\u0302\u0301","\u1ea7":"a\u0302\u0300","\u1eab":"a\u0302\u0303","\u0227":"a\u0307","\u01e1":"a\u0307\u0304","\xe5":"a\u030a","\u01fb":"a\u030a\u0301","\u1e03":"b\u0307","\u0107":"c\u0301","\u1e09":"c\u0327\u0301","\u010d":"c\u030c","\u0109":"c\u0302","\u010b":"c\u0307","\xe7":"c\u0327","\u010f":"d\u030c","\u1e0b":"d\u0307","\u1e11":"d\u0327","\xe9":"e\u0301","\xe8":"e\u0300","\xeb":"e\u0308","\u1ebd":"e\u0303","\u0113":"e\u0304","\u1e17":"e\u0304\u0301","\u1e15":"e\u0304\u0300","\u0115":"e\u0306","\u1e1d":"e\u0327\u0306","\u011b":"e\u030c","\xea":"e\u0302","\u1ebf":"e\u0302\u0301","\u1ec1":"e\u0302\u0300","\u1ec5":"e\u0302\u0303","\u0117":"e\u0307","\u0229":"e\u0327","\u1e1f":"f\u0307","\u01f5":"g\u0301","\u1e21":"g\u0304","\u011f":"g\u0306","\u01e7":"g\u030c","\u011d":"g\u0302","\u0121":"g\u0307","\u0123":"g\u0327","\u1e27":"h\u0308","\u021f":"h\u030c","\u0125":"h\u0302","\u1e23":"h\u0307","\u1e29":"h\u0327","\xed":"i\u0301","\xec":"i\u0300","\xef":"i\u0308","\u1e2f":"i\u0308\u0301","\u0129":"i\u0303","\u012b":"i\u0304","\u012d":"i\u0306","\u01d0":"i\u030c","\xee":"i\u0302","\u01f0":"j\u030c","\u0135":"j\u0302","\u1e31":"k\u0301","\u01e9":"k\u030c","\u0137":"k\u0327","\u013a":"l\u0301","\u013e":"l\u030c","\u013c":"l\u0327","\u1e3f":"m\u0301","\u1e41":"m\u0307","\u0144":"n\u0301","\u01f9":"n\u0300","\xf1":"n\u0303","\u0148":"n\u030c","\u1e45":"n\u0307","\u0146":"n\u0327","\xf3":"o\u0301","\xf2":"o\u0300","\xf6":"o\u0308","\u022b":"o\u0308\u0304","\xf5":"o\u0303","\u1e4d":"o\u0303\u0301","\u1e4f":"o\u0303\u0308","\u022d":"o\u0303\u0304","\u014d":"o\u0304","\u1e53":"o\u0304\u0301","\u1e51":"o\u0304\u0300","\u014f":"o\u0306","\u01d2":"o\u030c","\xf4":"o\u0302","\u1ed1":"o\u0302\u0301","\u1ed3":"o\u0302\u0300","\u1ed7":"o\u0302\u0303","\u022f":"o\u0307","\u0231":"o\u0307\u0304", +"\u0151":"o\u030b","\u1e55":"p\u0301","\u1e57":"p\u0307","\u0155":"r\u0301","\u0159":"r\u030c","\u1e59":"r\u0307","\u0157":"r\u0327","\u015b":"s\u0301","\u1e65":"s\u0301\u0307","\u0161":"s\u030c","\u1e67":"s\u030c\u0307","\u015d":"s\u0302","\u1e61":"s\u0307","\u015f":"s\u0327","\u1e97":"t\u0308","\u0165":"t\u030c","\u1e6b":"t\u0307","\u0163":"t\u0327","\xfa":"u\u0301","\xf9":"u\u0300","\xfc":"u\u0308","\u01d8":"u\u0308\u0301","\u01dc":"u\u0308\u0300","\u01d6":"u\u0308\u0304","\u01da":"u\u0308\u030c","\u0169":"u\u0303","\u1e79":"u\u0303\u0301","\u016b":"u\u0304","\u1e7b":"u\u0304\u0308","\u016d":"u\u0306","\u01d4":"u\u030c","\xfb":"u\u0302","\u016f":"u\u030a","\u0171":"u\u030b","\u1e7d":"v\u0303","\u1e83":"w\u0301","\u1e81":"w\u0300","\u1e85":"w\u0308","\u0175":"w\u0302","\u1e87":"w\u0307","\u1e98":"w\u030a","\u1e8d":"x\u0308","\u1e8b":"x\u0307","\xfd":"y\u0301","\u1ef3":"y\u0300","\xff":"y\u0308","\u1ef9":"y\u0303","\u0233":"y\u0304","\u0177":"y\u0302","\u1e8f":"y\u0307","\u1e99":"y\u030a","\u017a":"z\u0301","\u017e":"z\u030c","\u1e91":"z\u0302","\u017c":"z\u0307","\xc1":"A\u0301","\xc0":"A\u0300","\xc4":"A\u0308","\u01de":"A\u0308\u0304","\xc3":"A\u0303","\u0100":"A\u0304","\u0102":"A\u0306","\u1eae":"A\u0306\u0301","\u1eb0":"A\u0306\u0300","\u1eb4":"A\u0306\u0303","\u01cd":"A\u030c","\xc2":"A\u0302","\u1ea4":"A\u0302\u0301","\u1ea6":"A\u0302\u0300","\u1eaa":"A\u0302\u0303","\u0226":"A\u0307","\u01e0":"A\u0307\u0304","\xc5":"A\u030a","\u01fa":"A\u030a\u0301","\u1e02":"B\u0307","\u0106":"C\u0301","\u1e08":"C\u0327\u0301","\u010c":"C\u030c","\u0108":"C\u0302","\u010a":"C\u0307","\xc7":"C\u0327","\u010e":"D\u030c","\u1e0a":"D\u0307","\u1e10":"D\u0327","\xc9":"E\u0301","\xc8":"E\u0300","\xcb":"E\u0308","\u1ebc":"E\u0303","\u0112":"E\u0304","\u1e16":"E\u0304\u0301","\u1e14":"E\u0304\u0300","\u0114":"E\u0306","\u1e1c":"E\u0327\u0306","\u011a":"E\u030c","\xca":"E\u0302","\u1ebe":"E\u0302\u0301","\u1ec0":"E\u0302\u0300","\u1ec4":"E\u0302\u0303","\u0116":"E\u0307","\u0228":"E\u0327","\u1e1e":"F\u0307","\u01f4":"G\u0301","\u1e20":"G\u0304","\u011e":"G\u0306","\u01e6":"G\u030c","\u011c":"G\u0302","\u0120":"G\u0307","\u0122":"G\u0327","\u1e26":"H\u0308","\u021e":"H\u030c","\u0124":"H\u0302","\u1e22":"H\u0307","\u1e28":"H\u0327","\xcd":"I\u0301","\xcc":"I\u0300","\xcf":"I\u0308","\u1e2e":"I\u0308\u0301","\u0128":"I\u0303","\u012a":"I\u0304","\u012c":"I\u0306","\u01cf":"I\u030c","\xce":"I\u0302","\u0130":"I\u0307","\u0134":"J\u0302","\u1e30":"K\u0301","\u01e8":"K\u030c","\u0136":"K\u0327","\u0139":"L\u0301","\u013d":"L\u030c","\u013b":"L\u0327","\u1e3e":"M\u0301","\u1e40":"M\u0307","\u0143":"N\u0301","\u01f8":"N\u0300","\xd1":"N\u0303","\u0147":"N\u030c","\u1e44":"N\u0307","\u0145":"N\u0327","\xd3":"O\u0301","\xd2":"O\u0300","\xd6":"O\u0308","\u022a":"O\u0308\u0304","\xd5":"O\u0303","\u1e4c":"O\u0303\u0301","\u1e4e":"O\u0303\u0308","\u022c":"O\u0303\u0304","\u014c":"O\u0304","\u1e52":"O\u0304\u0301","\u1e50":"O\u0304\u0300","\u014e":"O\u0306","\u01d1":"O\u030c","\xd4":"O\u0302","\u1ed0":"O\u0302\u0301","\u1ed2":"O\u0302\u0300","\u1ed6":"O\u0302\u0303","\u022e":"O\u0307","\u0230":"O\u0307\u0304","\u0150":"O\u030b","\u1e54":"P\u0301","\u1e56":"P\u0307","\u0154":"R\u0301","\u0158":"R\u030c","\u1e58":"R\u0307","\u0156":"R\u0327","\u015a":"S\u0301","\u1e64":"S\u0301\u0307","\u0160":"S\u030c","\u1e66":"S\u030c\u0307","\u015c":"S\u0302","\u1e60":"S\u0307","\u015e":"S\u0327","\u0164":"T\u030c","\u1e6a":"T\u0307","\u0162":"T\u0327","\xda":"U\u0301","\xd9":"U\u0300","\xdc":"U\u0308","\u01d7":"U\u0308\u0301","\u01db":"U\u0308\u0300","\u01d5":"U\u0308\u0304","\u01d9":"U\u0308\u030c","\u0168":"U\u0303","\u1e78":"U\u0303\u0301","\u016a":"U\u0304","\u1e7a":"U\u0304\u0308","\u016c":"U\u0306","\u01d3":"U\u030c","\xdb":"U\u0302","\u016e":"U\u030a","\u0170":"U\u030b","\u1e7c":"V\u0303","\u1e82":"W\u0301","\u1e80":"W\u0300","\u1e84":"W\u0308","\u0174":"W\u0302","\u1e86":"W\u0307","\u1e8c":"X\u0308","\u1e8a":"X\u0307","\xdd":"Y\u0301","\u1ef2":"Y\u0300","\u0178":"Y\u0308","\u1ef8":"Y\u0303","\u0232":"Y\u0304","\u0176":"Y\u0302","\u1e8e":"Y\u0307","\u0179":"Z\u0301","\u017d":"Z\u030c","\u1e90":"Z\u0302","\u017b":"Z\u0307","\u03ac":"\u03b1\u0301","\u1f70":"\u03b1\u0300","\u1fb1":"\u03b1\u0304","\u1fb0":"\u03b1\u0306","\u03ad":"\u03b5\u0301","\u1f72":"\u03b5\u0300","\u03ae":"\u03b7\u0301","\u1f74":"\u03b7\u0300","\u03af":"\u03b9\u0301","\u1f76":"\u03b9\u0300","\u03ca":"\u03b9\u0308","\u0390":"\u03b9\u0308\u0301","\u1fd2":"\u03b9\u0308\u0300","\u1fd1":"\u03b9\u0304","\u1fd0":"\u03b9\u0306","\u03cc":"\u03bf\u0301","\u1f78":"\u03bf\u0300","\u03cd":"\u03c5\u0301","\u1f7a":"\u03c5\u0300","\u03cb":"\u03c5\u0308","\u03b0":"\u03c5\u0308\u0301","\u1fe2":"\u03c5\u0308\u0300","\u1fe1":"\u03c5\u0304","\u1fe0":"\u03c5\u0306","\u03ce":"\u03c9\u0301","\u1f7c":"\u03c9\u0300","\u038e":"\u03a5\u0301","\u1fea":"\u03a5\u0300","\u03ab":"\u03a5\u0308","\u1fe9":"\u03a5\u0304","\u1fe8":"\u03a5\u0306","\u038f":"\u03a9\u0301","\u1ffa":"\u03a9\u0300"},Jn=function(){function e(e,t){this.mode=void 0,this.gullet=void 0,this.settings=void 0,this.leftrightDepth=void 0,this.nextToken=void 0,this.mode="math",this.gullet=new Yn(e,t,this.mode),this.settings=t,this.leftrightDepth=0}var r=e.prototype;return r.expect=function(e,t){if(void 0===t&&(t=!0),this.fetch().text!==e)throw new B("Expected '"+e+"', got '"+this.fetch().text+"'",this.fetch());t&&this.consume()},r.consume=function(){this.nextToken=null},r.fetch=function(){return null==this.nextToken&&(this.nextToken=this.gullet.expandNextToken()),this.nextToken},r.switchMode=function(e){this.mode=e,this.gullet.switchMode(e)},r.parse=function(){this.settings.globalGroup||this.gullet.beginGroup(),this.settings.colorIsTextColor&&this.gullet.macros.set("\\color","\\textcolor");try{var e=this.parseExpression(!1);return this.expect("EOF"),this.settings.globalGroup||this.gullet.endGroup(),e}finally{this.gullet.endGroups()}},r.subparse=function(e){var t=this.nextToken;this.consume(),this.gullet.pushToken(new Yr("}")),this.gullet.pushTokens(e);var r=this.parseExpression(!1);return this.expect("}"),this.nextToken=t,r},r.parseExpression=function(t,r){for(var n=[];;){"math"===this.mode&&this.consumeSpaces();var i=this.fetch();if(-1!==e.endOfExpression.indexOf(i.text))break;if(r&&i.text===r)break;if(t&&Nn[i.text]&&Nn[i.text].infix)break;var a=this.parseAtom(r);if(!a)break;"internal"!==a.type&&n.push(a)}return"text"===this.mode&&this.formLigatures(n),this.handleInfixNodes(n)},r.handleInfixNodes=function(e){for(var t,r=-1,n=0;n=0&&this.settings.reportNonstrict("unicodeTextInMathMode",'Latin-1/Unicode text character "'+r[0]+'" used in math mode',e);var s,l=ze[this.mode][r].group,h=Wr.range(e);if(Se.hasOwnProperty(l)){var c=l;s={type:"atom",mode:this.mode,family:c,loc:h,text:r}}else s={type:l,mode:this.mode,loc:h,text:r};a=s}else{if(!(r.charCodeAt(0)>=128))return null;this.settings.strict&&(t(r.charCodeAt(0))?"math"===this.mode&&this.settings.reportNonstrict("unicodeTextInMathMode",'Unicode text character "'+r[0]+'" used in math mode',e):this.settings.reportNonstrict("unknownSymbol",'Unrecognized Unicode character "'+r[0]+'" ('+r.charCodeAt(0)+")",e)),a={type:"textord",mode:"text",loc:Wr.range(e),text:r}}if(this.consume(),o)for(var u=0;u0&&(i.push({type:"text",data:e.slice(0,r)}),e=e.slice(r));var s=t.findIndex(function(t){return e.startsWith(t.left)});if(-1===(r=n(t[s].right,e,t[s].left.length)))break;var l=e.slice(0,r+t[s].right.length),h=a.test(l)?l:e.slice(t[s].left.length,r);i.push({type:"math",data:h,rawData:l,display:t[s].display}),e=e.slice(r+t[s].right.length)}return""!==e&&i.push({type:"text",data:e}),i},s=function(e,t){var n=o(e,t.delimiters);if(1===n.length&&"text"===n[0].type)return null;for(var i=document.createDocumentFragment(),a=0;an.c||536==n.c&&11>n.g))}function w(e,t,r){return(e=e.match(t))&&e[r]?e[r]:""}function k(e){this.la=e||"-"}function S(e,t){this.M=e,this.Y=4,this.N="n";var r=(t||"n4").match(/^([nio])([1-9])$/i);r&&(this.N=r[1],this.Y=parseInt(r[2],10))}function A(e){return e.N+e.Y}function M(e){var t=4,r="n",n=null;return e&&((n=e.match(/(normal|oblique|italic)/i))&&n[1]&&(r=n[1].substr(0,1).toLowerCase()),(n=e.match(/([1-9]00|normal|bold)/i))&&n[1]&&(/bold/i.test(n[1])?t=7:/[1-9]00/.test(n[1])&&(t=parseInt(n[1].substr(0,1),10)))),r+t}function z(e,t){this.d=e,this.p=e.t.document.documentElement,this.P=t,this.j="wf",this.h=new k("-"),this.ga=!1!==t.events,this.B=!1!==t.classes}function T(e){if(e.B){var t=h(e.p,e.h.e(e.j,"active")),r=[],n=[e.h.e(e.j,"loading")];t||r.push(e.h.e(e.j,"inactive")),l(e.p,r,n)}C(e,"inactive")}function C(e,t,r){e.ga&&e.P[t]&&(r?e.P[t](r.getName(),A(r)):e.P[t]())}function B(){this.w={}}function L(e,t){this.d=e,this.G=t,this.m=this.d.createElement("span",{"aria-hidden":"true"},this.G)}function N(e){o(e.d,"body",e.m)}function q(e){var t;t=[];for(var r=e.M.split(/,\s*/),n=0;n=e.W?e.k.fa&&I(e,t,r)&&(null===e.ba||e.ba.hasOwnProperty(e.s.getName()))?H(e,e.Z):H(e,e.ja):R(e):H(e,e.Z)}function R(e){setTimeout(i(function(){O(this)},e),25)}function H(e,t){e.D.remove(),e.F.remove(),t(e.s)}function D(e,t,r,n){this.d=t,this.u=r,this.R=0,this.da=this.aa=!1,this.W=n,this.k=e.k}function P(e,t,r,n,a){if(r=r||{},0===t.length&&a)T(e.u);else for(e.R+=t.length,a&&(e.aa=a),a=0;ae.c||this.c===e.c&&this.g>e.g||this.c===e.c&&this.g===e.g&&this.A>e.A?1:this.c0})},unload:function(e){e.forEach(function(e){var t=SL.fonts.FAMILIES[e];t&&(t.loaded=!1,[].slice.call(document.querySelectorAll('link[href="'+SL.fonts.FONTS_URL+t.path+'"]')).forEach(function(e){e.parentNode.removeChild(e)}))})},finishLoading:function(){clearTimeout(this.initTimeout),document.documentElement.classList.add("fonts-are-ready"),!1===this._isReady&&(this._isReady=!0,this.ready.dispatch()),this.loaded.dispatch()},getPackageIDs:function(){return Object.keys(SL.fonts.PACKAGES)},getFamilyByName:function(e){for(var t in SL.fonts.FAMILIES){var r=SL.fonts.FAMILIES[t];if(e===r.name)return r}},isPackageLoaded:function(e){var t=SL.fonts.PACKAGES[e];return!t||(0===t.length||t.every(function(e){var t=SL.fonts.FAMILIES[e];return t.active||t.inactive}))},isReady:function(){return this._isReady},onFontActive:function(e){var t=SL.fonts.getFamilyByName(e);t&&(t.active=!0),this.fontactive.dispatch(t)},onFontInactive:function(e){var t=SL.fonts.getFamilyByName(e);t&&(t.inactive=!0),this.fontinactive.dispatch(t)},onInitialFontsActive:function(){this.finishLoading()},onInitialFontsInactive:function(){this.finishLoading()}},SL("views.decks").Export=Class.extend({init:function(){SL.deck.util.injectNotes(),SL.deck.util.renderMath(),SL.deck.util.injectCodeCopyButtons(),window.Reveal&&Reveal.isReady()&&(Reveal.sync(),Reveal.layout())}}),SL("deck").Animation={init:function(){this.animationListeners=[],this.animationsEnabled=!0,this.run=this.run.bind(this),this.reset=this.reset.bind(this),this.toggle=this.toggle.bind(this),this.onSlideChanged=this.onSlideChanged.bind(this),Reveal.addEventListener("slidechanged",this.onSlideChanged),this.revealElement=document.querySelector(".reveal"),this.interactiveAnimationChanged=new signals.Signal},sync:function(){this.animationsEnabled?this.enableAnimations():this.disableAnimations()},enableAnimations:function(){this.animationsEnabled=!0,this.revealElement.classList.remove("block-animations-disabled"),this.reset(this.revealElement);var e=Reveal.getCurrentSlide();e&&this.fastForwardAnimation(e,function(){this.run(e),this.bind(e)}.bind(this))},disableAnimations:function(){this.animationsEnabled=!1,this.revealElement.classList.add("block-animations-disabled"),this.unbind(),this.fastForwardAnimation(this.revealElement)},getAnimationTargets:function(e){return e instanceof Array?e:e.hasAttribute("data-animation-type")?[e]:[].slice.call(e.querySelectorAll("[data-animation-type]"))},getInteractiveAnimationTargets:function(e,t){var r=t?".animate":"";return[].slice.call(e.querySelectorAll(['[data-animation-trigger="click"]','[data-animation-trigger="hover"]'].join(r+",")+r))},run:function(e,t){this.getAnimationTargets(e).forEach(function(e){!t&&this.hasInteractiveAnimationTrigger(e)||e.classList.add("animate")}.bind(this))},toggle:function(e,t){this.getAnimationTargets(e).forEach(function(e){!t&&this.hasInteractiveAnimationTrigger(e)||e.classList.toggle("animate")}.bind(this))},reset:function(e){this.getAnimationTargets(e).forEach(function(e){e.classList.remove("animate")}.bind(this))},preview:function(e){!1===this.animationsEnabled&&this.revealElement.classList.remove("block-animations-disabled"),this.getAnimationTargets(e).forEach(function(e){e.classList.remove("animate"),this.fastForwardAnimation(e,function(){e.classList.add("animate"),!1===this.animationsEnabled&&this.revealElement.classList.add("block-animations-disabled")}.bind(this))}.bind(this))},bind:function(e){this.unbind(),this.getAnimationTargets(e).forEach(function(t){if(this.hasInteractiveAnimationTrigger(t)){var r=t.getAttribute("data-animation-trigger-id"),n="self"===r?t:e.querySelector('.sl-block[data-block-id="'+r+'"] .sl-block-content');if(n){var i=t.getAttribute("data-animation-trigger");"click"===i?(this.addAnimationEventListener(n,"touchstart",this.onTriggerTouchStart.bind(this,t)),this.addAnimationEventListener(n,"click",this.onTriggerClick.bind(this,t))):"hover"===i&&(this.addAnimationEventListener(n,"mouseover",this.onTriggerMouseOver.bind(this,t)),this.addAnimationEventListener(n,"mouseout",this.onTriggerMouseOut.bind(this,t)))}}}.bind(this))},addAnimationEventListener:function(e,t,r){e.addEventListener(t,r),/click|touchstart/gi.test(t)&&e.classList.add("animation-trigger"),this.animationListeners.push([e,t,r])},unbind:function(){this.animationListeners.forEach(function(e){var t=e[0],r=e[1],n=e[2];/click|touchstart/gi.test(r)&&t.classList.remove("animation-trigger"),t.removeEventListener(r,n)}),this.animationListeners.length=0},hasInteractiveAnimationTrigger:function(e){return/click|hover/gi.test(e.getAttribute("data-animation-trigger"))},fastForwardAnimation:function(e,t){e.classList.add("no-transition"),setTimeout(function(){e.classList.remove("no-transition"),"function"==typeof t&&t()},1)},getSerializedInteractiveState:function(){return this.getInteractiveAnimationTargets(Reveal.getCurrentSlide(),!0).map(function(e){var t=this.getParentBlock(e);return t?t.getAttribute("data-block-id"):null},this).filter(function(e){return"string"==typeof e}).join(",")},setSerializedInteractiveState:function(e){var t=this.getInteractiveAnimationTargets(Reveal.getCurrentSlide());if(t.length&&"string"==typeof e){e=e.split(",");var r=[],n=[];t.forEach(function(t){var i=this.getParentBlock(t),a=i?i.getAttribute("data-block-id"):null;"string"==typeof a&&-1!==e.indexOf(a)?n.push(t):r.push(t)},this),this.reset(r),this.run(n,!0)}},getParentBlock:function(e){for(var t=e.parentNode;t&&!t.hasAttribute("data-block-id");)t=t.parentNode;return t},onSlideChanged:function(e){this.animationsEnabled&&(e.previousSlide&&(this.reset(e.previousSlide),this.unbind()),e.currentSlide&&(this.run(e.currentSlide),this.bind(e.currentSlide)))},onTriggerTouchStart:function(e,t){t.preventDefault(),this.toggle(e,!0),this.interactiveAnimationChanged.dispatch()},onTriggerClick:function(e){Reveal.isAutoSliding()&&Reveal.getConfig().autoSlideStoppable&&Reveal.toggleAutoSlide(!1),this.toggle(e,!0),this.interactiveAnimationChanged.dispatch()},onTriggerMouseOver:function(e){this.run(e,!0),this.interactiveAnimationChanged.dispatch()},onTriggerMouseOut:function(e){this.reset(e),this.interactiveAnimationChanged.dispatch()}},SL("deck").AutoAnimate={ANIMATABLE_BLOCK_CONTENT_STYLES:["color","background-color","padding","font-size","line-height","letter-spacing","border-width","border-color","border-radius"],init:function(){this.onAutoAnimate=this.onAutoAnimate.bind(this),Reveal.addEventListener("autoanimate",this.onAutoAnimate)},matcher:function(e,t){var r=[];SL.deck.AutoAnimate.findMatchingElements(r,e,t,".sl-block[data-name]",function(e){return e.nodeName+":::"+e.getAttribute("data-name")});var n=r.map(e=>e.from);return SL.deck.AutoAnimate.findMatchingElements(r,e,t,'.sl-block[data-block-type="text"], .sl-block[data-block-type="snippet"], .sl-block[data-block-type="table"]',function(e){return e.getAttribute("data-block-type")+":::"+e.innerText},null,n),SL.deck.AutoAnimate.findMatchingElements(r,e,t,'.sl-block[data-block-type="image"], .sl-block[data-block-type="video"]',function(e){var t=e.querySelector("img[src], video[src]");return t?t.getAttribute("src"):null},null,n),SL.deck.AutoAnimate.findMatchingElements(r,e,t,'.sl-block[data-block-type="iframe"]',function(e){var t=e.querySelector("iframe[src], iframe[data-src]");return t?t.getAttribute("src")||t.getAttribute("data-src"):null},null,n),SL.deck.AutoAnimate.findMatchingElements(r,e,t,'.sl-block[data-block-type="code"]',function(e){return e.querySelector(":not(.editing-ui) pre code").textContent},null,n),SL.deck.AutoAnimate.findMatchingElements(r,e,t,'.sl-block[data-block-type="math"]',function(e){return e.querySelector(".math-input").textContent},null,n),SL.deck.AutoAnimate.expandBlockPairs(r)},findMatchingElements:function(e,t,r,n,i,a,o){var s={},l={};[].slice.call(t.querySelectorAll(n)).forEach(function(e){var t=i(e);"string"==typeof t&&t.length&&(s[t]=s[t]||[],s[t].push(e))}),[].slice.call(r.querySelectorAll(n)).forEach(function(t){var r,n=i(t);if(l[n]=l[n]||[],l[n].push(t),s[n]){var h=l[n].length-1,c=s[n].length-1;s[n][h]?(r=s[n][h],s[n][h]=null):s[n][c]&&(r=s[n][c],s[n][c]=null)}!r||o&&-1!==o.indexOf(r)||e.push({from:r,to:t,options:a||{styles:[]}})})},expandBlockPairs:function(e){return e.forEach(function(t){var r=t.from,n=t.to,i=r.querySelector(".sl-block-content"),a=n.querySelector(".sl-block-content");i&&a&&SL.deck.AutoAnimate.expandBlockPair(e,t,r,n,i,a)}),e},expandBlockPair:function(e,t,r,n,i,a){var o=r.querySelector(".sl-block-style"),s=n.querySelector(".sl-block-style");o&&o.closest(".sl-block")!==r&&(o=null),s&&s.closest(".sl-block")!==n&&(s=null);var l=r.getAttribute("data-block-type"),h={},c={};return n.dataset.autoAnimateDelay&&(t.options.delay=parseFloat(n.dataset.autoAnimateDelay)),n.dataset.autoAnimateDuration&&(t.options.duration=parseFloat(n.dataset.autoAnimateDuration)),n.dataset.autoAnimateEasing&&(t.options.easing=n.dataset.autoAnimateEasing),h["z-index"]={property:"z-index",from:a.style.zIndex,to:a.style.zIndex},/text|snippet|table/i.test(l)?h.width={property:"width"}:/code|math/i.test(l)&&(h.width={property:"width"},h.height={property:"height"}),o&&s?(h.opacity={property:"opacity",from:o.style.opacity||"1",to:s.style.opacity||"1"},(o.style.transform||s.style.transform)&&(h.width={property:"width"},h.height={property:"height"},e.push({from:o,to:s,options:{translate:!1,scale:!1,styles:[{property:"transform"}]}}))):o?(h.opacity={property:"opacity",from:o.style.opacity||"1",to:"1"},o.style.transform&&(h.width={property:"width"},h.height={property:"height"},c.transform={property:"transform",from:o.style.transform,to:"none"})):s&&(h.opacity={property:"opacity",from:"1",to:s.style.opacity||"1"},s.style.transform&&(h.width={property:"width"},h.height={property:"height"},e.push({from:document.createElement("div"),to:s,options:{translate:!1,scale:!1,styles:[{property:"transform",from:"none"}]}}))),e.push({from:i,to:a,options:{translate:!1,scale:!1,styles:SL.deck.AutoAnimate.ANIMATABLE_BLOCK_CONTENT_STYLES.concat(Object.keys(c).map(function(e){return c[e]}))}}),/text/i.test(l)&&this.expandTextBlock(e,t,r,n),/code/i.test(l)&&this.expandCodeBlock(e,t,r,n),/shape/i.test(l)&&this.expandShapeBlock(e,t,r,n),/line/i.test(l)&&this.expandLineBlock(e,t,r,n),t.options.styles=t.options.styles.concat(Object.keys(h).map(function(e){return h[e]})),(h.width||h.height)&&(t.options.scale=!1),e},expandTextBlock:function(e,t,r,n){SL.deck.AutoAnimate.findMatchingElements(e,r,n,"li>p",function(e){return e.innerText.trim()},{scale:!1,translate:!1,measure:SL.deck.AutoAnimate.getLocalBlockMeasurements}),SL.deck.AutoAnimate.findMatchingElements(e,r,n,"ul li, ol li",function(e){return Array.prototype.map.call(e.childNodes,function(e){return/li|ul|ol/i.test(e.nodeName)?"":e.textContent.trim()}).join("")},{scale:!1,measure:SL.deck.AutoAnimate.getLocalBlockMeasurements}),SL.deck.AutoAnimate.findMatchingElements(e,r,n,'span[style*="font-size"]',function(e){return e.textContent.trim()},{scale:!1,translate:!1,styles:[{property:"font-size"}]})},expandCodeBlock:function(e,t,r,n){var i=n.querySelector("code.current-fragment");i&&(n=i),SL.deck.AutoAnimate.findMatchingElements(e,r,n,".hljs-ln-code",function(e){return e.textContent},{scale:!1,styles:[],measure:SL.deck.AutoAnimate.getLocalBlockMeasurements}),SL.deck.AutoAnimate.findMatchingElements(e,r,n,".hljs-ln-line[data-line-number]",function(e){return e.getAttribute("data-line-number")},{scale:!1,styles:["width"],measure:SL.deck.AutoAnimate.getLocalBlockMeasurements})},expandShapeBlock:function(e,t,r,n){var i=r.querySelector(".shape-element"),a=n.querySelector(".shape-element"),o=[{property:"fill"},{property:"stroke"}];/rect/i.test(a.nodeName)&&o.push({property:"rx"},{property:"ry"}),i&&a&&e.push({from:i,to:a,options:{translate:!1,scale:!1,styles:o}})},expandLineBlock:function(e,t,r,n){var i=r.querySelector(".line-element"),a=n.querySelector(".line-element");i&&a&&e.push({from:i,to:a,options:{translate:!1,scale:!1,styles:[{property:"stroke"},{property:"stroke-width"}]}})},getLocalBlockMeasurements:function(e){var t=Reveal.getScale();return{x:Math.round(e.offsetLeft*t*100)/100,y:Math.round(e.offsetTop*t*100)/100,width:Math.round(e.offsetWidth*t*100)/100,height:Math.round(e.offsetHeight*t*100)/100}},onAutoAnimate:function(e){let t=[];Array.prototype.forEach.call(e.toSlide.querySelectorAll('.sl-block[data-auto-animate-target^="unmatched"]'),function(e){const r=e.querySelector(".sl-block-content");if(r){const n=r.style.zIndex,i=e.getAttribute("data-block-id"),a=e.getAttribute("data-auto-animate-target");t.push(`.reveal [data-auto-animate-target="${a}"][data-block-id="${i}"] { z-index: ${n}; }`)}}),t.length&&(e.sheet.innerHTML+=t.join(""))}},SL("deck").Controller={MODE_VIEWING:"viewing",MODE_EDITING:"editing",MODE_PRINTING:"printing",init:function(e){this.options=e||{},this.options.mode="string"==typeof this.options.mode?this.options.mode:SL.deck.Controller.MODE_VIEWING,this.mode=null,SL.deck.Media.init(this.options),this.options.mode===SL.deck.Controller.MODE_VIEWING&&SL.deck.util.formatIframes(),Reveal.isReady()?this.setup():Reveal.addEventListener("ready",this.setup.bind(this))},setup:function(){SL.deck.Animation.init(),SL.deck.AutoAnimate.init(),this.setMode(this.options.mode)},setMode:function(e){this.mode=e,this.mode===SL.deck.Controller.MODE_EDITING||this.mode===SL.deck.Controller.MODE_PRINTING?SL.deck.Animation.disableAnimations():SL.deck.Animation.enableAnimations()}},SL("deck").Media={init:function(e){this.options=e,this.supportsCDN()&&(this.switchToCDN(".reveal img[src], .reveal video[src]","src"),this.switchToCDN(".reveal img[data-src], .reveal video[data-src]","data-src"),this.switchToCDN(".reveal video[poster]","poster"),this.switchToCDN(".reveal [data-background-video]","data-background-video"),this.switchToCDN(".reveal [data-background-image]","data-background-image"))},supportsCDN:function(){return SL.config&&this.options.mode===SL.deck.Controller.MODE_VIEWING&&!document.documentElement.classList.contains("sl-editor")},switchToCDN:function(e,t){document.querySelectorAll(e).forEach(function(e){var r=e.getAttribute(t);0===r.lastIndexOf(SL.config.S3_HOST,0)&&e.setAttribute(t,r.replace(SL.config.S3_HOST,SL.config.CDN_HOST))},this)}},SL("deck").util={extend:function(e){return Array.prototype.forEach.call(arguments,function(t){for(var r in t)e[r]=t[r]},e),e},renderMath:function(e){SL.deck.util.renderMathBlocks(e),SL.deck.util.renderInlineMath(e)},renderMathBlocks:function(e){e||(e=document.querySelector(".reveal .slides")),window.katex&&"function"==typeof window.katex.render&&[].slice.call(e.querySelectorAll('.sl-block[data-block-type="math"]')).forEach(function(e){var t=e.querySelector(".math-input"),r=e.querySelector(".math-output");if(t&&!r&&((r=document.createElement("div")).className="math-output",t.parentNode.insertBefore(r,t)),t&&r)try{katex.render(t.innerText,r,{displayMode:null!==e.querySelector("[data-math-display=true]"),throwOnError:!0})}catch(n){console.warn(n.message)}})},renderInlineMath:function(e){e||(e=document.querySelector(".reveal .slides")),"function"==typeof window.renderMathInElement&&SL.deck.util.containsInlineMath(e)&&renderMathInElement(e,{delimiters:[{left:"$$",right:"$$",display:!0},{left:"\\[",right:"\\]",display:!0},{left:"\\(",right:"\\)",display:!1}]})},containsInlineMath:function(e){return!!e&&/\$\$.+\$\$|\\\[.+\\\]|\\\(.+\\\)/g.test(e.innerHTML)},injectCodeCopyButtons:function(){var e=[].slice.call(document.querySelectorAll('.sl-block[data-block-type="code"] .sl-block-content:not(.has-copy-button)'));e.length&&(this.copyButton=document.createElement("button"),this.copyButton.className="copy-code-to-clipboard",this.copyButton.textContent="Copy",this.copyButton.addEventListener("click",function(){this.copyButton.hasAttribute("data-code-to-copy")&&(this.copyButton.textContent="Copied!",this.copyButton.classList.add("bounce"),SL.deck.util.copyToClipboard(this.copyButton.getAttribute("data-code-to-copy")),setTimeout(function(){this.copyButton.textContent="Copy",this.copyButton.classList.remove("bounce")}.bind(this),1500))}.bind(this)),e.forEach(function(e){var t,r=e.querySelector("pre code");r&&(t=r.hasAttribute("data-plaintext")?r.getAttribute("data-plaintext"):r.textContent),t&&e.addEventListener("mouseenter",function(e){this.copyButton.setAttribute("data-code-to-copy",t),e.currentTarget.classList.add("has-copy-button"),e.currentTarget.appendChild(this.copyButton)}.bind(this))},this))},hasNotes:function(){if(SLConfig.deck&&SLConfig.deck.notes)for(var e in SLConfig.deck.notes)return!0;return document.querySelectorAll(".reveal .slides section[data-notes]").length>0},injectNotes:function(){SLConfig.deck&&SLConfig.deck.notes&&[].forEach.call(document.querySelectorAll(".reveal .slides section"),function(e){var t=SLConfig.deck.notes[e.getAttribute("data-id")];t&&"string"==typeof t&&e.setAttribute("data-notes",t)})},injectTranslationRules:function(){[].slice.call(document.querySelectorAll(".sl-block .katex")).forEach(function(e){e.classList.add("notranslate")})},formatIframes:function(){[].slice.call(document.querySelectorAll(".sl-block iframe[data-src]")).forEach(this.formatIframe.bind(this))},formatIframe:function(e){e.setAttribute("allowfullscreen",""),e.setAttribute("allow","fullscreen; accelerometer; geolocation; gyroscope; camera; encrypted-media; microphone; midi");var t=e.getAttribute("src")||e.getAttribute("data-src");"string"!=typeof t||/\.pdf$/i.test(t)?e.removeAttribute("sandbox"):e.setAttribute("sandbox","allow-forms allow-scripts allow-popups allow-same-origin allow-pointer-lock allow-presentation")},copyToClipboard:function(e){var t=document.createElement("textarea");t.value=e,document.body.appendChild(t),t.select();var r=document.execCommand("copy");return document.body.removeChild(t),r}}; + + + !function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.katex=t():e.katex=t()}("undefined"!=typeof self?self:this,function(){return function(){"use strict";function e(e){if(e["default"])return e["default"];var t=e.type,r=Array.isArray(t)?t[0]:t;if("string"!=typeof r)return r["enum"][0];switch(r){case"boolean":return!1;case"string":return"";case"number":return 0;case"object":return{}}}function t(e){for(var t=0;t=Y[t]&&e<=Y[t+1])return!0;return!1}function r(e,r,n){if(!J[r])throw new Error("Font metrics not found for font: "+r+".");var i=e.charCodeAt(0),a=J[r][i];if(!a&&e[0]in Q&&(i=Q[e[0]].charCodeAt(0),a=J[r][i]),a||"text"!==n||t(i)&&(a=J[r][77]),a)return{depth:a[0],height:a[1],italic:a[2],skew:a[3],width:a[4]}}function n(e){if(e instanceof be)return e;throw new Error("Expected symbolNode but got "+String(e)+".")}function i(e,t,r,n,i,a){Ee[e][i]={font:t,group:r,replace:n},a&&n&&(Ee[e][n]=Ee[e][i])}function a(e){for(var t=e.type,r=e.names,n=e.props,i=e.handler,a=e.htmlBuilder,o=e.mathmlBuilder,s={type:t,numArgs:n.numArgs,argTypes:n.argTypes,allowedInArgument:!!n.allowedInArgument,allowedInText:!!n.allowedInText,allowedInMath:void 0===n.allowedInMath||n.allowedInMath,numOptionalArgs:n.numOptionalArgs||0,infix:!!n.infix,primitive:!!n.primitive,handler:i},l=0;l0&&(o.push(s(l,t)),l=[]),o.push(i[c]));l.length>0&&o.push(s(l,t)),r?((a=s(Bt(r,t,!0))).classes=["tag"],o.push(a)):n&&o.push(n);var h=Ct(["katex-html"],o);if(h.setAttribute("aria-hidden","true"),a){var d=a.children[0];d.style.height=ue(h.height+h.depth),h.depth&&(d.style.verticalAlign=ue(-h.depth))}return h}function c(e){return new K(e)}function u(e,t,r,n,i){var a,o=Yt(e,r);a=1===o.length&&o[0]instanceof jt&&B.contains(["mrow","mtable"],o[0].type)?o[0]:new Ut.MathNode("mrow",o);var s=new Ut.MathNode("annotation",[new Ut.TextNode(t)]);s.setAttribute("encoding","application/x-tex");var l=new Ut.MathNode("semantics",[a,s]),c=new Ut.MathNode("math",[l]);c.setAttribute("xmlns","http://www.w3.org/1998/Math/MathML"),n&&c.setAttribute("display","block");var u=i?"katex":"katex-mathml";return yt.makeSpan([u],[c])}function h(e,t){if(!e||e.type!==t)throw new Error("Expected node of type "+t+", but got "+(e?"node of type "+e.type:String(e)));return e}function d(e){var t=p(e);if(!t)throw new Error("Expected node of symbol group type, but got "+(e?"node of type "+e.type:String(e)));return t}function p(e){return e&&("atom"===e.type||Ae.hasOwnProperty(e.type))?e:null}function m(e,t){var r=Bt(e.body,t,!0);return lr([e.mclass],r,t)}function f(e,t){var r,n=Yt(e.body,t);return"minner"===e.mclass?r=new Ut.MathNode("mpadded",n):"mord"===e.mclass?e.isCharacterBox?(r=n[0]).type="mi":r=new Ut.MathNode("mi",n):(e.isCharacterBox?(r=n[0]).type="mo":r=new Ut.MathNode("mo",n),"mbin"===e.mclass?(r.attributes.lspace="0.22em",r.attributes.rspace="0.22em"):"mpunct"===e.mclass?(r.attributes.lspace="0em",r.attributes.rspace="0.17em"):"mopen"===e.mclass||"mclose"===e.mclass?(r.attributes.lspace="0em",r.attributes.rspace="0em"):"minner"===e.mclass&&(r.attributes.lspace="0.0556em",r.attributes.width="+0.1111em")),r}function g(e,t,r){var n=ur[e];switch(n){case"\\\\cdrightarrow":case"\\\\cdleftarrow":return r.callFunction(n,[t[0]],[t[1]]);case"\\uparrow":case"\\downarrow":var i={type:"atom",text:n,mode:"math",family:"rel"},a={type:"ordgroup",mode:"math",body:[r.callFunction("\\\\cdleft",[t[0]],[]),r.callFunction("\\Big",[i],[]),r.callFunction("\\\\cdright",[t[1]],[])]};return r.callFunction("\\\\cdparent",[a],[]);case"\\\\cdlongequal":return r.callFunction("\\\\cdlongequal",[],[]);case"\\Vert":return r.callFunction("\\Big",[{type:"textord",text:"\\Vert",mode:"math"}],[]);default:return{type:"textord",text:" ",mode:"math"}}}function v(e,t){var r=p(e);if(r&&B.contains(jr,r.text))return r;throw new C(r?"Invalid delimiter '"+r.text+"' after '"+t.funcName+"'":"Invalid delimiter type '"+e.type+"'",e)}function y(e){if(!e.body)throw new Error("Bug: The leftright ParseNode wasn't fully parsed.")}function b(e){for(var t=e.type,r=e.names,n=e.props,i=e.handler,a=e.htmlBuilder,o=e.mathmlBuilder,s={type:t,numArgs:n.numArgs||0,allowedInText:!1,numOptionalArgs:0,handler:i},l=0;l1||!d)&&v.pop(),b.length15?"\u2026"+o.slice(r-15,r):o.slice(0,r))+s+(n+15":">","<":"<",'"':""","'":"'"},N=/[&><"']/g,I=function ai(e){return"ordgroup"===e.type||"color"===e.type?1===e.body.length?ai(e.body[0]):e:"font"===e.type?ai(e.body):e},B={contains:function(e,t){return-1!==e.indexOf(t)},deflt:function(e,t){return void 0===e?t:e},escape:function(e){return String(e).replace(N,function(e){return z[e]})},hyphenate:function(e){return e.replace(L,"-$1").toLowerCase()},getBaseElem:I,isCharacterBox:function(e){var t=I(e);return"mathord"===t.type||"textord"===t.type||"atom"===t.type},protocolFromUrl:function(e){var t=/^\s*([^\\/#]*?)(?::|�*58|�*3a)/i.exec(e);return null!=t?t[1]:"_relative"}},P={displayMode:{type:"boolean",description:"Render math in display mode, which puts the math in display style (so \\int and \\sum are large, for example), and centers the math on the page on its own line.",cli:"-d, --display-mode"},output:{type:{"enum":["htmlAndMathml","html","mathml"]},description:"Determines the markup language of the output.",cli:"-F, --format "},leqno:{type:"boolean",description:"Render display math in leqno style (left-justified tags)."},fleqn:{type:"boolean",description:"Render display math flush left."},throwOnError:{type:"boolean","default":!0,cli:"-t, --no-throw-on-error",cliDescription:"Render errors (in the color given by --error-color) instead of throwing a ParseError exception when encountering an error."},errorColor:{type:"string","default":"#cc0000",cli:"-c, --error-color ",cliDescription:"A color string given in the format 'rgb' or 'rrggbb' (no #). This option determines the color of errors rendered by the -t option.",cliProcessor:function(e){return"#"+e}},macros:{type:"object",cli:"-m, --macro ",cliDescription:"Define custom macro of the form '\\foo:expansion' (use multiple -m arguments for multiple macros).",cliDefault:[],cliProcessor:function(e,t){return t.push(e),t}},minRuleThickness:{type:"number",description:"Specifies a minimum thickness, in ems, for fraction lines, `\\sqrt` top lines, `{array}` vertical lines, `\\hline`, `\\hdashline`, `\\underline`, `\\overline`, and the borders of `\\fbox`, `\\boxed`, and `\\fcolorbox`.",processor:function(e){return Math.max(0,e)},cli:"--min-rule-thickness ",cliProcessor:parseFloat},colorIsTextColor:{type:"boolean",description:"Makes \\color behave like LaTeX's 2-argument \\textcolor, instead of LaTeX's one-argument \\color mode change.",cli:"-b, --color-is-text-color"},strict:{type:[{"enum":["warn","ignore","error"]},"boolean","function"],description:"Turn on strict / LaTeX faithfulness mode, which throws an error if the input uses features that are not supported by LaTeX.",cli:"-S, --strict",cliDefault:!1},trust:{type:["boolean","function"],description:"Trust the input, enabling all HTML features such as \\url.",cli:"-T, --trust"},maxSize:{type:"number","default":1/0,description:"If non-zero, all user-specified sizes, e.g. in \\rule{500em}{500em}, will be capped to maxSize ems. Otherwise, elements and spaces can be arbitrarily large",processor:function(e){return Math.max(0,e)},cli:"-s, --max-size ",cliProcessor:parseInt},maxExpand:{type:"number","default":1e3,description:"Limit the number of macro expansions to the specified number, to prevent e.g. infinite macro loops. If set to Infinity, the macro expander will try to fully expand as in LaTeX.",processor:function(e){return Math.max(0,e)},cli:"-e, --max-expand ",cliProcessor:function(e){return"Infinity"===e?1/0:parseInt(e)}},globalGroup:{type:"boolean",cli:!1}},O=function(){function t(t){for(var r in this.displayMode=void 0,this.output=void 0,this.leqno=void 0,this.fleqn=void 0,this.throwOnError=void 0,this.errorColor=void 0,this.macros=void 0,this.minRuleThickness=void 0,this.colorIsTextColor=void 0,this.strict=void 0,this.trust=void 0,this.maxSize=void 0,this.maxExpand=void 0,this.globalGroup=void 0,t=t||{},P)if(P.hasOwnProperty(r)){var n=P[r];this[r]=void 0!==t[r]?n.processor?n.processor(t[r]):t[r]:e(n)}}var r=t.prototype;return r.reportNonstrict=function(e,t,r){var n=this.strict;if("function"==typeof n&&(n=n(e,t,r)),n&&"ignore"!==n){if(!0===n||"error"===n)throw new C("LaTeX-incompatible input and strict mode is set to 'error': "+t+" ["+e+"]",r);"warn"===n?"undefined"!=typeof console&&console.warn("LaTeX-incompatible input and strict mode is set to 'warn': "+t+" ["+e+"]"):"undefined"!=typeof console&&console.warn("LaTeX-incompatible input and strict mode is set to unrecognized '"+n+"': "+t+" ["+e+"]")}},r.useStrictBehavior=function(e,t,r){var n=this.strict;if("function"==typeof n)try{n=n(e,t,r)}catch(e){n="error"}return!(!n||"ignore"===n||!0!==n&&"error"!==n&&("warn"===n?("undefined"!=typeof console&&console.warn("LaTeX-incompatible input and strict mode is set to 'warn': "+t+" ["+e+"]"),1):("undefined"!=typeof console&&console.warn("LaTeX-incompatible input and strict mode is set to unrecognized '"+n+"': "+t+" ["+e+"]"),1)))},r.isTrusted=function(e){e.url&&!e.protocol&&(e.protocol=B.protocolFromUrl(e.url));var t="function"==typeof this.trust?this.trust(e):this.trust;return Boolean(t)},t}(),q=function(){function e(e,t,r){this.id=void 0,this.size=void 0,this.cramped=void 0,this.id=e,this.size=t,this.cramped=r}var t=e.prototype;return t.sup=function(){return D[H[this.id]]},t.sub=function(){return D[F[this.id]]},t.fracNum=function(){return D[j[this.id]]},t.fracDen=function(){return D[V[this.id]]},t.cramp=function(){return D[U[this.id]]},t.text=function(){return D[_[this.id]]},t.isTight=function(){return this.size>=2},e}(),D=[new q(0,0,!1),new q(1,0,!0),new q(2,1,!1),new q(3,1,!0),new q(4,2,!1),new q(5,2,!0),new q(6,3,!1),new q(7,3,!0)],H=[4,5,4,5,6,7,6,7],F=[5,5,5,5,7,7,7,7],j=[2,3,4,5,6,7,6,7],V=[3,3,5,5,7,7,7,7],U=[1,1,3,3,5,5,7,7],_=[0,1,2,3,2,3,2,3],W={DISPLAY:D[0],TEXT:D[2],SCRIPT:D[4],SCRIPTSCRIPT:D[6]},G=[{name:"latin",blocks:[[256,591],[768,879]]},{name:"cyrillic",blocks:[[1024,1279]]},{name:"armenian",blocks:[[1328,1423]]},{name:"brahmic",blocks:[[2304,4255]]},{name:"georgian",blocks:[[4256,4351]]},{name:"cjk",blocks:[[12288,12543],[19968,40879],[65280,65376]]},{name:"hangul",blocks:[[44032,55215]]}],Y=[];G.forEach(function(e){return e.blocks.forEach(function(e){return Y.push.apply(Y,e)})});var X=80,$={doubleleftarrow:"M262 157\nl10-10c34-36 62.7-77 86-123 3.3-8 5-13.3 5-16 0-5.3-6.7-8-20-8-7.3\n 0-12.2.5-14.5 1.5-2.3 1-4.8 4.5-7.5 10.5-49.3 97.3-121.7 169.3-217 216-28\n 14-57.3 25-88 33-6.7 2-11 3.8-13 5.5-2 1.7-3 4.2-3 7.5s1 5.8 3 7.5\nc2 1.7 6.3 3.5 13 5.5 68 17.3 128.2 47.8 180.5 91.5 52.3 43.7 93.8 96.2 124.5\n 157.5 9.3 8 15.3 12.3 18 13h6c12-.7 18-4 18-10 0-2-1.7-7-5-15-23.3-46-52-87\n-86-123l-10-10h399738v-40H218c328 0 0 0 0 0l-10-8c-26.7-20-65.7-43-117-69 2.7\n-2 6-3.7 10-5 36.7-16 72.3-37.3 107-64l10-8h399782v-40z\nm8 0v40h399730v-40zm0 194v40h399730v-40z",doublerightarrow:"M399738 392l\n-10 10c-34 36-62.7 77-86 123-3.3 8-5 13.3-5 16 0 5.3 6.7 8 20 8 7.3 0 12.2-.5\n 14.5-1.5 2.3-1 4.8-4.5 7.5-10.5 49.3-97.3 121.7-169.3 217-216 28-14 57.3-25 88\n-33 6.7-2 11-3.8 13-5.5 2-1.7 3-4.2 3-7.5s-1-5.8-3-7.5c-2-1.7-6.3-3.5-13-5.5-68\n-17.3-128.2-47.8-180.5-91.5-52.3-43.7-93.8-96.2-124.5-157.5-9.3-8-15.3-12.3-18\n-13h-6c-12 .7-18 4-18 10 0 2 1.7 7 5 15 23.3 46 52 87 86 123l10 10H0v40h399782\nc-328 0 0 0 0 0l10 8c26.7 20 65.7 43 117 69-2.7 2-6 3.7-10 5-36.7 16-72.3 37.3\n-107 64l-10 8H0v40zM0 157v40h399730v-40zm0 194v40h399730v-40z",leftarrow:"M400000 241H110l3-3c68.7-52.7 113.7-120\n 135-202 4-14.7 6-23 6-25 0-7.3-7-11-21-11-8 0-13.2.8-15.5 2.5-2.3 1.7-4.2 5.8\n-5.5 12.5-1.3 4.7-2.7 10.3-4 17-12 48.7-34.8 92-68.5 130S65.3 228.3 18 247\nc-10 4-16 7.7-18 11 0 8.7 6 14.3 18 17 47.3 18.7 87.8 47 121.5 85S196 441.3 208\n 490c.7 2 1.3 5 2 9s1.2 6.7 1.5 8c.3 1.3 1 3.3 2 6s2.2 4.5 3.5 5.5c1.3 1 3.3\n 1.8 6 2.5s6 1 10 1c14 0 21-3.7 21-11 0-2-2-10.3-6-25-20-79.3-65-146.7-135-202\n l-3-3h399890zM100 241v40h399900v-40z",leftbrace:"M6 548l-6-6v-35l6-11c56-104 135.3-181.3 238-232 57.3-28.7 117\n-45 179-50h399577v120H403c-43.3 7-81 15-113 26-100.7 33-179.7 91-237 174-2.7\n 5-6 9-10 13-.7 1-7.3 1-20 1H6z",leftbraceunder:"M0 6l6-6h17c12.688 0 19.313.3 20 1 4 4 7.313 8.3 10 13\n 35.313 51.3 80.813 93.8 136.5 127.5 55.688 33.7 117.188 55.8 184.5 66.5.688\n 0 2 .3 4 1 18.688 2.7 76 4.3 172 5h399450v120H429l-6-1c-124.688-8-235-61.7\n-331-161C60.687 138.7 32.312 99.3 7 54L0 41V6z",leftgroup:"M400000 80\nH435C64 80 168.3 229.4 21 260c-5.9 1.2-18 0-18 0-2 0-3-1-3-3v-38C76 61 257 0\n 435 0h399565z",leftgroupunder:"M400000 262\nH435C64 262 168.3 112.6 21 82c-5.9-1.2-18 0-18 0-2 0-3 1-3 3v38c76 158 257 219\n 435 219h399565z",leftharpoon:"M0 267c.7 5.3 3 10 7 14h399993v-40H93c3.3\n-3.3 10.2-9.5 20.5-18.5s17.8-15.8 22.5-20.5c50.7-52 88-110.3 112-175 4-11.3 5\n-18.3 3-21-1.3-4-7.3-6-18-6-8 0-13 .7-15 2s-4.7 6.7-8 16c-42 98.7-107.3 174.7\n-196 228-6.7 4.7-10.7 8-12 10-1.3 2-2 5.7-2 11zm100-26v40h399900v-40z",leftharpoonplus:"M0 267c.7 5.3 3 10 7 14h399993v-40H93c3.3-3.3 10.2-9.5\n 20.5-18.5s17.8-15.8 22.5-20.5c50.7-52 88-110.3 112-175 4-11.3 5-18.3 3-21-1.3\n-4-7.3-6-18-6-8 0-13 .7-15 2s-4.7 6.7-8 16c-42 98.7-107.3 174.7-196 228-6.7 4.7\n-10.7 8-12 10-1.3 2-2 5.7-2 11zm100-26v40h399900v-40zM0 435v40h400000v-40z\nm0 0v40h400000v-40z",leftharpoondown:"M7 241c-4 4-6.333 8.667-7 14 0 5.333.667 9 2 11s5.333\n 5.333 12 10c90.667 54 156 130 196 228 3.333 10.667 6.333 16.333 9 17 2 .667 5\n 1 9 1h5c10.667 0 16.667-2 18-6 2-2.667 1-9.667-3-21-32-87.333-82.667-157.667\n-152-211l-3-3h399907v-40zM93 281 H400000 v-40L7 241z",leftharpoondownplus:"M7 435c-4 4-6.3 8.7-7 14 0 5.3.7 9 2 11s5.3 5.3 12\n 10c90.7 54 156 130 196 228 3.3 10.7 6.3 16.3 9 17 2 .7 5 1 9 1h5c10.7 0 16.7\n-2 18-6 2-2.7 1-9.7-3-21-32-87.3-82.7-157.7-152-211l-3-3h399907v-40H7zm93 0\nv40h399900v-40zM0 241v40h399900v-40zm0 0v40h399900v-40z",lefthook:"M400000 281 H103s-33-11.2-61-33.5S0 197.3 0 164s14.2-61.2 42.5\n-83.5C70.8 58.2 104 47 142 47 c16.7 0 25 6.7 25 20 0 12-8.7 18.7-26 20-40 3.3\n-68.7 15.7-86 37-10 12-15 25.3-15 40 0 22.7 9.8 40.7 29.5 54 19.7 13.3 43.5 21\n 71.5 23h399859zM103 281v-40h399897v40z",leftlinesegment:"M40 281 V428 H0 V94 H40 V241 H400000 v40z\nM40 281 V428 H0 V94 H40 V241 H400000 v40z",leftmapsto:"M40 281 V448H0V74H40V241H400000v40z\nM40 281 V448H0V74H40V241H400000v40z",leftToFrom:"M0 147h400000v40H0zm0 214c68 40 115.7 95.7 143 167h22c15.3 0 23\n-.3 23-1 0-1.3-5.3-13.7-16-37-18-35.3-41.3-69-70-101l-7-8h399905v-40H95l7-8\nc28.7-32 52-65.7 70-101 10.7-23.3 16-35.7 16-37 0-.7-7.7-1-23-1h-22C115.7 265.3\n 68 321 0 361zm0-174v-40h399900v40zm100 154v40h399900v-40z",longequal:"M0 50 h400000 v40H0z m0 194h40000v40H0z\nM0 50 h400000 v40H0z m0 194h40000v40H0z",midbrace:"M200428 334\nc-100.7-8.3-195.3-44-280-108-55.3-42-101.7-93-139-153l-9-14c-2.7 4-5.7 8.7-9 14\n-53.3 86.7-123.7 153-211 199-66.7 36-137.3 56.3-212 62H0V214h199568c178.3-11.7\n 311.7-78.3 403-201 6-8 9.7-12 11-12 .7-.7 6.7-1 18-1s17.3.3 18 1c1.3 0 5 4 11\n 12 44.7 59.3 101.3 106.3 170 141s145.3 54.3 229 60h199572v120z",midbraceunder:"M199572 214\nc100.7 8.3 195.3 44 280 108 55.3 42 101.7 93 139 153l9 14c2.7-4 5.7-8.7 9-14\n 53.3-86.7 123.7-153 211-199 66.7-36 137.3-56.3 212-62h199568v120H200432c-178.3\n 11.7-311.7 78.3-403 201-6 8-9.7 12-11 12-.7.7-6.7 1-18 1s-17.3-.3-18-1c-1.3 0\n-5-4-11-12-44.7-59.3-101.3-106.3-170-141s-145.3-54.3-229-60H0V214z",oiintSize1:"M512.6 71.6c272.6 0 320.3 106.8 320.3 178.2 0 70.8-47.7 177.6\n-320.3 177.6S193.1 320.6 193.1 249.8c0-71.4 46.9-178.2 319.5-178.2z\nm368.1 178.2c0-86.4-60.9-215.4-368.1-215.4-306.4 0-367.3 129-367.3 215.4 0 85.8\n60.9 214.8 367.3 214.8 307.2 0 368.1-129 368.1-214.8z",oiintSize2:"M757.8 100.1c384.7 0 451.1 137.6 451.1 230 0 91.3-66.4 228.8\n-451.1 228.8-386.3 0-452.7-137.5-452.7-228.8 0-92.4 66.4-230 452.7-230z\nm502.4 230c0-111.2-82.4-277.2-502.4-277.2s-504 166-504 277.2\nc0 110 84 276 504 276s502.4-166 502.4-276z",oiiintSize1:"M681.4 71.6c408.9 0 480.5 106.8 480.5 178.2 0 70.8-71.6 177.6\n-480.5 177.6S202.1 320.6 202.1 249.8c0-71.4 70.5-178.2 479.3-178.2z\nm525.8 178.2c0-86.4-86.8-215.4-525.7-215.4-437.9 0-524.7 129-524.7 215.4 0\n85.8 86.8 214.8 524.7 214.8 438.9 0 525.7-129 525.7-214.8z",oiiintSize2:"M1021.2 53c603.6 0 707.8 165.8 707.8 277.2 0 110-104.2 275.8\n-707.8 275.8-606 0-710.2-165.8-710.2-275.8C311 218.8 415.2 53 1021.2 53z\nm770.4 277.1c0-131.2-126.4-327.6-770.5-327.6S248.4 198.9 248.4 330.1\nc0 130 128.8 326.4 772.7 326.4s770.5-196.4 770.5-326.4z",rightarrow:"M0 241v40h399891c-47.3 35.3-84 78-110 128\n-16.7 32-27.7 63.7-33 95 0 1.3-.2 2.7-.5 4-.3 1.3-.5 2.3-.5 3 0 7.3 6.7 11 20\n 11 8 0 13.2-.8 15.5-2.5 2.3-1.7 4.2-5.5 5.5-11.5 2-13.3 5.7-27 11-41 14.7-44.7\n 39-84.5 73-119.5s73.7-60.2 119-75.5c6-2 9-5.7 9-11s-3-9-9-11c-45.3-15.3-85\n-40.5-119-75.5s-58.3-74.8-73-119.5c-4.7-14-8.3-27.3-11-40-1.3-6.7-3.2-10.8-5.5\n-12.5-2.3-1.7-7.5-2.5-15.5-2.5-14 0-21 3.7-21 11 0 2 2 10.3 6 25 20.7 83.3 67\n 151.7 139 205zm0 0v40h399900v-40z",rightbrace:"M400000 542l\n-6 6h-17c-12.7 0-19.3-.3-20-1-4-4-7.3-8.3-10-13-35.3-51.3-80.8-93.8-136.5-127.5\ns-117.2-55.8-184.5-66.5c-.7 0-2-.3-4-1-18.7-2.7-76-4.3-172-5H0V214h399571l6 1\nc124.7 8 235 61.7 331 161 31.3 33.3 59.7 72.7 85 118l7 13v35z",rightbraceunder:"M399994 0l6 6v35l-6 11c-56 104-135.3 181.3-238 232-57.3\n 28.7-117 45-179 50H-300V214h399897c43.3-7 81-15 113-26 100.7-33 179.7-91 237\n-174 2.7-5 6-9 10-13 .7-1 7.3-1 20-1h17z",rightgroup:"M0 80h399565c371 0 266.7 149.4 414 180 5.9 1.2 18 0 18 0 2 0\n 3-1 3-3v-38c-76-158-257-219-435-219H0z",rightgroupunder:"M0 262h399565c371 0 266.7-149.4 414-180 5.9-1.2 18 0 18\n 0 2 0 3 1 3 3v38c-76 158-257 219-435 219H0z",rightharpoon:"M0 241v40h399993c4.7-4.7 7-9.3 7-14 0-9.3\n-3.7-15.3-11-18-92.7-56.7-159-133.7-199-231-3.3-9.3-6-14.7-8-16-2-1.3-7-2-15-2\n-10.7 0-16.7 2-18 6-2 2.7-1 9.7 3 21 15.3 42 36.7 81.8 64 119.5 27.3 37.7 58\n 69.2 92 94.5zm0 0v40h399900v-40z",rightharpoonplus:"M0 241v40h399993c4.7-4.7 7-9.3 7-14 0-9.3-3.7-15.3-11\n-18-92.7-56.7-159-133.7-199-231-3.3-9.3-6-14.7-8-16-2-1.3-7-2-15-2-10.7 0-16.7\n 2-18 6-2 2.7-1 9.7 3 21 15.3 42 36.7 81.8 64 119.5 27.3 37.7 58 69.2 92 94.5z\nm0 0v40h399900v-40z m100 194v40h399900v-40zm0 0v40h399900v-40z",rightharpoondown:"M399747 511c0 7.3 6.7 11 20 11 8 0 13-.8 15-2.5s4.7-6.8\n 8-15.5c40-94 99.3-166.3 178-217 13.3-8 20.3-12.3 21-13 5.3-3.3 8.5-5.8 9.5\n-7.5 1-1.7 1.5-5.2 1.5-10.5s-2.3-10.3-7-15H0v40h399908c-34 25.3-64.7 57-92 95\n-27.3 38-48.7 77.7-64 119-3.3 8.7-5 14-5 16zM0 241v40h399900v-40z",rightharpoondownplus:"M399747 705c0 7.3 6.7 11 20 11 8 0 13-.8\n 15-2.5s4.7-6.8 8-15.5c40-94 99.3-166.3 178-217 13.3-8 20.3-12.3 21-13 5.3-3.3\n 8.5-5.8 9.5-7.5 1-1.7 1.5-5.2 1.5-10.5s-2.3-10.3-7-15H0v40h399908c-34 25.3\n-64.7 57-92 95-27.3 38-48.7 77.7-64 119-3.3 8.7-5 14-5 16zM0 435v40h399900v-40z\nm0-194v40h400000v-40zm0 0v40h400000v-40z",righthook:"M399859 241c-764 0 0 0 0 0 40-3.3 68.7-15.7 86-37 10-12 15-25.3\n 15-40 0-22.7-9.8-40.7-29.5-54-19.7-13.3-43.5-21-71.5-23-17.3-1.3-26-8-26-20 0\n-13.3 8.7-20 26-20 38 0 71 11.2 99 33.5 0 0 7 5.6 21 16.7 14 11.2 21 33.5 21\n 66.8s-14 61.2-42 83.5c-28 22.3-61 33.5-99 33.5L0 241z M0 281v-40h399859v40z",rightlinesegment:"M399960 241 V94 h40 V428 h-40 V281 H0 v-40z\nM399960 241 V94 h40 V428 h-40 V281 H0 v-40z",rightToFrom:"M400000 167c-70.7-42-118-97.7-142-167h-23c-15.3 0-23 .3-23\n 1 0 1.3 5.3 13.7 16 37 18 35.3 41.3 69 70 101l7 8H0v40h399905l-7 8c-28.7 32\n-52 65.7-70 101-10.7 23.3-16 35.7-16 37 0 .7 7.7 1 23 1h23c24-69.3 71.3-125 142\n-167z M100 147v40h399900v-40zM0 341v40h399900v-40z",twoheadleftarrow:"M0 167c68 40\n 115.7 95.7 143 167h22c15.3 0 23-.3 23-1 0-1.3-5.3-13.7-16-37-18-35.3-41.3-69\n-70-101l-7-8h125l9 7c50.7 39.3 85 86 103 140h46c0-4.7-6.3-18.7-19-42-18-35.3\n-40-67.3-66-96l-9-9h399716v-40H284l9-9c26-28.7 48-60.7 66-96 12.7-23.333 19\n-37.333 19-42h-46c-18 54-52.3 100.7-103 140l-9 7H95l7-8c28.7-32 52-65.7 70-101\n 10.7-23.333 16-35.7 16-37 0-.7-7.7-1-23-1h-22C115.7 71.3 68 127 0 167z",twoheadrightarrow:"M400000 167\nc-68-40-115.7-95.7-143-167h-22c-15.3 0-23 .3-23 1 0 1.3 5.3 13.7 16 37 18 35.3\n 41.3 69 70 101l7 8h-125l-9-7c-50.7-39.3-85-86-103-140h-46c0 4.7 6.3 18.7 19 42\n 18 35.3 40 67.3 66 96l9 9H0v40h399716l-9 9c-26 28.7-48 60.7-66 96-12.7 23.333\n-19 37.333-19 42h46c18-54 52.3-100.7 103-140l9-7h125l-7 8c-28.7 32-52 65.7-70\n 101-10.7 23.333-16 35.7-16 37 0 .7 7.7 1 23 1h22c27.3-71.3 75-127 143-167z",tilde1:"M200 55.538c-77 0-168 73.953-177 73.953-3 0-7\n-2.175-9-5.437L2 97c-1-2-2-4-2-6 0-4 2-7 5-9l20-12C116 12 171 0 207 0c86 0\n 114 68 191 68 78 0 168-68 177-68 4 0 7 2 9 5l12 19c1 2.175 2 4.35 2 6.525 0\n 4.35-2 7.613-5 9.788l-19 13.05c-92 63.077-116.937 75.308-183 76.128\n-68.267.847-113-73.952-191-73.952z",tilde2:"M344 55.266c-142 0-300.638 81.316-311.5 86.418\n-8.01 3.762-22.5 10.91-23.5 5.562L1 120c-1-2-1-3-1-4 0-5 3-9 8-10l18.4-9C160.9\n 31.9 283 0 358 0c148 0 188 122 331 122s314-97 326-97c4 0 8 2 10 7l7 21.114\nc1 2.14 1 3.21 1 4.28 0 5.347-3 9.626-7 10.696l-22.3 12.622C852.6 158.372 751\n 181.476 676 181.476c-149 0-189-126.21-332-126.21z",tilde3:"M786 59C457 59 32 175.242 13 175.242c-6 0-10-3.457\n-11-10.37L.15 138c-1-7 3-12 10-13l19.2-6.4C378.4 40.7 634.3 0 804.3 0c337 0\n 411.8 157 746.8 157 328 0 754-112 773-112 5 0 10 3 11 9l1 14.075c1 8.066-.697\n 16.595-6.697 17.492l-21.052 7.31c-367.9 98.146-609.15 122.696-778.15 122.696\n -338 0-409-156.573-744-156.573z",tilde4:"M786 58C457 58 32 177.487 13 177.487c-6 0-10-3.345\n-11-10.035L.15 143c-1-7 3-12 10-13l22-6.7C381.2 35 637.15 0 807.15 0c337 0 409\n 177 744 177 328 0 754-127 773-127 5 0 10 3 11 9l1 14.794c1 7.805-3 13.38-9\n 14.495l-20.7 5.574c-366.85 99.79-607.3 139.372-776.3 139.372-338 0-409\n -175.236-744-175.236z",vec:"M377 20c0-5.333 1.833-10 5.5-14S391 0 397 0c4.667 0 8.667 1.667 12 5\n3.333 2.667 6.667 9 10 19 6.667 24.667 20.333 43.667 41 57 7.333 4.667 11\n10.667 11 18 0 6-1 10-3 12s-6.667 5-14 9c-28.667 14.667-53.667 35.667-75 63\n-1.333 1.333-3.167 3.5-5.5 6.5s-4 4.833-5 5.5c-1 .667-2.5 1.333-4.5 2s-4.333 1\n-7 1c-4.667 0-9.167-1.833-13.5-5.5S337 184 337 178c0-12.667 15.667-32.333 47-59\nH213l-171-1c-8.667-6-13-12.333-13-19 0-4.667 4.333-11.333 13-20h359\nc-16-25.333-24-45-24-59z",widehat1:"M529 0h5l519 115c5 1 9 5 9 10 0 1-1 2-1 3l-4 22\nc-1 5-5 9-11 9h-2L532 67 19 159h-2c-5 0-9-4-11-9l-5-22c-1-6 2-12 8-13z",widehat2:"M1181 0h2l1171 176c6 0 10 5 10 11l-2 23c-1 6-5 10\n-11 10h-1L1182 67 15 220h-1c-6 0-10-4-11-10l-2-23c-1-6 4-11 10-11z",widehat3:"M1181 0h2l1171 236c6 0 10 5 10 11l-2 23c-1 6-5 10\n-11 10h-1L1182 67 15 280h-1c-6 0-10-4-11-10l-2-23c-1-6 4-11 10-11z",widehat4:"M1181 0h2l1171 296c6 0 10 5 10 11l-2 23c-1 6-5 10\n-11 10h-1L1182 67 15 340h-1c-6 0-10-4-11-10l-2-23c-1-6 4-11 10-11z",widecheck1:"M529,159h5l519,-115c5,-1,9,-5,9,-10c0,-1,-1,-2,-1,-3l-4,-22c-1,\n-5,-5,-9,-11,-9h-2l-512,92l-513,-92h-2c-5,0,-9,4,-11,9l-5,22c-1,6,2,12,8,13z",widecheck2:"M1181,220h2l1171,-176c6,0,10,-5,10,-11l-2,-23c-1,-6,-5,-10,\n-11,-10h-1l-1168,153l-1167,-153h-1c-6,0,-10,4,-11,10l-2,23c-1,6,4,11,10,11z",widecheck3:"M1181,280h2l1171,-236c6,0,10,-5,10,-11l-2,-23c-1,-6,-5,-10,\n-11,-10h-1l-1168,213l-1167,-213h-1c-6,0,-10,4,-11,10l-2,23c-1,6,4,11,10,11z",widecheck4:"M1181,340h2l1171,-296c6,0,10,-5,10,-11l-2,-23c-1,-6,-5,-10,\n-11,-10h-1l-1168,273l-1167,-273h-1c-6,0,-10,4,-11,10l-2,23c-1,6,4,11,10,11z",baraboveleftarrow:"M400000 620h-399890l3 -3c68.7 -52.7 113.7 -120 135 -202\nc4 -14.7 6 -23 6 -25c0 -7.3 -7 -11 -21 -11c-8 0 -13.2 0.8 -15.5 2.5\nc-2.3 1.7 -4.2 5.8 -5.5 12.5c-1.3 4.7 -2.7 10.3 -4 17c-12 48.7 -34.8 92 -68.5 130\ns-74.2 66.3 -121.5 85c-10 4 -16 7.7 -18 11c0 8.7 6 14.3 18 17c47.3 18.7 87.8 47\n121.5 85s56.5 81.3 68.5 130c0.7 2 1.3 5 2 9s1.2 6.7 1.5 8c0.3 1.3 1 3.3 2 6\ns2.2 4.5 3.5 5.5c1.3 1 3.3 1.8 6 2.5s6 1 10 1c14 0 21 -3.7 21 -11\nc0 -2 -2 -10.3 -6 -25c-20 -79.3 -65 -146.7 -135 -202l-3 -3h399890z\nM100 620v40h399900v-40z M0 241v40h399900v-40zM0 241v40h399900v-40z",rightarrowabovebar:"M0 241v40h399891c-47.3 35.3-84 78-110 128-16.7 32\n-27.7 63.7-33 95 0 1.3-.2 2.7-.5 4-.3 1.3-.5 2.3-.5 3 0 7.3 6.7 11 20 11 8 0\n13.2-.8 15.5-2.5 2.3-1.7 4.2-5.5 5.5-11.5 2-13.3 5.7-27 11-41 14.7-44.7 39\n-84.5 73-119.5s73.7-60.2 119-75.5c6-2 9-5.7 9-11s-3-9-9-11c-45.3-15.3-85-40.5\n-119-75.5s-58.3-74.8-73-119.5c-4.7-14-8.3-27.3-11-40-1.3-6.7-3.2-10.8-5.5\n-12.5-2.3-1.7-7.5-2.5-15.5-2.5-14 0-21 3.7-21 11 0 2 2 10.3 6 25 20.7 83.3 67\n151.7 139 205zm96 379h399894v40H0zm0 0h399904v40H0z",baraboveshortleftharpoon:"M507,435c-4,4,-6.3,8.7,-7,14c0,5.3,0.7,9,2,11\nc1.3,2,5.3,5.3,12,10c90.7,54,156,130,196,228c3.3,10.7,6.3,16.3,9,17\nc2,0.7,5,1,9,1c0,0,5,0,5,0c10.7,0,16.7,-2,18,-6c2,-2.7,1,-9.7,-3,-21\nc-32,-87.3,-82.7,-157.7,-152,-211c0,0,-3,-3,-3,-3l399351,0l0,-40\nc-398570,0,-399437,0,-399437,0z M593 435 v40 H399500 v-40z\nM0 281 v-40 H399908 v40z M0 281 v-40 H399908 v40z",rightharpoonaboveshortbar:"M0,241 l0,40c399126,0,399993,0,399993,0\nc4.7,-4.7,7,-9.3,7,-14c0,-9.3,-3.7,-15.3,-11,-18c-92.7,-56.7,-159,-133.7,-199,\n-231c-3.3,-9.3,-6,-14.7,-8,-16c-2,-1.3,-7,-2,-15,-2c-10.7,0,-16.7,2,-18,6\nc-2,2.7,-1,9.7,3,21c15.3,42,36.7,81.8,64,119.5c27.3,37.7,58,69.2,92,94.5z\nM0 241 v40 H399908 v-40z M0 475 v-40 H399500 v40z M0 475 v-40 H399500 v40z",shortbaraboveleftharpoon:"M7,435c-4,4,-6.3,8.7,-7,14c0,5.3,0.7,9,2,11\nc1.3,2,5.3,5.3,12,10c90.7,54,156,130,196,228c3.3,10.7,6.3,16.3,9,17c2,0.7,5,1,9,\n1c0,0,5,0,5,0c10.7,0,16.7,-2,18,-6c2,-2.7,1,-9.7,-3,-21c-32,-87.3,-82.7,-157.7,\n-152,-211c0,0,-3,-3,-3,-3l399907,0l0,-40c-399126,0,-399993,0,-399993,0z\nM93 435 v40 H400000 v-40z M500 241 v40 H400000 v-40z M500 241 v40 H400000 v-40z",shortrightharpoonabovebar:"M53,241l0,40c398570,0,399437,0,399437,0\nc4.7,-4.7,7,-9.3,7,-14c0,-9.3,-3.7,-15.3,-11,-18c-92.7,-56.7,-159,-133.7,-199,\n-231c-3.3,-9.3,-6,-14.7,-8,-16c-2,-1.3,-7,-2,-15,-2c-10.7,0,-16.7,2,-18,6\nc-2,2.7,-1,9.7,3,21c15.3,42,36.7,81.8,64,119.5c27.3,37.7,58,69.2,92,94.5z\nM500 241 v40 H399408 v-40z M500 435 v40 H400000 v-40z"},K=function(){function e(e){this.children=void 0,this.classes=void 0,this.height=void 0,this.depth=void 0,this.maxFontSize=void 0,this.style=void 0,this.children=e,this.classes=[],this.height=0,this.depth=0,this.maxFontSize=0,this.style={}}var t=e.prototype;return t.hasClass=function(e){return B.contains(this.classes,e)},t.toNode=function(){for(var e=document.createDocumentFragment(),t=0;t=5?0:e>=3?1:2]){var r=ee[t]={cssEmPerMu:Z.quad[t]/18};for(var n in Z)Z.hasOwnProperty(n)&&(r[n]=Z[n][t])}return ee[t]}(this.size)),this._fontMetrics},t.getColor=function(){return this.phantom?"transparent":this.color},e}();ie.BASESIZE=6;var ae=ie,oe={pt:1,mm:7227/2540,cm:7227/254,"in":72.27,bp:1.00375,pc:12,dd:1238/1157,cc:14856/1157,nd:685/642,nc:1370/107,sp:1/65536,px:1.00375},se={ex:!0,em:!0,mu:!0},le=function(e){return"string"!=typeof e&&(e=e.unit),e in oe||e in se||"ex"===e},ce=function(e,t){var r;if(e.unit in oe)r=oe[e.unit]/t.fontMetrics().ptPerEm/t.sizeMultiplier;else if("mu"===e.unit)r=t.fontMetrics().cssEmPerMu;else{var n;if(n=t.style.isTight()?t.havingStyle(t.style.text()):t,"ex"===e.unit)r=n.fontMetrics().xHeight;else{if("em"!==e.unit)throw new C("Invalid unit: '"+e.unit+"'");r=n.fontMetrics().quad}n!==t&&(r*=n.sizeMultiplier/t.sizeMultiplier)}return Math.min(e.number*r,t.maxSize)},ue=function(e){return+e.toFixed(4)+"em"},he=function(e){return e.filter(function(e){return e}).join(" ")},de=function(e,t,r){if(this.classes=e||[],this.attributes={},this.height=0,this.depth=0,this.maxFontSize=0,this.style=r||{},t){t.style.isTight()&&this.classes.push("mtight");var n=t.getColor();n&&(this.style.color=n)}},pe=function(e){var t=document.createElement(e);for(var r in t.className=he(this.classes),this.style)this.style.hasOwnProperty(r)&&(t.style[r]=this.style[r]);for(var n in this.attributes)this.attributes.hasOwnProperty(n)&&t.setAttribute(n,this.attributes[n]);for(var i=0;i"},fe=function(){function e(e,t,r,n){this.children=void 0,this.attributes=void 0,this.classes=void 0,this.height=void 0,this.depth=void 0,this.width=void 0,this.maxFontSize=void 0,this.style=void 0,de.call(this,e,r,n),this.children=t||[]}var t=e.prototype;return t.setAttribute=function(e,t){this.attributes[e]=t},t.hasClass=function(e){return B.contains(this.classes,e)},t.toNode=function(){return pe.call(this,"span")},t.toMarkup=function(){return me.call(this,"span")},e}(),ge=function(){function e(e,t,r,n){this.children=void 0,this.attributes=void 0,this.classes=void 0,this.height=void 0,this.depth=void 0,this.maxFontSize=void 0,this.style=void 0,de.call(this,t,n),this.children=r||[],this.setAttribute("href",e)}var t=e.prototype;return t.setAttribute=function(e,t){this.attributes[e]=t},t.hasClass=function(e){return B.contains(this.classes,e)},t.toNode=function(){return pe.call(this,"a")},t.toMarkup=function(){return me.call(this,"a")},e}(),ve=function(){function e(e,t,r){this.src=void 0,this.alt=void 0,this.classes=void 0,this.height=void 0,this.depth=void 0,this.maxFontSize=void 0,this.style=void 0,this.alt=t,this.src=e,this.classes=["mord"],this.style=r}var t=e.prototype;return t.hasClass=function(e){return B.contains(this.classes,e)},t.toNode=function(){var e=document.createElement("img");for(var t in e.src=this.src,e.alt=this.alt,e.className="mord",this.style)this.style.hasOwnProperty(t)&&(e.style[t]=this.style[t]);return e},t.toMarkup=function(){var e=""+this.alt+""},e}(),ye={"\xee":"\u0131\u0302","\xef":"\u0131\u0308","\xed":"\u0131\u0301","\xec":"\u0131\u0300"},be=function(){function e(e,t,r,n,i,a,o,s){this.text=void 0,this.height=void 0,this.depth=void 0,this.italic=void 0,this.skew=void 0,this.width=void 0,this.maxFontSize=void 0,this.classes=void 0,this.style=void 0,this.text=e,this.height=t||0,this.depth=r||0,this.italic=n||0,this.skew=i||0,this.width=a||0,this.classes=o||[],this.style=s||{},this.maxFontSize=0;var l=function(e){for(var t=0;t=i[0]&&e<=i[1])return r.name}return null}(this.text.charCodeAt(0));l&&this.classes.push(l+"_fallback"),/[\xee\xef\xed\xec]/.test(this.text)&&(this.text=ye[this.text])}var t=e.prototype;return t.hasClass=function(e){return B.contains(this.classes,e)},t.toNode=function(){var e=document.createTextNode(this.text),t=null;for(var r in this.italic>0&&((t=document.createElement("span")).style.marginRight=ue(this.italic)),this.classes.length>0&&((t=t||document.createElement("span")).className=he(this.classes)),this.style)this.style.hasOwnProperty(r)&&((t=t||document.createElement("span")).style[r]=this.style[r]);return t?(t.appendChild(e),t):e},t.toMarkup=function(){var e=!1,t="0&&(r+="margin-right:"+this.italic+"em;"),this.style)this.style.hasOwnProperty(n)&&(r+=B.hyphenate(n)+":"+this.style[n]+";");r&&(e=!0,t+=' style="'+B.escape(r)+'"');var i=B.escape(this.text);return e?(t+=">",t+=i,t+=""):i},e}(),we=function(){function e(e,t){ +this.children=void 0,this.attributes=void 0,this.children=e||[],this.attributes=t||{}}var t=e.prototype;return t.toNode=function(){var e=document.createElementNS("http://www.w3.org/2000/svg","svg");for(var t in this.attributes)Object.prototype.hasOwnProperty.call(this.attributes,t)&&e.setAttribute(t,this.attributes[t]);for(var r=0;r"},e}(),xe=function(){function e(e,t){this.pathName=void 0,this.alternate=void 0,this.pathName=e,this.alternate=t}var t=e.prototype;return t.toNode=function(){var e=document.createElementNS("http://www.w3.org/2000/svg","path");return this.alternate?e.setAttribute("d",this.alternate):e.setAttribute("d",$[this.pathName]),e},t.toMarkup=function(){return this.alternate?"":""},e}(),ke=function(){function e(e){this.attributes=void 0,this.attributes=e||{}}var t=e.prototype;return t.toNode=function(){var e=document.createElementNS("http://www.w3.org/2000/svg","line");for(var t in this.attributes)Object.prototype.hasOwnProperty.call(this.attributes,t)&&e.setAttribute(t,this.attributes[t]);return e},t.toMarkup=function(){var e=""},e}(),Se={bin:1,close:1,inner:1,open:1,punct:1,rel:1},Ae={"accent-token":1,mathord:1,"op-token":1,spacing:1,textord:1},Ee={math:{},text:{}},Me=Ee,Te="math",Re="text",Ce="main",Le="ams",ze="accent-token",Ne="bin",Ie="close",Be="inner",Pe="mathord",Oe="op-token",qe="open",De="punct",He="rel",Fe="spacing",je="textord";i(Te,Ce,He,"\u2261","\\equiv",!0),i(Te,Ce,He,"\u227a","\\prec",!0),i(Te,Ce,He,"\u227b","\\succ",!0),i(Te,Ce,He,"\u223c","\\sim",!0),i(Te,Ce,He,"\u22a5","\\perp"),i(Te,Ce,He,"\u2aaf","\\preceq",!0),i(Te,Ce,He,"\u2ab0","\\succeq",!0),i(Te,Ce,He,"\u2243","\\simeq",!0),i(Te,Ce,He,"\u2223","\\mid",!0),i(Te,Ce,He,"\u226a","\\ll",!0),i(Te,Ce,He,"\u226b","\\gg",!0),i(Te,Ce,He,"\u224d","\\asymp",!0),i(Te,Ce,He,"\u2225","\\parallel"),i(Te,Ce,He,"\u22c8","\\bowtie",!0),i(Te,Ce,He,"\u2323","\\smile",!0),i(Te,Ce,He,"\u2291","\\sqsubseteq",!0),i(Te,Ce,He,"\u2292","\\sqsupseteq",!0),i(Te,Ce,He,"\u2250","\\doteq",!0),i(Te,Ce,He,"\u2322","\\frown",!0),i(Te,Ce,He,"\u220b","\\ni",!0),i(Te,Ce,He,"\u221d","\\propto",!0),i(Te,Ce,He,"\u22a2","\\vdash",!0),i(Te,Ce,He,"\u22a3","\\dashv",!0),i(Te,Ce,He,"\u220b","\\owns"),i(Te,Ce,De,".","\\ldotp"),i(Te,Ce,De,"\u22c5","\\cdotp"),i(Te,Ce,je,"#","\\#"),i(Re,Ce,je,"#","\\#"),i(Te,Ce,je,"&","\\&"),i(Re,Ce,je,"&","\\&"),i(Te,Ce,je,"\u2135","\\aleph",!0),i(Te,Ce,je,"\u2200","\\forall",!0),i(Te,Ce,je,"\u210f","\\hbar",!0),i(Te,Ce,je,"\u2203","\\exists",!0),i(Te,Ce,je,"\u2207","\\nabla",!0),i(Te,Ce,je,"\u266d","\\flat",!0),i(Te,Ce,je,"\u2113","\\ell",!0),i(Te,Ce,je,"\u266e","\\natural",!0),i(Te,Ce,je,"\u2663","\\clubsuit",!0),i(Te,Ce,je,"\u2118","\\wp",!0),i(Te,Ce,je,"\u266f","\\sharp",!0),i(Te,Ce,je,"\u2662","\\diamondsuit",!0),i(Te,Ce,je,"\u211c","\\Re",!0),i(Te,Ce,je,"\u2661","\\heartsuit",!0),i(Te,Ce,je,"\u2111","\\Im",!0),i(Te,Ce,je,"\u2660","\\spadesuit",!0),i(Te,Ce,je,"\xa7","\\S",!0),i(Re,Ce,je,"\xa7","\\S"),i(Te,Ce,je,"\xb6","\\P",!0),i(Re,Ce,je,"\xb6","\\P"),i(Te,Ce,je,"\u2020","\\dag"),i(Re,Ce,je,"\u2020","\\dag"),i(Re,Ce,je,"\u2020","\\textdagger"),i(Te,Ce,je,"\u2021","\\ddag"),i(Re,Ce,je,"\u2021","\\ddag"),i(Re,Ce,je,"\u2021","\\textdaggerdbl"),i(Te,Ce,Ie,"\u23b1","\\rmoustache",!0),i(Te,Ce,qe,"\u23b0","\\lmoustache",!0),i(Te,Ce,Ie,"\u27ef","\\rgroup",!0),i(Te,Ce,qe,"\u27ee","\\lgroup",!0),i(Te,Ce,Ne,"\u2213","\\mp",!0),i(Te,Ce,Ne,"\u2296","\\ominus",!0),i(Te,Ce,Ne,"\u228e","\\uplus",!0),i(Te,Ce,Ne,"\u2293","\\sqcap",!0),i(Te,Ce,Ne,"\u2217","\\ast"),i(Te,Ce,Ne,"\u2294","\\sqcup",!0),i(Te,Ce,Ne,"\u25ef","\\bigcirc",!0),i(Te,Ce,Ne,"\u2219","\\bullet",!0),i(Te,Ce,Ne,"\u2021","\\ddagger"),i(Te,Ce,Ne,"\u2240","\\wr",!0),i(Te,Ce,Ne,"\u2a3f","\\amalg"),i(Te,Ce,Ne,"&","\\And"),i(Te,Ce,He,"\u27f5","\\longleftarrow",!0),i(Te,Ce,He,"\u21d0","\\Leftarrow",!0),i(Te,Ce,He,"\u27f8","\\Longleftarrow",!0),i(Te,Ce,He,"\u27f6","\\longrightarrow",!0),i(Te,Ce,He,"\u21d2","\\Rightarrow",!0),i(Te,Ce,He,"\u27f9","\\Longrightarrow",!0),i(Te,Ce,He,"\u2194","\\leftrightarrow",!0),i(Te,Ce,He,"\u27f7","\\longleftrightarrow",!0),i(Te,Ce,He,"\u21d4","\\Leftrightarrow",!0),i(Te,Ce,He,"\u27fa","\\Longleftrightarrow",!0),i(Te,Ce,He,"\u21a6","\\mapsto",!0),i(Te,Ce,He,"\u27fc","\\longmapsto",!0),i(Te,Ce,He,"\u2197","\\nearrow",!0),i(Te,Ce,He,"\u21a9","\\hookleftarrow",!0),i(Te,Ce,He,"\u21aa","\\hookrightarrow",!0),i(Te,Ce,He,"\u2198","\\searrow",!0),i(Te,Ce,He,"\u21bc","\\leftharpoonup",!0),i(Te,Ce,He,"\u21c0","\\rightharpoonup",!0),i(Te,Ce,He,"\u2199","\\swarrow",!0),i(Te,Ce,He,"\u21bd","\\leftharpoondown",!0),i(Te,Ce,He,"\u21c1","\\rightharpoondown",!0),i(Te,Ce,He,"\u2196","\\nwarrow",!0),i(Te,Ce,He,"\u21cc","\\rightleftharpoons",!0),i(Te,Le,He,"\u226e","\\nless",!0),i(Te,Le,He,"\ue010","\\@nleqslant"),i(Te,Le,He,"\ue011","\\@nleqq"),i(Te,Le,He,"\u2a87","\\lneq",!0),i(Te,Le,He,"\u2268","\\lneqq",!0),i(Te,Le,He,"\ue00c","\\@lvertneqq"),i(Te,Le,He,"\u22e6","\\lnsim",!0),i(Te,Le,He,"\u2a89","\\lnapprox",!0),i(Te,Le,He,"\u2280","\\nprec",!0),i(Te,Le,He,"\u22e0","\\npreceq",!0),i(Te,Le,He,"\u22e8","\\precnsim",!0),i(Te,Le,He,"\u2ab9","\\precnapprox",!0),i(Te,Le,He,"\u2241","\\nsim",!0),i(Te,Le,He,"\ue006","\\@nshortmid"),i(Te,Le,He,"\u2224","\\nmid",!0),i(Te,Le,He,"\u22ac","\\nvdash",!0),i(Te,Le,He,"\u22ad","\\nvDash",!0),i(Te,Le,He,"\u22ea","\\ntriangleleft"),i(Te,Le,He,"\u22ec","\\ntrianglelefteq",!0),i(Te,Le,He,"\u228a","\\subsetneq",!0),i(Te,Le,He,"\ue01a","\\@varsubsetneq"),i(Te,Le,He,"\u2acb","\\subsetneqq",!0),i(Te,Le,He,"\ue017","\\@varsubsetneqq"),i(Te,Le,He,"\u226f","\\ngtr",!0),i(Te,Le,He,"\ue00f","\\@ngeqslant"),i(Te,Le,He,"\ue00e","\\@ngeqq"),i(Te,Le,He,"\u2a88","\\gneq",!0),i(Te,Le,He,"\u2269","\\gneqq",!0),i(Te,Le,He,"\ue00d","\\@gvertneqq"),i(Te,Le,He,"\u22e7","\\gnsim",!0),i(Te,Le,He,"\u2a8a","\\gnapprox",!0),i(Te,Le,He,"\u2281","\\nsucc",!0),i(Te,Le,He,"\u22e1","\\nsucceq",!0),i(Te,Le,He,"\u22e9","\\succnsim",!0),i(Te,Le,He,"\u2aba","\\succnapprox",!0),i(Te,Le,He,"\u2246","\\ncong",!0),i(Te,Le,He,"\ue007","\\@nshortparallel"),i(Te,Le,He,"\u2226","\\nparallel",!0),i(Te,Le,He,"\u22af","\\nVDash",!0),i(Te,Le,He,"\u22eb","\\ntriangleright"),i(Te,Le,He,"\u22ed","\\ntrianglerighteq",!0),i(Te,Le,He,"\ue018","\\@nsupseteqq"),i(Te,Le,He,"\u228b","\\supsetneq",!0),i(Te,Le,He,"\ue01b","\\@varsupsetneq"),i(Te,Le,He,"\u2acc","\\supsetneqq",!0),i(Te,Le,He,"\ue019","\\@varsupsetneqq"),i(Te,Le,He,"\u22ae","\\nVdash",!0),i(Te,Le,He,"\u2ab5","\\precneqq",!0),i(Te,Le,He,"\u2ab6","\\succneqq",!0),i(Te,Le,He,"\ue016","\\@nsubseteqq"),i(Te,Le,Ne,"\u22b4","\\unlhd"),i(Te,Le,Ne,"\u22b5","\\unrhd"),i(Te,Le,He,"\u219a","\\nleftarrow",!0),i(Te,Le,He,"\u219b","\\nrightarrow",!0),i(Te,Le,He,"\u21cd","\\nLeftarrow",!0),i(Te,Le,He,"\u21cf","\\nRightarrow",!0),i(Te,Le,He,"\u21ae","\\nleftrightarrow",!0),i(Te,Le,He,"\u21ce","\\nLeftrightarrow",!0),i(Te,Le,He,"\u25b3","\\vartriangle"),i(Te,Le,je,"\u210f","\\hslash"),i(Te,Le,je,"\u25bd","\\triangledown"),i(Te,Le,je,"\u25ca","\\lozenge"),i(Te,Le,je,"\u24c8","\\circledS"),i(Te,Le,je,"\xae","\\circledR"),i(Re,Le,je,"\xae","\\circledR"),i(Te,Le,je,"\u2221","\\measuredangle",!0),i(Te,Le,je,"\u2204","\\nexists"),i(Te,Le,je,"\u2127","\\mho"),i(Te,Le,je,"\u2132","\\Finv",!0),i(Te,Le,je,"\u2141","\\Game",!0),i(Te,Le,je,"\u2035","\\backprime"),i(Te,Le,je,"\u25b2","\\blacktriangle"),i(Te,Le,je,"\u25bc","\\blacktriangledown"),i(Te,Le,je,"\u25a0","\\blacksquare"),i(Te,Le,je,"\u29eb","\\blacklozenge"),i(Te,Le,je,"\u2605","\\bigstar"),i(Te,Le,je,"\u2222","\\sphericalangle",!0),i(Te,Le,je,"\u2201","\\complement",!0),i(Te,Le,je,"\xf0","\\eth",!0),i(Re,Ce,je,"\xf0","\xf0"),i(Te,Le,je,"\u2571","\\diagup"),i(Te,Le,je,"\u2572","\\diagdown"),i(Te,Le,je,"\u25a1","\\square"),i(Te,Le,je,"\u25a1","\\Box"),i(Te,Le,je,"\u25ca","\\Diamond"),i(Te,Le,je,"\xa5","\\yen",!0),i(Re,Le,je,"\xa5","\\yen",!0),i(Te,Le,je,"\u2713","\\checkmark",!0),i(Re,Le,je,"\u2713","\\checkmark"),i(Te,Le,je,"\u2136","\\beth",!0),i(Te,Le,je,"\u2138","\\daleth",!0),i(Te,Le,je,"\u2137","\\gimel",!0),i(Te,Le,je,"\u03dd","\\digamma",!0),i(Te,Le,je,"\u03f0","\\varkappa"),i(Te,Le,qe,"\u250c","\\@ulcorner",!0),i(Te,Le,Ie,"\u2510","\\@urcorner",!0),i(Te,Le,qe,"\u2514","\\@llcorner",!0),i(Te,Le,Ie,"\u2518","\\@lrcorner",!0),i(Te,Le,He,"\u2266","\\leqq",!0),i(Te,Le,He,"\u2a7d","\\leqslant",!0),i(Te,Le,He,"\u2a95","\\eqslantless",!0),i(Te,Le,He,"\u2272","\\lesssim",!0),i(Te,Le,He,"\u2a85","\\lessapprox",!0),i(Te,Le,He,"\u224a","\\approxeq",!0),i(Te,Le,Ne,"\u22d6","\\lessdot"),i(Te,Le,He,"\u22d8","\\lll",!0),i(Te,Le,He,"\u2276","\\lessgtr",!0),i(Te,Le,He,"\u22da","\\lesseqgtr",!0),i(Te,Le,He,"\u2a8b","\\lesseqqgtr",!0),i(Te,Le,He,"\u2251","\\doteqdot"),i(Te,Le,He,"\u2253","\\risingdotseq",!0),i(Te,Le,He,"\u2252","\\fallingdotseq",!0),i(Te,Le,He,"\u223d","\\backsim",!0),i(Te,Le,He,"\u22cd","\\backsimeq",!0),i(Te,Le,He,"\u2ac5","\\subseteqq",!0),i(Te,Le,He,"\u22d0","\\Subset",!0),i(Te,Le,He,"\u228f","\\sqsubset",!0),i(Te,Le,He,"\u227c","\\preccurlyeq",!0),i(Te,Le,He,"\u22de","\\curlyeqprec",!0),i(Te,Le,He,"\u227e","\\precsim",!0),i(Te,Le,He,"\u2ab7","\\precapprox",!0),i(Te,Le,He,"\u22b2","\\vartriangleleft"),i(Te,Le,He,"\u22b4","\\trianglelefteq"),i(Te,Le,He,"\u22a8","\\vDash",!0),i(Te,Le,He,"\u22aa","\\Vvdash",!0),i(Te,Le,He,"\u2323","\\smallsmile"),i(Te,Le,He,"\u2322","\\smallfrown"),i(Te,Le,He,"\u224f","\\bumpeq",!0),i(Te,Le,He,"\u224e","\\Bumpeq",!0),i(Te,Le,He,"\u2267","\\geqq",!0),i(Te,Le,He,"\u2a7e","\\geqslant",!0),i(Te,Le,He,"\u2a96","\\eqslantgtr",!0),i(Te,Le,He,"\u2273","\\gtrsim",!0),i(Te,Le,He,"\u2a86","\\gtrapprox",!0),i(Te,Le,Ne,"\u22d7","\\gtrdot"),i(Te,Le,He,"\u22d9","\\ggg",!0),i(Te,Le,He,"\u2277","\\gtrless",!0),i(Te,Le,He,"\u22db","\\gtreqless",!0),i(Te,Le,He,"\u2a8c","\\gtreqqless",!0),i(Te,Le,He,"\u2256","\\eqcirc",!0),i(Te,Le,He,"\u2257","\\circeq",!0),i(Te,Le,He,"\u225c","\\triangleq",!0),i(Te,Le,He,"\u223c","\\thicksim"),i(Te,Le,He,"\u2248","\\thickapprox"),i(Te,Le,He,"\u2ac6","\\supseteqq",!0),i(Te,Le,He,"\u22d1","\\Supset",!0),i(Te,Le,He,"\u2290","\\sqsupset",!0),i(Te,Le,He,"\u227d","\\succcurlyeq",!0),i(Te,Le,He,"\u22df","\\curlyeqsucc",!0),i(Te,Le,He,"\u227f","\\succsim",!0),i(Te,Le,He,"\u2ab8","\\succapprox",!0),i(Te,Le,He,"\u22b3","\\vartriangleright"),i(Te,Le,He,"\u22b5","\\trianglerighteq"),i(Te,Le,He,"\u22a9","\\Vdash",!0),i(Te,Le,He,"\u2223","\\shortmid"),i(Te,Le,He,"\u2225","\\shortparallel"),i(Te,Le,He,"\u226c","\\between",!0),i(Te,Le,He,"\u22d4","\\pitchfork",!0),i(Te,Le,He,"\u221d","\\varpropto"),i(Te,Le,He,"\u25c0","\\blacktriangleleft"),i(Te,Le,He,"\u2234","\\therefore",!0),i(Te,Le,He,"\u220d","\\backepsilon"),i(Te,Le,He,"\u25b6","\\blacktriangleright"),i(Te,Le,He,"\u2235","\\because",!0),i(Te,Le,He,"\u22d8","\\llless"),i(Te,Le,He,"\u22d9","\\gggtr"),i(Te,Le,Ne,"\u22b2","\\lhd"),i(Te,Le,Ne,"\u22b3","\\rhd"),i(Te,Le,He,"\u2242","\\eqsim",!0),i(Te,Ce,He,"\u22c8","\\Join"),i(Te,Le,He,"\u2251","\\Doteq",!0),i(Te,Le,Ne,"\u2214","\\dotplus",!0),i(Te,Le,Ne,"\u2216","\\smallsetminus"),i(Te,Le,Ne,"\u22d2","\\Cap",!0),i(Te,Le,Ne,"\u22d3","\\Cup",!0),i(Te,Le,Ne,"\u2a5e","\\doublebarwedge",!0),i(Te,Le,Ne,"\u229f","\\boxminus",!0),i(Te,Le,Ne,"\u229e","\\boxplus",!0),i(Te,Le,Ne,"\u22c7","\\divideontimes",!0),i(Te,Le,Ne,"\u22c9","\\ltimes",!0),i(Te,Le,Ne,"\u22ca","\\rtimes",!0),i(Te,Le,Ne,"\u22cb","\\leftthreetimes",!0),i(Te,Le,Ne,"\u22cc","\\rightthreetimes",!0),i(Te,Le,Ne,"\u22cf","\\curlywedge",!0),i(Te,Le,Ne,"\u22ce","\\curlyvee",!0),i(Te,Le,Ne,"\u229d","\\circleddash",!0),i(Te,Le,Ne,"\u229b","\\circledast",!0),i(Te,Le,Ne,"\u22c5","\\centerdot"),i(Te,Le,Ne,"\u22ba","\\intercal",!0),i(Te,Le,Ne,"\u22d2","\\doublecap"),i(Te,Le,Ne,"\u22d3","\\doublecup"),i(Te,Le,Ne,"\u22a0","\\boxtimes",!0),i(Te,Le,He,"\u21e2","\\dashrightarrow",!0),i(Te,Le,He,"\u21e0","\\dashleftarrow",!0),i(Te,Le,He,"\u21c7","\\leftleftarrows",!0),i(Te,Le,He,"\u21c6","\\leftrightarrows",!0),i(Te,Le,He,"\u21da","\\Lleftarrow",!0),i(Te,Le,He,"\u219e","\\twoheadleftarrow",!0),i(Te,Le,He,"\u21a2","\\leftarrowtail",!0),i(Te,Le,He,"\u21ab","\\looparrowleft",!0),i(Te,Le,He,"\u21cb","\\leftrightharpoons",!0),i(Te,Le,He,"\u21b6","\\curvearrowleft",!0),i(Te,Le,He,"\u21ba","\\circlearrowleft",!0),i(Te,Le,He,"\u21b0","\\Lsh",!0),i(Te,Le,He,"\u21c8","\\upuparrows",!0),i(Te,Le,He,"\u21bf","\\upharpoonleft",!0),i(Te,Le,He,"\u21c3","\\downharpoonleft",!0),i(Te,Ce,He,"\u22b6","\\origof",!0),i(Te,Ce,He,"\u22b7","\\imageof",!0),i(Te,Le,He,"\u22b8","\\multimap",!0),i(Te,Le,He,"\u21ad","\\leftrightsquigarrow",!0),i(Te,Le,He,"\u21c9","\\rightrightarrows",!0),i(Te,Le,He,"\u21c4","\\rightleftarrows",!0),i(Te,Le,He,"\u21a0","\\twoheadrightarrow",!0),i(Te,Le,He,"\u21a3","\\rightarrowtail",!0),i(Te,Le,He,"\u21ac","\\looparrowright",!0),i(Te,Le,He,"\u21b7","\\curvearrowright",!0),i(Te,Le,He,"\u21bb","\\circlearrowright",!0),i(Te,Le,He,"\u21b1","\\Rsh",!0),i(Te,Le,He,"\u21ca","\\downdownarrows",!0),i(Te,Le,He,"\u21be","\\upharpoonright",!0),i(Te,Le,He,"\u21c2","\\downharpoonright",!0),i(Te,Le,He,"\u21dd","\\rightsquigarrow",!0),i(Te,Le,He,"\u21dd","\\leadsto"),i(Te,Le,He,"\u21db","\\Rrightarrow",!0),i(Te,Le,He,"\u21be","\\restriction"),i(Te,Ce,je,"\u2018","`"),i(Te,Ce,je,"$","\\$"),i(Re,Ce,je,"$","\\$"),i(Re,Ce,je,"$","\\textdollar"),i(Te,Ce,je,"%","\\%"),i(Re,Ce,je,"%","\\%"),i(Te,Ce,je,"_","\\_"),i(Re,Ce,je,"_","\\_"),i(Re,Ce,je,"_","\\textunderscore"),i(Te,Ce,je,"\u2220","\\angle",!0),i(Te,Ce,je,"\u221e","\\infty",!0),i(Te,Ce,je,"\u2032","\\prime"),i(Te,Ce,je,"\u25b3","\\triangle"),i(Te,Ce,je,"\u0393","\\Gamma",!0),i(Te,Ce,je,"\u0394","\\Delta",!0),i(Te,Ce,je,"\u0398","\\Theta",!0),i(Te,Ce,je,"\u039b","\\Lambda",!0),i(Te,Ce,je,"\u039e","\\Xi",!0),i(Te,Ce,je,"\u03a0","\\Pi",!0),i(Te,Ce,je,"\u03a3","\\Sigma",!0),i(Te,Ce,je,"\u03a5","\\Upsilon",!0),i(Te,Ce,je,"\u03a6","\\Phi",!0),i(Te,Ce,je,"\u03a8","\\Psi",!0),i(Te,Ce,je,"\u03a9","\\Omega",!0),i(Te,Ce,je,"A","\u0391"),i(Te,Ce,je,"B","\u0392"),i(Te,Ce,je,"E","\u0395"),i(Te,Ce,je,"Z","\u0396"),i(Te,Ce,je,"H","\u0397"),i(Te,Ce,je,"I","\u0399"),i(Te,Ce,je,"K","\u039a"),i(Te,Ce,je,"M","\u039c"),i(Te,Ce,je,"N","\u039d"),i(Te,Ce,je,"O","\u039f"),i(Te,Ce,je,"P","\u03a1"),i(Te,Ce,je,"T","\u03a4"),i(Te,Ce,je,"X","\u03a7"),i(Te,Ce,je,"\xac","\\neg",!0),i(Te,Ce,je,"\xac","\\lnot"),i(Te,Ce,je,"\u22a4","\\top"),i(Te,Ce,je,"\u22a5","\\bot"),i(Te,Ce,je,"\u2205","\\emptyset"),i(Te,Le,je,"\u2205","\\varnothing"),i(Te,Ce,Pe,"\u03b1","\\alpha",!0),i(Te,Ce,Pe,"\u03b2","\\beta",!0),i(Te,Ce,Pe,"\u03b3","\\gamma",!0),i(Te,Ce,Pe,"\u03b4","\\delta",!0),i(Te,Ce,Pe,"\u03f5","\\epsilon",!0),i(Te,Ce,Pe,"\u03b6","\\zeta",!0),i(Te,Ce,Pe,"\u03b7","\\eta",!0),i(Te,Ce,Pe,"\u03b8","\\theta",!0),i(Te,Ce,Pe,"\u03b9","\\iota",!0),i(Te,Ce,Pe,"\u03ba","\\kappa",!0),i(Te,Ce,Pe,"\u03bb","\\lambda",!0),i(Te,Ce,Pe,"\u03bc","\\mu",!0),i(Te,Ce,Pe,"\u03bd","\\nu",!0),i(Te,Ce,Pe,"\u03be","\\xi",!0),i(Te,Ce,Pe,"\u03bf","\\omicron",!0),i(Te,Ce,Pe,"\u03c0","\\pi",!0),i(Te,Ce,Pe,"\u03c1","\\rho",!0),i(Te,Ce,Pe,"\u03c3","\\sigma",!0),i(Te,Ce,Pe,"\u03c4","\\tau",!0),i(Te,Ce,Pe,"\u03c5","\\upsilon",!0),i(Te,Ce,Pe,"\u03d5","\\phi",!0),i(Te,Ce,Pe,"\u03c7","\\chi",!0),i(Te,Ce,Pe,"\u03c8","\\psi",!0),i(Te,Ce,Pe,"\u03c9","\\omega",!0),i(Te,Ce,Pe,"\u03b5","\\varepsilon",!0),i(Te,Ce,Pe,"\u03d1","\\vartheta",!0),i(Te,Ce,Pe,"\u03d6","\\varpi",!0),i(Te,Ce,Pe,"\u03f1","\\varrho",!0),i(Te,Ce,Pe,"\u03c2","\\varsigma",!0),i(Te,Ce,Pe,"\u03c6","\\varphi",!0),i(Te,Ce,Ne,"\u2217","*",!0),i(Te,Ce,Ne,"+","+"),i(Te,Ce,Ne,"\u2212","-",!0),i(Te,Ce,Ne,"\u22c5","\\cdot",!0),i(Te,Ce,Ne,"\u2218","\\circ",!0),i(Te,Ce,Ne,"\xf7","\\div",!0),i(Te,Ce,Ne,"\xb1","\\pm",!0),i(Te,Ce,Ne,"\xd7","\\times",!0),i(Te,Ce,Ne,"\u2229","\\cap",!0),i(Te,Ce,Ne,"\u222a","\\cup",!0),i(Te,Ce,Ne,"\u2216","\\setminus",!0),i(Te,Ce,Ne,"\u2227","\\land"),i(Te,Ce,Ne,"\u2228","\\lor"),i(Te,Ce,Ne,"\u2227","\\wedge",!0),i(Te,Ce,Ne,"\u2228","\\vee",!0),i(Te,Ce,je,"\u221a","\\surd"),i(Te,Ce,qe,"\u27e8","\\langle",!0),i(Te,Ce,qe,"\u2223","\\lvert"),i(Te,Ce,qe,"\u2225","\\lVert"),i(Te,Ce,Ie,"?","?"),i(Te,Ce,Ie,"!","!"),i(Te,Ce,Ie,"\u27e9","\\rangle",!0),i(Te,Ce,Ie,"\u2223","\\rvert"),i(Te,Ce,Ie,"\u2225","\\rVert"),i(Te,Ce,He,"=","="),i(Te,Ce,He,":",":"),i(Te,Ce,He,"\u2248","\\approx",!0),i(Te,Ce,He,"\u2245","\\cong",!0),i(Te,Ce,He,"\u2265","\\ge"),i(Te,Ce,He,"\u2265","\\geq",!0),i(Te,Ce,He,"\u2190","\\gets"),i(Te,Ce,He,">","\\gt",!0),i(Te,Ce,He,"\u2208","\\in",!0),i(Te,Ce,He,"\ue020","\\@not"),i(Te,Ce,He,"\u2282","\\subset",!0),i(Te,Ce,He,"\u2283","\\supset",!0),i(Te,Ce,He,"\u2286","\\subseteq",!0),i(Te,Ce,He,"\u2287","\\supseteq",!0),i(Te,Le,He,"\u2288","\\nsubseteq",!0),i(Te,Le,He,"\u2289","\\nsupseteq",!0),i(Te,Ce,He,"\u22a8","\\models"),i(Te,Ce,He,"\u2190","\\leftarrow",!0),i(Te,Ce,He,"\u2264","\\le"),i(Te,Ce,He,"\u2264","\\leq",!0),i(Te,Ce,He,"<","\\lt",!0),i(Te,Ce,He,"\u2192","\\rightarrow",!0),i(Te,Ce,He,"\u2192","\\to"),i(Te,Le,He,"\u2271","\\ngeq",!0),i(Te,Le,He,"\u2270","\\nleq",!0),i(Te,Ce,Fe,"\xa0","\\ "),i(Te,Ce,Fe,"\xa0","\\space"),i(Te,Ce,Fe,"\xa0","\\nobreakspace"),i(Re,Ce,Fe,"\xa0","\\ "),i(Re,Ce,Fe,"\xa0"," "),i(Re,Ce,Fe,"\xa0","\\space"),i(Re,Ce,Fe,"\xa0","\\nobreakspace"),i(Te,Ce,Fe,null,"\\nobreak"),i(Te,Ce,Fe,null,"\\allowbreak"),i(Te,Ce,De,",",","),i(Te,Ce,De,";",";"),i(Te,Le,Ne,"\u22bc","\\barwedge",!0),i(Te,Le,Ne,"\u22bb","\\veebar",!0),i(Te,Ce,Ne,"\u2299","\\odot",!0),i(Te,Ce,Ne,"\u2295","\\oplus",!0),i(Te,Ce,Ne,"\u2297","\\otimes",!0),i(Te,Ce,je,"\u2202","\\partial",!0),i(Te,Ce,Ne,"\u2298","\\oslash",!0),i(Te,Le,Ne,"\u229a","\\circledcirc",!0),i(Te,Le,Ne,"\u22a1","\\boxdot",!0),i(Te,Ce,Ne,"\u25b3","\\bigtriangleup"),i(Te,Ce,Ne,"\u25bd","\\bigtriangledown"),i(Te,Ce,Ne,"\u2020","\\dagger"),i(Te,Ce,Ne,"\u22c4","\\diamond"),i(Te,Ce,Ne,"\u22c6","\\star"),i(Te,Ce,Ne,"\u25c3","\\triangleleft"),i(Te,Ce,Ne,"\u25b9","\\triangleright"),i(Te,Ce,qe,"{","\\{"),i(Re,Ce,je,"{","\\{"),i(Re,Ce,je,"{","\\textbraceleft"),i(Te,Ce,Ie,"}","\\}"),i(Re,Ce,je,"}","\\}"),i(Re,Ce,je,"}","\\textbraceright"),i(Te,Ce,qe,"{","\\lbrace"),i(Te,Ce,Ie,"}","\\rbrace"),i(Te,Ce,qe,"[","\\lbrack",!0),i(Re,Ce,je,"[","\\lbrack",!0),i(Te,Ce,Ie,"]","\\rbrack",!0),i(Re,Ce,je,"]","\\rbrack",!0),i(Te,Ce,qe,"(","\\lparen",!0),i(Te,Ce,Ie,")","\\rparen",!0),i(Re,Ce,je,"<","\\textless",!0),i(Re,Ce,je,">","\\textgreater",!0),i(Te,Ce,qe,"\u230a","\\lfloor",!0),i(Te,Ce,Ie,"\u230b","\\rfloor",!0),i(Te,Ce,qe,"\u2308","\\lceil",!0),i(Te,Ce,Ie,"\u2309","\\rceil",!0),i(Te,Ce,je,"\\","\\backslash"),i(Te,Ce,je,"\u2223","|"),i(Te,Ce,je,"\u2223","\\vert"),i(Re,Ce,je,"|","\\textbar",!0),i(Te,Ce,je,"\u2225","\\|"),i(Te,Ce,je,"\u2225","\\Vert"),i(Re,Ce,je,"\u2225","\\textbardbl"),i(Re,Ce,je,"~","\\textasciitilde"),i(Re,Ce,je,"\\","\\textbackslash"),i(Re,Ce,je,"^","\\textasciicircum"),i(Te,Ce,He,"\u2191","\\uparrow",!0),i(Te,Ce,He,"\u21d1","\\Uparrow",!0),i(Te,Ce,He,"\u2193","\\downarrow",!0),i(Te,Ce,He,"\u21d3","\\Downarrow",!0),i(Te,Ce,He,"\u2195","\\updownarrow",!0),i(Te,Ce,He,"\u21d5","\\Updownarrow",!0),i(Te,Ce,Oe,"\u2210","\\coprod"),i(Te,Ce,Oe,"\u22c1","\\bigvee"),i(Te,Ce,Oe,"\u22c0","\\bigwedge"),i(Te,Ce,Oe,"\u2a04","\\biguplus"),i(Te,Ce,Oe,"\u22c2","\\bigcap"),i(Te,Ce,Oe,"\u22c3","\\bigcup"),i(Te,Ce,Oe,"\u222b","\\int"),i(Te,Ce,Oe,"\u222b","\\intop"),i(Te,Ce,Oe,"\u222c","\\iint"),i(Te,Ce,Oe,"\u222d","\\iiint"),i(Te,Ce,Oe,"\u220f","\\prod"),i(Te,Ce,Oe,"\u2211","\\sum"),i(Te,Ce,Oe,"\u2a02","\\bigotimes"),i(Te,Ce,Oe,"\u2a01","\\bigoplus"),i(Te,Ce,Oe,"\u2a00","\\bigodot"),i(Te,Ce,Oe,"\u222e","\\oint"),i(Te,Ce,Oe,"\u222f","\\oiint"),i(Te,Ce,Oe,"\u2230","\\oiiint"),i(Te,Ce,Oe,"\u2a06","\\bigsqcup"),i(Te,Ce,Oe,"\u222b","\\smallint"),i(Re,Ce,Be,"\u2026","\\textellipsis"),i(Te,Ce,Be,"\u2026","\\mathellipsis"),i(Re,Ce,Be,"\u2026","\\ldots",!0),i(Te,Ce,Be,"\u2026","\\ldots",!0),i(Te,Ce,Be,"\u22ef","\\@cdots",!0),i(Te,Ce,Be,"\u22f1","\\ddots",!0),i(Te,Ce,je,"\u22ee","\\varvdots"),i(Te,Ce,ze,"\u02ca","\\acute"),i(Te,Ce,ze,"\u02cb","\\grave"),i(Te,Ce,ze,"\xa8","\\ddot"),i(Te,Ce,ze,"~","\\tilde"),i(Te,Ce,ze,"\u02c9","\\bar"),i(Te,Ce,ze,"\u02d8","\\breve"),i(Te,Ce,ze,"\u02c7","\\check"),i(Te,Ce,ze,"^","\\hat"),i(Te,Ce,ze,"\u20d7","\\vec"),i(Te,Ce,ze,"\u02d9","\\dot"),i(Te,Ce,ze,"\u02da","\\mathring"),i(Te,Ce,Pe,"\ue131","\\@imath"),i(Te,Ce,Pe,"\ue237","\\@jmath"),i(Te,Ce,je,"\u0131","\u0131"),i(Te,Ce,je,"\u0237","\u0237"),i(Re,Ce,je,"\u0131","\\i",!0),i(Re,Ce,je,"\u0237","\\j",!0),i(Re,Ce,je,"\xdf","\\ss",!0),i(Re,Ce,je,"\xe6","\\ae",!0),i(Re,Ce,je,"\u0153","\\oe",!0),i(Re,Ce,je,"\xf8","\\o",!0),i(Re,Ce,je,"\xc6","\\AE",!0),i(Re,Ce,je,"\u0152","\\OE",!0),i(Re,Ce,je,"\xd8","\\O",!0),i(Re,Ce,ze,"\u02ca","\\'"),i(Re,Ce,ze,"\u02cb","\\`"),i(Re,Ce,ze,"\u02c6","\\^"),i(Re,Ce,ze,"\u02dc","\\~"),i(Re,Ce,ze,"\u02c9","\\="),i(Re,Ce,ze,"\u02d8","\\u"),i(Re,Ce,ze,"\u02d9","\\."),i(Re,Ce,ze,"\xb8","\\c"),i(Re,Ce,ze,"\u02da","\\r"),i(Re,Ce,ze,"\u02c7","\\v"),i(Re,Ce,ze,"\xa8",'\\"'),i(Re,Ce,ze,"\u02dd","\\H"),i(Re,Ce,ze,"\u25ef","\\textcircled");var Ve={"--":!0,"---":!0,"``":!0,"''":!0};i(Re,Ce,je,"\u2013","--",!0),i(Re,Ce,je,"\u2013","\\textendash"),i(Re,Ce,je,"\u2014","---",!0),i(Re,Ce,je,"\u2014","\\textemdash"),i(Re,Ce,je,"\u2018","`",!0),i(Re,Ce,je,"\u2018","\\textquoteleft"),i(Re,Ce,je,"\u2019","'",!0),i(Re,Ce,je,"\u2019","\\textquoteright"),i(Re,Ce,je,"\u201c","``",!0),i(Re,Ce,je,"\u201c","\\textquotedblleft"),i(Re,Ce,je,"\u201d","''",!0),i(Re,Ce,je,"\u201d","\\textquotedblright"),i(Te,Ce,je,"\xb0","\\degree",!0),i(Re,Ce,je,"\xb0","\\degree"),i(Re,Ce,je,"\xb0","\\textdegree",!0),i(Te,Ce,je,"\xa3","\\pounds"),i(Te,Ce,je,"\xa3","\\mathsterling",!0),i(Re,Ce,je,"\xa3","\\pounds"),i(Re,Ce,je,"\xa3","\\textsterling",!0),i(Te,Le,je,"\u2720","\\maltese"),i(Re,Le,je,"\u2720","\\maltese");for(var Ue='0123456789/@."',_e=0;_et&&(t=a.height),a.depth>r&&(r=a.depth),a.maxFontSize>n&&(n=a.maxFontSize)}e.height=t,e.depth=r,e.maxFontSize=n},dt=function(e,t,r,n){var i=new fe(e,t,r,n);return ht(i),i},pt=function(e,t,r,n){return new fe(e,t,r,n)},mt=function(e){var t=new K(e);return ht(t),t},ft=function(e,t,r){var n="";switch(e){case"amsrm":n="AMS";break;case"textrm":n="Main";break;case"textsf":n="SansSerif";break;case"texttt":n="Typewriter";break;default:n=e}return n+"-"+("textbf"===t&&"textit"===r?"BoldItalic":"textbf"===t?"Bold":"textit"===t?"Italic":"Regular")},gt={mathbf:{variant:"bold",fontName:"Main-Bold"},mathrm:{variant:"normal",fontName:"Main-Regular"},textit:{variant:"italic",fontName:"Main-Italic"},mathit:{variant:"italic",fontName:"Main-Italic"},mathnormal:{variant:"italic",fontName:"Math-Italic"},mathbb:{variant:"double-struck",fontName:"AMS-Regular"},mathcal:{variant:"script",fontName:"Caligraphic-Regular"},mathfrak:{variant:"fraktur",fontName:"Fraktur-Regular"},mathscr:{variant:"script",fontName:"Script-Regular"},mathsf:{variant:"sans-serif",fontName:"SansSerif-Regular"},mathtt:{variant:"monospace",fontName:"Typewriter-Regular"}},vt={vec:["vec",.471,.714],oiintSize1:["oiintSize1",.957,.499],oiintSize2:["oiintSize2",1.472,.659],oiiintSize1:["oiiintSize1",1.304,.499],oiiintSize2:["oiiintSize2",1.98,.659]},yt={fontMap:gt,makeSymbol:ct,mathsym:function(e,t,r,n){return void 0===n&&(n=[]),"boldsymbol"===r.font&<(e,"Main-Bold",t).metrics?ct(e,"Main-Bold",t,r,n.concat(["mathbf"])):"\\"===e||"main"===Me[t][e].font?ct(e,"Main-Regular",t,r,n):ct(e,"AMS-Regular",t,r,n.concat(["amsrm"]))},makeSpan:dt,makeSvgSpan:pt,makeLineSpan:function(e,t,r){var n=dt([e],[],t);return n.height=Math.max(r||t.fontMetrics().defaultRuleThickness,t.minRuleThickness),n.style.borderBottomWidth=ue(n.height),n.maxFontSize=1,n},makeAnchor:function(e,t,r,n){var i=new ge(e,t,r,n);return ht(i),i},makeFragment:mt,wrapFragment:function(e,t){return e instanceof K?dt([],[e],t):e},makeVList:function(e){for(var t=function(e){if("individualShift"===e.positionType){for(var t=e.children,r=[t[0]],n=-t[0].shift-t[0].elem.depth,i=n,a=1;a0)return ct(i,l,n,t,a.concat(c));if(s){var h,d;if("boldsymbol"===s){var p=function(e,t,r,n,a){return"textord"!==a&<(i,"Math-BoldItalic",t).metrics?{fontName:"Math-BoldItalic",fontClass:"boldsymbol"}:{fontName:"Main-Bold",fontClass:"mathbf"}}(0,n,0,0,r);h=p.fontName,d=[p.fontClass]}else o?(h=gt[s].fontName,d=[s]):(h=ft(s,t.fontWeight,t.fontShape),d=[s,t.fontWeight,t.fontShape]);if(lt(i,h,n).metrics)return ct(i,h,n,t,a.concat(d));if(Ve.hasOwnProperty(i)&&"Typewriter"===h.slice(0,10)){for(var m=[],f=0;f0&&(e.className=he(this.classes));for(var r=0;r0&&(e+=' class ="'+B.escape(he(this.classes))+'"'),e+=">";for(var r=0;r"},t.toText=function(){return this.children.map(function(e){return e.toText()}).join("")},e}(),Vt=function(){function e(e){this.text=void 0,this.text=e}var t=e.prototype;return t.toNode=function(){return document.createTextNode(this.text)},t.toMarkup=function(){return B.escape(this.toText())},t.toText=function(){return this.text},e}(),Ut={MathNode:jt,TextNode:Vt,SpaceNode:function(){function e(e){this.width=void 0,this.character=void 0,this.width=e,this.character=e>=.05555&&e<=.05556?"\u200a":e>=.1666&&e<=.1667?"\u2009":e>=.2222&&e<=.2223?"\u2005":e>=.2777&&e<=.2778?"\u2005\u200a":e>=-.05556&&e<=-.05555?"\u200a\u2063":e>=-.1667&&e<=-.1666?"\u2009\u2063":e>=-.2223&&e<=-.2222?"\u205f\u2063":e>=-.2778&&e<=-.2777?"\u2005\u2063":null}var t=e.prototype;return t.toNode=function(){if(this.character)return document.createTextNode(this.character);var e=document.createElementNS("http://www.w3.org/1998/Math/MathML","mspace");return e.setAttribute("width",ue(this.width)),e},t.toMarkup=function(){return this.character?""+this.character+"":''},t.toText=function(){return this.character?this.character:" "},e}(),newDocumentFragment:c},_t=function(e,t,r){return!Me[t][e]||!Me[t][e].replace||55349===e.charCodeAt(0)||Ve.hasOwnProperty(e)&&r&&(r.fontFamily&&"tt"===r.fontFamily.slice(4,6)||r.font&&"tt"===r.font.slice(4,6))||(e=Me[t][e].replace),new Ut.TextNode(e)},Wt=function(e){return 1===e.length?e[0]:new Ut.MathNode("mrow",e)},Gt=function(e,t){if("texttt"===t.fontFamily)return"monospace";if("textsf"===t.fontFamily)return"textit"===t.fontShape&&"textbf"===t.fontWeight?"sans-serif-bold-italic":"textit"===t.fontShape?"sans-serif-italic":"textbf"===t.fontWeight?"bold-sans-serif":"sans-serif";if("textit"===t.fontShape&&"textbf"===t.fontWeight)return"bold-italic";if("textit"===t.fontShape)return"italic";if("textbf"===t.fontWeight)return"bold";var n=t.font;if(!n||"mathnormal"===n)return null;var i=e.mode;if("mathit"===n)return"italic";if("boldsymbol"===n)return"textord"===e.type?"bold":"bold-italic";if("mathbf"===n)return"bold";if("mathbb"===n)return"double-struck";if("mathfrak"===n)return"fraktur";if("mathscr"===n||"mathcal"===n)return"script";if("mathsf"===n)return"sans-serif";if("mathtt"===n)return"monospace";var a=e.text;return B.contains(["\\imath","\\jmath"],a)?null:(Me[i][a]&&Me[i][a].replace&&(a=Me[i][a].replace),r(a,yt.fontMap[n].fontName,i)?yt.fontMap[n].variant:null)},Yt=function(e,t,r){if(1===e.length){var n=$t(e[0],t);return r&&n instanceof jt&&"mo"===n.type&&(n.setAttribute("lspace","0em"),n.setAttribute("rspace","0em")),[n]}for(var i,a=[],o=0;o0&&(p.text=p.text.slice(0,1)+"\u0338"+p.text.slice(1),a.pop())}}}a.push(s),i=s}return a},Xt=function(e,t,r){return Wt(Yt(e,t,r))},$t=function(e,t){if(!e)return new Ut.MathNode("mrow");if(Mt[e.type])return Mt[e.type](e,t);throw new C("Got group of unknown type: '"+e.type+"'")},Kt=function(e){return new ae({style:e.displayMode?W.DISPLAY:W.TEXT,maxSize:e.maxSize,minRuleThickness:e.minRuleThickness})},Jt=function(e,t){if(t.displayMode){var r=["katex-display"];t.leqno&&r.push("leqno"),t.fleqn&&r.push("fleqn"),e=yt.makeSpan(r,[e])}return e},Zt=function(e,t,r){var n,i=Kt(r);if("mathml"===r.output)return u(e,t,i,r.displayMode,!0);if("html"===r.output){var a=l(e,i);n=yt.makeSpan(["katex"],[a])}else{var o=u(e,t,i,r.displayMode,!1),s=l(e,i);n=yt.makeSpan(["katex"],[o,s])}return Jt(n,r)},Qt={widehat:"^",widecheck:"\u02c7",widetilde:"~",utilde:"~",overleftarrow:"\u2190",underleftarrow:"\u2190",xleftarrow:"\u2190",overrightarrow:"\u2192",underrightarrow:"\u2192",xrightarrow:"\u2192",underbrace:"\u23df",overbrace:"\u23de",overgroup:"\u23e0",undergroup:"\u23e1",overleftrightarrow:"\u2194",underleftrightarrow:"\u2194",xleftrightarrow:"\u2194",Overrightarrow:"\u21d2",xRightarrow:"\u21d2",overleftharpoon:"\u21bc",xleftharpoonup:"\u21bc",overrightharpoon:"\u21c0",xrightharpoonup:"\u21c0",xLeftarrow:"\u21d0",xLeftrightarrow:"\u21d4",xhookleftarrow:"\u21a9",xhookrightarrow:"\u21aa",xmapsto:"\u21a6",xrightharpoondown:"\u21c1",xleftharpoondown:"\u21bd",xrightleftharpoons:"\u21cc",xleftrightharpoons:"\u21cb",xtwoheadleftarrow:"\u219e",xtwoheadrightarrow:"\u21a0",xlongequal:"=",xtofrom:"\u21c4",xrightleftarrows:"\u21c4",xrightequilibrium:"\u21cc",xleftequilibrium:"\u21cb","\\cdrightarrow":"\u2192","\\cdleftarrow":"\u2190","\\cdlongequal":"="},er={overrightarrow:[["rightarrow"],.888,522,"xMaxYMin"],overleftarrow:[["leftarrow"],.888,522,"xMinYMin"],underrightarrow:[["rightarrow"],.888,522,"xMaxYMin"],underleftarrow:[["leftarrow"],.888,522,"xMinYMin"],xrightarrow:[["rightarrow"],1.469,522,"xMaxYMin"],"\\cdrightarrow":[["rightarrow"],3,522,"xMaxYMin"],xleftarrow:[["leftarrow"],1.469,522,"xMinYMin"],"\\cdleftarrow":[["leftarrow"],3,522,"xMinYMin"],Overrightarrow:[["doublerightarrow"],.888,560,"xMaxYMin"],xRightarrow:[["doublerightarrow"],1.526,560,"xMaxYMin"],xLeftarrow:[["doubleleftarrow"],1.526,560,"xMinYMin"],overleftharpoon:[["leftharpoon"],.888,522,"xMinYMin"],xleftharpoonup:[["leftharpoon"],.888,522,"xMinYMin"],xleftharpoondown:[["leftharpoondown"],.888,522,"xMinYMin"],overrightharpoon:[["rightharpoon"],.888,522,"xMaxYMin"],xrightharpoonup:[["rightharpoon"],.888,522,"xMaxYMin"],xrightharpoondown:[["rightharpoondown"],.888,522,"xMaxYMin"],xlongequal:[["longequal"],.888,334,"xMinYMin"],"\\cdlongequal":[["longequal"],3,334,"xMinYMin"],xtwoheadleftarrow:[["twoheadleftarrow"],.888,334,"xMinYMin"],xtwoheadrightarrow:[["twoheadrightarrow"],.888,334,"xMaxYMin"],overleftrightarrow:[["leftarrow","rightarrow"],.888,522],overbrace:[["leftbrace","midbrace","rightbrace"],1.6,548],underbrace:[["leftbraceunder","midbraceunder","rightbraceunder"],1.6,548],underleftrightarrow:[["leftarrow","rightarrow"],.888,522],xleftrightarrow:[["leftarrow","rightarrow"],1.75,522],xLeftrightarrow:[["doubleleftarrow","doublerightarrow"],1.75,560],xrightleftharpoons:[["leftharpoondownplus","rightharpoonplus"],1.75,716],xleftrightharpoons:[["leftharpoonplus","rightharpoondownplus"],1.75,716],xhookleftarrow:[["leftarrow","righthook"],1.08,522],xhookrightarrow:[["lefthook","rightarrow"],1.08,522],overlinesegment:[["leftlinesegment","rightlinesegment"],.888,522],underlinesegment:[["leftlinesegment","rightlinesegment"],.888,522],overgroup:[["leftgroup","rightgroup"],.888,342],undergroup:[["leftgroupunder","rightgroupunder"],.888,342],xmapsto:[["leftmapsto","rightarrow"],1.5,522],xtofrom:[["leftToFrom","rightToFrom"],1.75,528],xrightleftarrows:[["baraboveleftarrow","rightarrowabovebar"],1.75,901],xrightequilibrium:[["baraboveshortleftharpoon","rightharpoonaboveshortbar"],1.75,716],xleftequilibrium:[["shortbaraboveleftharpoon","shortrightharpoonabovebar"],1.75,716]},tr=function(e,t,r,n,i){var a,o=e.height+e.depth+r+n;if(/fbox|color|angl/.test(t)){if(a=yt.makeSpan(["stretchy",t],[],i),"fbox"===t){var s=i.color&&i.getColor();s&&(a.style.borderColor=s)}}else{var l=[];/^[bx]cancel$/.test(t)&&l.push(new ke({x1:"0",y1:"0",x2:"100%",y2:"100%","stroke-width":"0.046em"})),/^x?cancel$/.test(t)&&l.push(new ke({x1:"0",y1:"100%",x2:"100%",y2:"0","stroke-width":"0.046em"}));var c=new we(l,{width:"100%",height:ue(o)});a=yt.makeSvgSpan([],[c],i)}return a.height=o,a.style.height=ue(o),a},rr=function(e){var t=new Ut.MathNode("mo",[new Ut.TextNode(Qt[e.replace(/^\\/,"")])]);return t.setAttribute("stretchy","true"),t},nr=function(e,t){var r=function(){var r=4e5,n=e.label.slice(1);if(B.contains(["widehat","widecheck","widetilde","utilde"],n)){var i,a,o,s="ordgroup"===(p=e.base).type?p.body.length:1;if(s>5)"widehat"===n||"widecheck"===n?(i=420,r=2364,o=.42,a=n+"4"):(i=312,r=2340,o=.34,a="tilde4");else{var l=[1,1,2,2,3,3][s];"widehat"===n||"widecheck"===n?(r=[0,1062,2364,2364,2364][l],i=[0,239,300,360,420][l],o=[0,.24,.3,.3,.36,.42][l],a=n+l):(r=[0,600,1033,2339,2340][l],i=[0,260,286,306,312][l],o=[0,.26,.286,.3,.306,.34][l],a="tilde"+l)}var c=new xe(a),u=new we([c],{width:"100%",height:ue(o),viewBox:"0 0 "+r+" "+i,preserveAspectRatio:"none"});return{span:yt.makeSvgSpan([],[u],t),minWidth:0,height:o}}var h,d,p,m=[],f=er[n],g=f[0],v=f[1],y=f[2],b=y/1e3,w=g.length;if(1===w)h=["hide-tail"],d=[f[3]];else if(2===w)h=["halfarrow-left","halfarrow-right"],d=["xMinYMin","xMaxYMin"];else{if(3!==w)throw new Error("Correct katexImagesData or update code here to support\n "+w+" children.");h=["brace-left","brace-center","brace-right"],d=["xMinYMin","xMidYMin","xMaxYMin"]}for(var x=0;x0&&(n.style.minWidth=ue(i)),n},ir=function(e,t){var r,i,a;e&&"supsub"===e.type?(r=(i=h(e.base,"accent")).base,e.base=r,a=function(e){if(e instanceof fe)return e;throw new Error("Expected span but got "+String(e)+".")}(Ft(e,t)),e.base=i):r=(i=h(e,"accent")).base;var o=Ft(r,t.havingCrampedStyle()),s=0;if(i.isShifty&&B.isCharacterBox(r)){var l=B.getBaseElem(r);s=n(Ft(l,t.havingCrampedStyle())).skew}var c,u="\\c"===i.label,d=u?o.height+o.depth:Math.min(o.height,t.fontMetrics().xHeight);if(i.isStretchy)c=nr(i,t),c=yt.makeVList({positionType:"firstBaseline",children:[{type:"elem",elem:o},{type:"elem",elem:c,wrapperClasses:["svg-align"],wrapperStyle:s>0?{width:"calc(100% - "+ue(2*s)+")",marginLeft:ue(2*s)}:void 0}]},t);else{var p,m;"\\vec"===i.label?(p=yt.staticSvg("vec",t),m=yt.svgData.vec[1]):((p=n(p=yt.makeOrd({mode:i.mode,text:i.label},t,"textord"))).italic=0,m=p.width,u&&(d+=p.depth)),c=yt.makeSpan(["accent-body"],[p]);var f="\\textcircled"===i.label;f&&(c.classes.push("accent-full"),d=o.height);var g=s;f||(g-=m/2),c.style.left=ue(g),"\\textcircled"===i.label&&(c.style.top=".2em"),c=yt.makeVList({positionType:"firstBaseline",children:[{type:"elem",elem:o},{type:"kern",size:-d},{type:"elem",elem:c}]},t)}var v=yt.makeSpan(["mord","accent"],[c],t);return a?(a.children[0]=v,a.height=Math.max(v.height,a.height),a.classes[0]="mord",a):v},ar=function(e,t){var r=e.isStretchy?rr(e.label):new Ut.MathNode("mo",[_t(e.label,e.mode)]),n=new Ut.MathNode("mover",[$t(e.base,t),r]);return n.setAttribute("accent","true"),n},or=new RegExp(["\\acute","\\grave","\\ddot","\\tilde","\\bar","\\breve","\\check","\\hat","\\vec","\\dot","\\mathring"].map(function(e){return"\\"+e}).join("|"));a({type:"accent",names:["\\acute","\\grave","\\ddot","\\tilde","\\bar","\\breve","\\check","\\hat","\\vec","\\dot","\\mathring","\\widecheck","\\widehat","\\widetilde","\\overrightarrow","\\overleftarrow","\\Overrightarrow","\\overleftrightarrow","\\overgroup","\\overlinesegment","\\overleftharpoon","\\overrightharpoon"],props:{numArgs:1},handler:function(e,t){var r=Tt(t[0]),n=!or.test(e.funcName),i=!n||"\\widehat"===e.funcName||"\\widetilde"===e.funcName||"\\widecheck"===e.funcName;return{type:"accent",mode:e.parser.mode,label:e.funcName,isStretchy:n,isShifty:i,base:r}},htmlBuilder:ir,mathmlBuilder:ar}),a({type:"accent",names:["\\'","\\`","\\^","\\~","\\=","\\u","\\.",'\\"',"\\c","\\r","\\H","\\v","\\textcircled"],props:{numArgs:1,allowedInText:!0,allowedInMath:!0,argTypes:["primitive"]},handler:function(e,t){var r=t[0],n=e.parser.mode;return"math"===n&&(e.parser.settings.reportNonstrict("mathVsTextAccents","LaTeX's accent "+e.funcName+" works only in text mode"),n="text"),{type:"accent",mode:n,label:e.funcName,isStretchy:!1,isShifty:!0,base:r}},htmlBuilder:ir,mathmlBuilder:ar}),a({type:"accentUnder",names:["\\underleftarrow","\\underrightarrow","\\underleftrightarrow","\\undergroup","\\underlinesegment","\\utilde"],props:{numArgs:1},handler:function(e,t){var r=e.parser,n=e.funcName,i=t[0];return{type:"accentUnder",mode:r.mode,label:n,base:i}},htmlBuilder:function(e,t){var r=Ft(e.base,t),n=nr(e,t),i="\\utilde"===e.label?.12:0,a=yt.makeVList({positionType:"top",positionData:r.height,children:[{type:"elem",elem:n,wrapperClasses:["svg-align"]},{type:"kern",size:i},{type:"elem",elem:r}]},t);return yt.makeSpan(["mord","accentunder"],[a],t)},mathmlBuilder:function(e,t){var r=rr(e.label),n=new Ut.MathNode("munder",[$t(e.base,t),r]);return n.setAttribute("accentunder","true"),n}});var sr=function(e){var t=new Ut.MathNode("mpadded",e?[e]:[]);return t.setAttribute("width","+0.6em"),t.setAttribute("lspace","0.3em"),t};a({type:"xArrow",names:["\\xleftarrow","\\xrightarrow","\\xLeftarrow","\\xRightarrow","\\xleftrightarrow","\\xLeftrightarrow","\\xhookleftarrow","\\xhookrightarrow","\\xmapsto","\\xrightharpoondown","\\xrightharpoonup","\\xleftharpoondown","\\xleftharpoonup","\\xrightleftharpoons","\\xleftrightharpoons","\\xlongequal","\\xtwoheadrightarrow","\\xtwoheadleftarrow","\\xtofrom","\\xrightleftarrows","\\xrightequilibrium","\\xleftequilibrium","\\\\cdrightarrow","\\\\cdleftarrow","\\\\cdlongequal"],props:{numArgs:1,numOptionalArgs:1},handler:function(e,t,r){var n=e.parser,i=e.funcName;return{type:"xArrow",mode:n.mode,label:i,body:t[0],below:r[0]}},htmlBuilder:function(e,t){var r,n=t.style,i=t.havingStyle(n.sup()),a=yt.wrapFragment(Ft(e.body,i,t),t),o="\\x"===e.label.slice(0,2)?"x":"cd";a.classes.push(o+"-arrow-pad"),e.below&&(i=t.havingStyle(n.sub()),(r=yt.wrapFragment(Ft(e.below,i,t),t)).classes.push(o+"-arrow-pad"));var s,l=nr(e,t),c=-t.fontMetrics().axisHeight+.5*l.height,u=-t.fontMetrics().axisHeight-.5*l.height-.111;if((a.depth>.25||"\\xleftequilibrium"===e.label)&&(u-=a.depth),r){var h=-t.fontMetrics().axisHeight+r.height+.5*l.height+.111;s=yt.makeVList({positionType:"individualShift",children:[{type:"elem",elem:a,shift:u},{type:"elem",elem:l,shift:c},{type:"elem",elem:r,shift:h}]},t)}else s=yt.makeVList({positionType:"individualShift",children:[{type:"elem",elem:a,shift:u},{type:"elem",elem:l,shift:c}]},t);return s.children[0].children[0].children[1].classes.push("svg-align"),yt.makeSpan(["mrel","x-arrow"],[s],t)},mathmlBuilder:function(e,t){var r,n=rr(e.label);if(n.setAttribute("minsize","x"===e.label.charAt(0)?"1.75em":"3.0em"),e.body){var i=sr($t(e.body,t));if(e.below){var a=sr($t(e.below,t));r=new Ut.MathNode("munderover",[n,a,i])}else r=new Ut.MathNode("mover",[n,i])}else if(e.below){var o=sr($t(e.below,t));r=new Ut.MathNode("munder",[n,o])}else r=sr(),r=new Ut.MathNode("mover",[n,r]);return r}});var lr=yt.makeSpan;a({type:"mclass",names:["\\mathord","\\mathbin","\\mathrel","\\mathopen","\\mathclose","\\mathpunct","\\mathinner"],props:{numArgs:1,primitive:!0},handler:function(e,t){var r=e.parser,n=e.funcName,i=t[0];return{type:"mclass",mode:r.mode,mclass:"m"+n.slice(5),body:Rt(i),isCharacterBox:B.isCharacterBox(i)}},htmlBuilder:m,mathmlBuilder:f});var cr=function(e){var t="ordgroup"===e.type&&e.body.length?e.body[0]:e;return"atom"!==t.type||"bin"!==t.family&&"rel"!==t.family?"mord":"m"+t.family};a({type:"mclass",names:["\\@binrel"],props:{numArgs:2},handler:function(e,t){return{type:"mclass",mode:e.parser.mode,mclass:cr(t[0]),body:Rt(t[1]),isCharacterBox:B.isCharacterBox(t[1])}}}),a({type:"mclass",names:["\\stackrel","\\overset","\\underset"],props:{numArgs:2},handler:function(e,t){var r,n=e.parser,i=e.funcName,a=t[1],o=t[0];r="\\stackrel"!==i?cr(a):"mrel";var s={type:"op",mode:a.mode,limits:!0,alwaysHandleSupSub:!0,parentIsSupSub:!1,symbol:!1,suppressBaseShift:"\\stackrel"!==i,body:Rt(a)},l={type:"supsub",mode:o.mode,base:s,sup:"\\underset"===i?null:o,sub:"\\underset"===i?o:null};return{type:"mclass",mode:n.mode,mclass:r,body:[l],isCharacterBox:B.isCharacterBox(l)}},htmlBuilder:m,mathmlBuilder:f}),a({type:"pmb",names:["\\pmb"],props:{numArgs:1,allowedInText:!0},handler:function(e,t){return{type:"pmb",mode:e.parser.mode,mclass:cr(t[0]),body:Rt(t[0])}},htmlBuilder:function(e,t){var r=Bt(e.body,t,!0),n=yt.makeSpan([e.mclass],r,t);return n.style.textShadow="0.02em 0.01em 0.04px",n},mathmlBuilder:function(e,t){var r=Yt(e.body,t),n=new Ut.MathNode("mstyle",r);return n.setAttribute("style","text-shadow: 0.02em 0.01em 0.04px"),n}});var ur={">":"\\\\cdrightarrow","<":"\\\\cdleftarrow","=":"\\\\cdlongequal",A:"\\uparrow",V:"\\downarrow","|":"\\Vert",".":"no arrow"},hr=function(e){return"textord"===e.type&&"@"===e.text};a({type:"cdlabel",names:["\\\\cdleft","\\\\cdright"],props:{numArgs:1},handler:function(e,t){var r=e.parser,n=e.funcName;return{type:"cdlabel",mode:r.mode,side:n.slice(4),label:t[0]}},htmlBuilder:function(e,t){var r=t.havingStyle(t.style.sup()),n=yt.wrapFragment(Ft(e.label,r,t),t);return n.classes.push("cd-label-"+e.side),n.style.bottom=ue(.8-n.depth),n.height=0,n.depth=0,n},mathmlBuilder:function(e,t){var r=new Ut.MathNode("mrow",[$t(e.label,t)]);return(r=new Ut.MathNode("mpadded",[r])).setAttribute("width","0"),"left"===e.side&&r.setAttribute("lspace","-1width"),r.setAttribute("voffset","0.7em"),(r=new Ut.MathNode("mstyle",[r])).setAttribute("displaystyle","false"),r.setAttribute("scriptlevel","1"),r}}),a({type:"cdlabelparent",names:["\\\\cdparent"],props:{numArgs:1},handler:function(e,t){return{type:"cdlabelparent",mode:e.parser.mode,fragment:t[0]}},htmlBuilder:function(e,t){var r=yt.wrapFragment(Ft(e.fragment,t),t);return r.classes.push("cd-vert-arrow"),r},mathmlBuilder:function(e,t){return new Ut.MathNode("mrow",[$t(e.fragment,t)])}}),a({type:"textord",names:["\\@char"],props:{numArgs:1,allowedInText:!0},handler:function(e,t){for(var r=e.parser,n=h(t[0],"ordgroup").body,i="",a=0;a=1114111)throw new C("\\@char with invalid code point "+i);return s<=65535?o=String.fromCharCode(s):(s-=65536,o=String.fromCharCode(55296+(s>>10),56320+(1023&s))),{type:"textord",mode:r.mode,text:o}}});var dr=function(e,t){var r=Bt(e.body,t.withColor(e.color),!1);return yt.makeFragment(r)},pr=function(e,t){var r=Yt(e.body,t.withColor(e.color)),n=new Ut.MathNode("mstyle",r);return n.setAttribute("mathcolor",e.color),n};a({type:"color",names:["\\textcolor"],props:{numArgs:2,allowedInText:!0,argTypes:["color","original"]},handler:function(e,t){var r=e.parser,n=h(t[0],"color-token").color,i=t[1];return{type:"color",mode:r.mode,color:n,body:Rt(i)}},htmlBuilder:dr,mathmlBuilder:pr}),a({type:"color",names:["\\color"],props:{numArgs:1,allowedInText:!0,argTypes:["color"]},handler:function(e,t){var r=e.parser,n=e.breakOnTokenText,i=h(t[0],"color-token").color;r.gullet.macros.set("\\current@color",i);var a=r.parseExpression(!0,n);return{type:"color",mode:r.mode,color:i,body:a}},htmlBuilder:dr,mathmlBuilder:pr}),a({type:"cr",names:["\\\\"],props:{numArgs:0,numOptionalArgs:0,allowedInText:!0},handler:function(e){var t=e.parser,r="["===t.gullet.future().text?t.parseSizeGroup(!0):null,n=!t.settings.displayMode||!t.settings.useStrictBehavior("newLineInDisplayMode","In LaTeX, \\\\ or \\newline does nothing in display mode");return{type:"cr",mode:t.mode,newLine:n,size:r&&h(r,"size").value}},htmlBuilder:function(e,t){var r=yt.makeSpan(["mspace"],[],t);return e.newLine&&(r.classes.push("newline"),e.size&&(r.style.marginTop=ue(ce(e.size,t)))),r},mathmlBuilder:function(e,t){var r=new Ut.MathNode("mspace");return e.newLine&&(r.setAttribute("linebreak","newline"),e.size&&r.setAttribute("height",ue(ce(e.size,t)))),r}});var mr={"\\global":"\\global","\\long":"\\\\globallong","\\\\globallong":"\\\\globallong","\\def":"\\gdef","\\gdef":"\\gdef","\\edef":"\\xdef","\\xdef":"\\xdef","\\let":"\\\\globallet","\\futurelet":"\\\\globalfuture"},fr=function(e){var t=e.text;if(/^(?:[\\{}$&#^_]|EOF)$/.test(t))throw new C("Expected a control sequence",e);return t},gr=function(e,t,r,n){var i=e.gullet.macros.get(r.text);null==i&&(r.noexpand=!0,i={tokens:[r],numArgs:0,unexpandable:!e.gullet.isExpandable(r.text)}),e.gullet.macros.set(t,i,n)};a({type:"internal",names:["\\global","\\long","\\\\globallong"],props:{numArgs:0,allowedInText:!0},handler:function(e){var t=e.parser,r=e.funcName;t.consumeSpaces();var n=t.fetch();if(mr[n.text])return"\\global"!==r&&"\\\\globallong"!==r||(n.text=mr[n.text]),h(t.parseFunction(),"internal");throw new C("Invalid token after macro prefix",n)}}),a({type:"internal",names:["\\def","\\gdef","\\edef","\\xdef"],props:{numArgs:0,allowedInText:!0,primitive:!0},handler:function(e){var t=e.parser,r=e.funcName,n=t.gullet.popToken(),i=n.text;if(/^(?:[\\{}$&#^_]|EOF)$/.test(i))throw new C("Expected a control sequence",n);for(var a,o=0,s=[[]];"{"!==t.gullet.future().text;)if("#"===(n=t.gullet.popToken()).text){if("{"===t.gullet.future().text){a=t.gullet.future(),s[o].push("{");break}if(n=t.gullet.popToken(),!/^[1-9]$/.test(n.text))throw new C('Invalid argument number "'+n.text+'"');if(parseInt(n.text)!==o+1)throw new C('Argument number "'+n.text+'" out of order');o++,s.push([])}else{if("EOF"===n.text)throw new C("Expected a macro definition");s[o].push(n.text)}var l=t.gullet.consumeArg().tokens;return a&&l.unshift(a),"\\edef"!==r&&"\\xdef"!==r||(l=t.gullet.expandTokens(l)).reverse(),t.gullet.macros.set(i,{tokens:l,numArgs:o,delimiters:s},r===mr[r]),{type:"internal",mode:t.mode}}}),a({type:"internal",names:["\\let","\\\\globallet"],props:{numArgs:0,allowedInText:!0,primitive:!0},handler:function(e){var t=e.parser,r=e.funcName,n=fr(t.gullet.popToken());t.gullet.consumeSpaces();var i=function(e){var t=e.gullet.popToken();return"="===t.text&&" "===(t=e.gullet.popToken()).text&&(t=e.gullet.popToken()),t}(t);return gr(t,n,i,"\\\\globallet"===r),{type:"internal",mode:t.mode}}}),a({type:"internal",names:["\\futurelet","\\\\globalfuture"],props:{numArgs:0,allowedInText:!0,primitive:!0},handler:function(e){var t=e.parser,r=e.funcName,n=fr(t.gullet.popToken()),i=t.gullet.popToken(),a=t.gullet.popToken();return gr(t,n,a,"\\\\globalfuture"===r),t.gullet.pushToken(a),t.gullet.pushToken(i),{type:"internal",mode:t.mode}}});var vr=function(e,t,n){var i=r(Me.math[e]&&Me.math[e].replace||e,t,n);if(!i)throw new Error("Unsupported symbol "+e+" and font size "+t+".");return i},yr=function(e,t,r,n){var i=r.havingBaseStyle(t),a=yt.makeSpan(n.concat(i.sizingClasses(r)),[e],r),o=i.sizeMultiplier/r.sizeMultiplier;return a.height*=o,a.depth*=o,a.maxFontSize=i.sizeMultiplier,a},br=function(e,t,r){var n=t.havingBaseStyle(r),i=(1-t.sizeMultiplier/n.sizeMultiplier)*t.fontMetrics().axisHeight;e.classes.push("delimcenter"),e.style.top=ue(i),e.height-=i,e.depth+=i},wr=function(e,t,r,n,i,a){var o=function(e,t,r,n){return yt.makeSymbol(e,"Size"+t+"-Regular",r,n)}(e,t,i,n),s=yr(yt.makeSpan(["delimsizing","size"+t],[o],n),W.TEXT,n,a);return r&&br(s,n,W.TEXT),s},xr=function(e,t,r){var n;return n="Size1-Regular"===t?"delim-size1":"delim-size4",{type:"elem",elem:yt.makeSpan(["delimsizinginner",n],[yt.makeSpan([],[yt.makeSymbol(e,t,r)])])}},kr=function(e,t,r){var n=J["Size4-Regular"][e.charCodeAt(0)]?J["Size4-Regular"][e.charCodeAt(0)][4]:J["Size1-Regular"][e.charCodeAt(0)][4],i=new xe("inner",function(e,t){switch(e){case"\u239c":return"M291 0 H417 V"+t+" H291z M291 0 H417 V"+t+" H291z";case"\u2223":return"M145 0 H188 V"+t+" H145z M145 0 H188 V"+t+" H145z";case"\u2225":return"M145 0 H188 V"+t+" H145z M145 0 H188 V"+t+" H145zM367 0 H410 V"+t+" H367z M367 0 H410 V"+t+" H367z";case"\u239f":return"M457 0 H583 V"+t+" H457z M457 0 H583 V"+t+" H457z";case"\u23a2":return"M319 0 H403 V"+t+" H319z M319 0 H403 V"+t+" H319z";case"\u23a5":return"M263 0 H347 V"+t+" H263z M263 0 H347 V"+t+" H263z";case"\u23aa":return"M384 0 H504 V"+t+" H384z M384 0 H504 V"+t+" H384z";case"\u23d0":return"M312 0 H355 V"+t+" H312z M312 0 H355 V"+t+" H312z";case"\u2016":return"M257 0 H300 V"+t+" H257z M257 0 H300 V"+t+" H257zM478 0 H521 V"+t+" H478z M478 0 H521 V"+t+" H478z";default:return""}}(e,Math.round(1e3*t))),a=new we([i],{width:ue(n),height:ue(t),style:"width:"+ue(n),viewBox:"0 0 "+1e3*n+" "+Math.round(1e3*t),preserveAspectRatio:"xMinYMin"}),o=yt.makeSvgSpan([],[a],r);return o.height=t,o.style.height=ue(t),o.style.width=ue(n),{type:"elem",elem:o}},Sr={type:"kern",size:-.008},Ar=["|","\\lvert","\\rvert","\\vert"],Er=["\\|","\\lVert","\\rVert","\\Vert"],Mr=function(e,t,r,n,i,a){var o,s,l,c,u="",h=0;o=l=c=e,s=null;var d="Size1-Regular";"\\uparrow"===e?l=c="\u23d0":"\\Uparrow"===e?l=c="\u2016":"\\downarrow"===e?o=l="\u23d0":"\\Downarrow"===e?o=l="\u2016":"\\updownarrow"===e?(o="\\uparrow",l="\u23d0",c="\\downarrow"):"\\Updownarrow"===e?(o="\\Uparrow",l="\u2016",c="\\Downarrow"):B.contains(Ar,e)?(l="\u2223",u="vert",h=333):B.contains(Er,e)?(l="\u2225",u="doublevert",h=556):"["===e||"\\lbrack"===e?(o="\u23a1",l="\u23a2",c="\u23a3",d="Size4-Regular",u="lbrack",h=667):"]"===e||"\\rbrack"===e?(o="\u23a4",l="\u23a5",c="\u23a6",d="Size4-Regular",u="rbrack",h=667):"\\lfloor"===e||"\u230a"===e?(l=o="\u23a2",c="\u23a3",d="Size4-Regular",u="lfloor",h=667):"\\lceil"===e||"\u2308"===e?(o="\u23a1",l=c="\u23a2",d="Size4-Regular",u="lceil",h=667):"\\rfloor"===e||"\u230b"===e?(l=o="\u23a5",c="\u23a6",d="Size4-Regular",u="rfloor",h=667):"\\rceil"===e||"\u2309"===e?(o="\u23a4",l=c="\u23a5",d="Size4-Regular",u="rceil",h=667):"("===e||"\\lparen"===e?(o="\u239b",l="\u239c",c="\u239d",d="Size4-Regular",u="lparen",h=875):")"===e||"\\rparen"===e?(o="\u239e",l="\u239f",c="\u23a0",d="Size4-Regular",u="rparen",h=875):"\\{"===e||"\\lbrace"===e?(o="\u23a7",s="\u23a8",c="\u23a9",l="\u23aa",d="Size4-Regular"):"\\}"===e||"\\rbrace"===e?(o="\u23ab",s="\u23ac",c="\u23ad",l="\u23aa",d="Size4-Regular"):"\\lgroup"===e||"\u27ee"===e?(o="\u23a7",c="\u23a9",l="\u23aa",d="Size4-Regular"):"\\rgroup"===e||"\u27ef"===e?(o="\u23ab",c="\u23ad",l="\u23aa",d="Size4-Regular"):"\\lmoustache"===e||"\u23b0"===e?(o="\u23a7",c="\u23ad",l="\u23aa",d="Size4-Regular"):"\\rmoustache"!==e&&"\u23b1"!==e||(o="\u23ab",c="\u23a9",l="\u23aa",d="Size4-Regular");var p=vr(o,d,i),m=p.height+p.depth,f=vr(l,d,i),g=f.height+f.depth,v=vr(c,d,i),y=v.height+v.depth,b=0,w=1;if(null!==s){var x=vr(s,d,i);b=x.height+x.depth,w=2}var k=m+y+b,S=k+Math.max(0,Math.ceil((t-k)/(w*g)))*w*g,A=n.fontMetrics().axisHeight;r&&(A*=n.sizeMultiplier);var E=S/2-A,M=[];if(u.length>0){var T=S-m-y,R=Math.round(1e3*S),C=function(e,t){switch(e){case"lbrack":return"M403 1759 V84 H666 V0 H319 V1759 v"+t+" v1759 h347 v-84\nH403z M403 1759 V0 H319 V1759 v"+t+" v1759 h84z";case"rbrack":return"M347 1759 V0 H0 V84 H263 V1759 v"+t+" v1759 H0 v84 H347z\nM347 1759 V0 H263 V1759 v"+t+" v1759 h84z";case"vert":return"M145 15 v585 v"+t+" v585 c2.667,10,9.667,15,21,15\nc10,0,16.667,-5,20,-15 v-585 v"+-t+" v-585 c-2.667,-10,-9.667,-15,-21,-15\nc-10,0,-16.667,5,-20,15z M188 15 H145 v585 v"+t+" v585 h43z";case"doublevert":return"M145 15 v585 v"+t+" v585 c2.667,10,9.667,15,21,15\nc10,0,16.667,-5,20,-15 v-585 v"+-t+" v-585 c-2.667,-10,-9.667,-15,-21,-15\nc-10,0,-16.667,5,-20,15z M188 15 H145 v585 v"+t+" v585 h43z\nM367 15 v585 v"+t+" v585 c2.667,10,9.667,15,21,15\nc10,0,16.667,-5,20,-15 v-585 v"+-t+" v-585 c-2.667,-10,-9.667,-15,-21,-15\nc-10,0,-16.667,5,-20,15z M410 15 H367 v585 v"+t+" v585 h43z";case"lfloor":return"M319 602 V0 H403 V602 v"+t+" v1715 h263 v84 H319z\nMM319 602 V0 H403 V602 v"+t+" v1715 H319z";case"rfloor":return"M319 602 V0 H403 V602 v"+t+" v1799 H0 v-84 H319z\nMM319 602 V0 H403 V602 v"+t+" v1715 H319z";case"lceil":return"M403 1759 V84 H666 V0 H319 V1759 v"+t+" v602 h84z\nM403 1759 V0 H319 V1759 v"+t+" v602 h84z";case"rceil":return"M347 1759 V0 H0 V84 H263 V1759 v"+t+" v602 h84z\nM347 1759 V0 h-84 V1759 v"+t+" v602 h84z";case"lparen":return"M863,9c0,-2,-2,-5,-6,-9c0,0,-17,0,-17,0c-12.7,0,-19.3,0.3,-20,1\nc-5.3,5.3,-10.3,11,-15,17c-242.7,294.7,-395.3,682,-458,1162c-21.3,163.3,-33.3,349,\n-36,557 l0,"+(t+84)+"c0.2,6,0,26,0,60c2,159.3,10,310.7,24,454c53.3,528,210,\n949.7,470,1265c4.7,6,9.7,11.7,15,17c0.7,0.7,7,1,19,1c0,0,18,0,18,0c4,-4,6,-7,6,-9\nc0,-2.7,-3.3,-8.7,-10,-18c-135.3,-192.7,-235.5,-414.3,-300.5,-665c-65,-250.7,-102.5,\n-544.7,-112.5,-882c-2,-104,-3,-167,-3,-189\nl0,-"+(t+92)+"c0,-162.7,5.7,-314,17,-454c20.7,-272,63.7,-513,129,-723c65.3,\n-210,155.3,-396.3,270,-559c6.7,-9.3,10,-15.3,10,-18z";case"rparen":return"M76,0c-16.7,0,-25,3,-25,9c0,2,2,6.3,6,13c21.3,28.7,42.3,60.3,\n63,95c96.7,156.7,172.8,332.5,228.5,527.5c55.7,195,92.8,416.5,111.5,664.5\nc11.3,139.3,17,290.7,17,454c0,28,1.7,43,3.3,45l0,"+(t+9)+"\nc-3,4,-3.3,16.7,-3.3,38c0,162,-5.7,313.7,-17,455c-18.7,248,-55.8,469.3,-111.5,664\nc-55.7,194.7,-131.8,370.3,-228.5,527c-20.7,34.7,-41.7,66.3,-63,95c-2,3.3,-4,7,-6,11\nc0,7.3,5.7,11,17,11c0,0,11,0,11,0c9.3,0,14.3,-0.3,15,-1c5.3,-5.3,10.3,-11,15,-17\nc242.7,-294.7,395.3,-681.7,458,-1161c21.3,-164.7,33.3,-350.7,36,-558\nl0,-"+(t+144)+"c-2,-159.3,-10,-310.7,-24,-454c-53.3,-528,-210,-949.7,\n-470,-1265c-4.7,-6,-9.7,-11.7,-15,-17c-0.7,-0.7,-6.7,-1,-18,-1z";default:throw new Error("Unknown stretchy delimiter.")} +}(u,Math.round(1e3*T)),L=new xe(u,C),z=(h/1e3).toFixed(3)+"em",N=(R/1e3).toFixed(3)+"em",I=new we([L],{width:z,height:N,viewBox:"0 0 "+h+" "+R}),P=yt.makeSvgSpan([],[I],n);P.height=R/1e3,P.style.width=z,P.style.height=N,M.push({type:"elem",elem:P})}else{if(M.push(xr(c,d,i)),M.push(Sr),null===s){var O=S-m-y+.016;M.push(kr(l,O,n))}else{var q=(S-m-y-b)/2+.016;M.push(kr(l,q,n)),M.push(Sr),M.push(xr(s,d,i)),M.push(Sr),M.push(kr(l,q,n))}M.push(Sr),M.push(xr(o,d,i))}var D=n.havingBaseStyle(W.TEXT),H=yt.makeVList({positionType:"bottom",positionData:E,children:M},D);return yr(yt.makeSpan(["delimsizing","mult"],[H],D),W.TEXT,n,a)},Tr=.08,Rr=function(e,t,r,n,i){var a=function(e,t,r){t*=1e3;var n="";switch(e){case"sqrtMain":n=function(e){return"M95,"+(622+e+X)+"\nc-2.7,0,-7.17,-2.7,-13.5,-8c-5.8,-5.3,-9.5,-10,-9.5,-14\nc0,-2,0.3,-3.3,1,-4c1.3,-2.7,23.83,-20.7,67.5,-54\nc44.2,-33.3,65.8,-50.3,66.5,-51c1.3,-1.3,3,-2,5,-2c4.7,0,8.7,3.3,12,10\ns173,378,173,378c0.7,0,35.3,-71,104,-213c68.7,-142,137.5,-285,206.5,-429\nc69,-144,104.5,-217.7,106.5,-221\nl"+e/2.075+" -"+e+"\nc5.3,-9.3,12,-14,20,-14\nH400000v"+(40+e)+"H845.2724\ns-225.272,467,-225.272,467s-235,486,-235,486c-2.7,4.7,-9,7,-19,7\nc-6,0,-10,-1,-12,-3s-194,-422,-194,-422s-65,47,-65,47z\nM"+(834+e)+" "+X+"h400000v"+(40+e)+"h-400000z"}(t);break;case"sqrtSize1":n=function(e){return"M263,"+(601+e+X)+"c0.7,0,18,39.7,52,119\nc34,79.3,68.167,158.7,102.5,238c34.3,79.3,51.8,119.3,52.5,120\nc340,-704.7,510.7,-1060.3,512,-1067\nl"+e/2.084+" -"+e+"\nc4.7,-7.3,11,-11,19,-11\nH40000v"+(40+e)+"H1012.3\ns-271.3,567,-271.3,567c-38.7,80.7,-84,175,-136,283c-52,108,-89.167,185.3,-111.5,232\nc-22.3,46.7,-33.8,70.3,-34.5,71c-4.7,4.7,-12.3,7,-23,7s-12,-1,-12,-1\ns-109,-253,-109,-253c-72.7,-168,-109.3,-252,-110,-252c-10.7,8,-22,16.7,-34,26\nc-22,17.3,-33.3,26,-34,26s-26,-26,-26,-26s76,-59,76,-59s76,-60,76,-60z\nM"+(1001+e)+" "+X+"h400000v"+(40+e)+"h-400000z"}(t);break;case"sqrtSize2":n=function(e){return"M983 "+(10+e+X)+"\nl"+e/3.13+" -"+e+"\nc4,-6.7,10,-10,18,-10 H400000v"+(40+e)+"\nH1013.1s-83.4,268,-264.1,840c-180.7,572,-277,876.3,-289,913c-4.7,4.7,-12.7,7,-24,7\ns-12,0,-12,0c-1.3,-3.3,-3.7,-11.7,-7,-25c-35.3,-125.3,-106.7,-373.3,-214,-744\nc-10,12,-21,25,-33,39s-32,39,-32,39c-6,-5.3,-15,-14,-27,-26s25,-30,25,-30\nc26.7,-32.7,52,-63,76,-91s52,-60,52,-60s208,722,208,722\nc56,-175.3,126.3,-397.3,211,-666c84.7,-268.7,153.8,-488.2,207.5,-658.5\nc53.7,-170.3,84.5,-266.8,92.5,-289.5z\nM"+(1001+e)+" "+X+"h400000v"+(40+e)+"h-400000z"}(t);break;case"sqrtSize3":n=function(e){return"M424,"+(2398+e+X)+"\nc-1.3,-0.7,-38.5,-172,-111.5,-514c-73,-342,-109.8,-513.3,-110.5,-514\nc0,-2,-10.7,14.3,-32,49c-4.7,7.3,-9.8,15.7,-15.5,25c-5.7,9.3,-9.8,16,-12.5,20\ns-5,7,-5,7c-4,-3.3,-8.3,-7.7,-13,-13s-13,-13,-13,-13s76,-122,76,-122s77,-121,77,-121\ns209,968,209,968c0,-2,84.7,-361.7,254,-1079c169.3,-717.3,254.7,-1077.7,256,-1081\nl"+e/4.223+" -"+e+"c4,-6.7,10,-10,18,-10 H400000\nv"+(40+e)+"H1014.6\ns-87.3,378.7,-272.6,1166c-185.3,787.3,-279.3,1182.3,-282,1185\nc-2,6,-10,9,-24,9\nc-8,0,-12,-0.7,-12,-2z M"+(1001+e)+" "+X+"\nh400000v"+(40+e)+"h-400000z"}(t);break;case"sqrtSize4":n=function(e){return"M473,"+(2713+e+X)+"\nc339.3,-1799.3,509.3,-2700,510,-2702 l"+e/5.298+" -"+e+"\nc3.3,-7.3,9.3,-11,18,-11 H400000v"+(40+e)+"H1017.7\ns-90.5,478,-276.2,1466c-185.7,988,-279.5,1483,-281.5,1485c-2,6,-10,9,-24,9\nc-8,0,-12,-0.7,-12,-2c0,-1.3,-5.3,-32,-16,-92c-50.7,-293.3,-119.7,-693.3,-207,-1200\nc0,-1.3,-5.3,8.7,-16,30c-10.7,21.3,-21.3,42.7,-32,64s-16,33,-16,33s-26,-26,-26,-26\ns76,-153,76,-153s77,-151,77,-151c0.7,0.7,35.7,202,105,604c67.3,400.7,102,602.7,104,\n606zM"+(1001+e)+" "+X+"h400000v"+(40+e)+"H1017.7z"}(t);break;case"sqrtTall":n=function(e,t,r){return"M702 "+(e+X)+"H400000"+(40+e)+"\nH742v"+(r-54-X-e)+"l-4 4-4 4c-.667.7 -2 1.5-4 2.5s-4.167 1.833-6.5 2.5-5.5 1-9.5 1\nh-12l-28-84c-16.667-52-96.667 -294.333-240-727l-212 -643 -85 170\nc-4-3.333-8.333-7.667-13 -13l-13-13l77-155 77-156c66 199.333 139 419.667\n219 661 l218 661zM702 "+X+"H400000v"+(40+e)+"H742z"}(t,0,r)}return n}(e,n,r),o=new xe(e,a),s=new we([o],{width:"400em",height:ue(t),viewBox:"0 0 400000 "+r,preserveAspectRatio:"xMinYMin slice"});return yt.makeSvgSpan(["hide-tail"],[s],i)},Cr=["(","\\lparen",")","\\rparen","[","\\lbrack","]","\\rbrack","\\{","\\lbrace","\\}","\\rbrace","\\lfloor","\\rfloor","\u230a","\u230b","\\lceil","\\rceil","\u2308","\u2309","\\surd"],Lr=["\\uparrow","\\downarrow","\\updownarrow","\\Uparrow","\\Downarrow","\\Updownarrow","|","\\|","\\vert","\\Vert","\\lvert","\\rvert","\\lVert","\\rVert","\\lgroup","\\rgroup","\u27ee","\u27ef","\\lmoustache","\\rmoustache","\u23b0","\u23b1"],zr=["<",">","\\langle","\\rangle","/","\\backslash","\\lt","\\gt"],Nr=[0,1.2,1.8,2.4,3],Ir=[{type:"small",style:W.SCRIPTSCRIPT},{type:"small",style:W.SCRIPT},{type:"small",style:W.TEXT},{type:"large",size:1},{type:"large",size:2},{type:"large",size:3},{type:"large",size:4}],Br=[{type:"small",style:W.SCRIPTSCRIPT},{type:"small",style:W.SCRIPT},{type:"small",style:W.TEXT},{type:"stack"}],Pr=[{type:"small",style:W.SCRIPTSCRIPT},{type:"small",style:W.SCRIPT},{type:"small",style:W.TEXT},{type:"large",size:1},{type:"large",size:2},{type:"large",size:3},{type:"large",size:4},{type:"stack"}],Or=function(e){if("small"===e.type)return"Main-Regular";if("large"===e.type)return"Size"+e.size+"-Regular";if("stack"===e.type)return"Size4-Regular";throw new Error("Add support for delim type '"+e.type+"' here.")},qr=function(e,t,r,n){for(var i=Math.min(2,3-n.style.size);it)return r[i]}return r[r.length-1]},Dr=function(e,t,r,n,i,a){var o;"<"===e||"\\lt"===e||"\u27e8"===e?e="\\langle":">"!==e&&"\\gt"!==e&&"\u27e9"!==e||(e="\\rangle"),o=B.contains(zr,e)?Ir:B.contains(Cr,e)?Pr:Br;var s=qr(e,t,o,n);return"small"===s.type?function(e,t,r,n,i,a){var o=yt.makeSymbol(e,"Main-Regular",i,n),s=yr(o,t,n,a);return r&&br(s,n,t),s}(e,s.style,r,n,i,a):"large"===s.type?wr(e,s.size,r,n,i,a):Mr(e,t,r,n,i,a)},Hr={sqrtImage:function(e,t){var r,n,i=t.havingBaseSizing(),a=qr("\\surd",e*i.sizeMultiplier,Pr,i),o=i.sizeMultiplier,s=Math.max(0,t.minRuleThickness-t.fontMetrics().sqrtRuleThickness),l=0,c=0,u=0;return"small"===a.type?(e<1?o=1:e<1.4&&(o=.7),c=(1+s)/o,(r=Rr("sqrtMain",l=(1+s+Tr)/o,u=1e3+1e3*s+80,s,t)).style.minWidth="0.853em",n=.833/o):"large"===a.type?(u=1080*Nr[a.size],c=(Nr[a.size]+s)/o,l=(Nr[a.size]+s+Tr)/o,(r=Rr("sqrtSize"+a.size,l,u,s,t)).style.minWidth="1.02em",n=1/o):(l=e+s+Tr,c=e+s,u=Math.floor(1e3*e+s)+80,(r=Rr("sqrtTall",l,u,s,t)).style.minWidth="0.742em",n=1.056),r.height=c,r.style.height=ue(l),{span:r,advanceWidth:n,ruleWidth:(t.fontMetrics().sqrtRuleThickness+s)*o}},sizedDelim:function(e,t,r,n,i){if("<"===e||"\\lt"===e||"\u27e8"===e?e="\\langle":">"!==e&&"\\gt"!==e&&"\u27e9"!==e||(e="\\rangle"),B.contains(Cr,e)||B.contains(zr,e))return wr(e,t,!1,r,n,i);if(B.contains(Lr,e))return Mr(e,Nr[t],!1,r,n,i);throw new C("Illegal delimiter: '"+e+"'")},sizeToMaxHeight:Nr,customSizedDelim:Dr,leftRightDelim:function(e,t,r,n,i,a){var o=n.fontMetrics().axisHeight*n.sizeMultiplier,s=5/n.fontMetrics().ptPerEm,l=Math.max(t-o,r+o),c=Math.max(l/500*901,2*l-s);return Dr(e,c,!0,n,i,a)}},Fr={"\\bigl":{mclass:"mopen",size:1},"\\Bigl":{mclass:"mopen",size:2},"\\biggl":{mclass:"mopen",size:3},"\\Biggl":{mclass:"mopen",size:4},"\\bigr":{mclass:"mclose",size:1},"\\Bigr":{mclass:"mclose",size:2},"\\biggr":{mclass:"mclose",size:3},"\\Biggr":{mclass:"mclose",size:4},"\\bigm":{mclass:"mrel",size:1},"\\Bigm":{mclass:"mrel",size:2},"\\biggm":{mclass:"mrel",size:3},"\\Biggm":{mclass:"mrel",size:4},"\\big":{mclass:"mord",size:1},"\\Big":{mclass:"mord",size:2},"\\bigg":{mclass:"mord",size:3},"\\Bigg":{mclass:"mord",size:4}},jr=["(","\\lparen",")","\\rparen","[","\\lbrack","]","\\rbrack","\\{","\\lbrace","\\}","\\rbrace","\\lfloor","\\rfloor","\u230a","\u230b","\\lceil","\\rceil","\u2308","\u2309","<",">","\\langle","\u27e8","\\rangle","\u27e9","\\lt","\\gt","\\lvert","\\rvert","\\lVert","\\rVert","\\lgroup","\\rgroup","\u27ee","\u27ef","\\lmoustache","\\rmoustache","\u23b0","\u23b1","/","\\backslash","|","\\vert","\\|","\\Vert","\\uparrow","\\Uparrow","\\downarrow","\\Downarrow","\\updownarrow","\\Updownarrow","."];a({type:"delimsizing",names:["\\bigl","\\Bigl","\\biggl","\\Biggl","\\bigr","\\Bigr","\\biggr","\\Biggr","\\bigm","\\Bigm","\\biggm","\\Biggm","\\big","\\Big","\\bigg","\\Bigg"],props:{numArgs:1,argTypes:["primitive"]},handler:function(e,t){var r=v(t[0],e);return{type:"delimsizing",mode:e.parser.mode,size:Fr[e.funcName].size,mclass:Fr[e.funcName].mclass,delim:r.text}},htmlBuilder:function(e,t){return"."===e.delim?yt.makeSpan([e.mclass]):Hr.sizedDelim(e.delim,e.size,t,e.mode,[e.mclass])},mathmlBuilder:function(e){var t=[];"."!==e.delim&&t.push(_t(e.delim,e.mode));var r=new Ut.MathNode("mo",t);"mopen"===e.mclass||"mclose"===e.mclass?r.setAttribute("fence","true"):r.setAttribute("fence","false"),r.setAttribute("stretchy","true");var n=ue(Hr.sizeToMaxHeight[e.size]);return r.setAttribute("minsize",n),r.setAttribute("maxsize",n),r}}),a({type:"leftright-right",names:["\\right"],props:{numArgs:1,primitive:!0},handler:function(e,t){var r=e.parser.gullet.macros.get("\\current@color");if(r&&"string"!=typeof r)throw new C("\\current@color set to non-string in \\right");return{type:"leftright-right",mode:e.parser.mode,delim:v(t[0],e).text,color:r}}}),a({type:"leftright",names:["\\left"],props:{numArgs:1,primitive:!0},handler:function(e,t){var r=v(t[0],e),n=e.parser;++n.leftrightDepth;var i=n.parseExpression(!1);--n.leftrightDepth,n.expect("\\right",!1);var a=h(n.parseFunction(),"leftright-right");return{type:"leftright",mode:n.mode,body:i,left:r.text,right:a.delim,rightColor:a.color}},htmlBuilder:function(e,t){y(e);for(var r,n,i=Bt(e.body,t,!0,["mopen","mclose"]),a=0,o=0,s=!1,l=0;l-1?"mpadded":"menclose",[$t(e.body,t)]);switch(e.label){case"\\cancel":n.setAttribute("notation","updiagonalstrike");break;case"\\bcancel":n.setAttribute("notation","downdiagonalstrike");break;case"\\phase":n.setAttribute("notation","phasorangle");break;case"\\sout":n.setAttribute("notation","horizontalstrike");break;case"\\fbox":n.setAttribute("notation","box");break;case"\\angl":n.setAttribute("notation","actuarial");break;case"\\fcolorbox":case"\\colorbox":if(r=t.fontMetrics().fboxsep*t.fontMetrics().ptPerEm,n.setAttribute("width","+"+2*r+"pt"),n.setAttribute("height","+"+2*r+"pt"),n.setAttribute("lspace",r+"pt"),n.setAttribute("voffset",r+"pt"),"\\fcolorbox"===e.label){var i=Math.max(t.fontMetrics().fboxrule,t.minRuleThickness);n.setAttribute("style","border: "+i+"em solid "+String(e.borderColor))}break;case"\\xcancel":n.setAttribute("notation","updiagonalstrike downdiagonalstrike")}return e.backgroundColor&&n.setAttribute("mathbackground",e.backgroundColor),n};a({type:"enclose",names:["\\colorbox"],props:{numArgs:2,allowedInText:!0,argTypes:["color","text"]},handler:function(e,t){var r=e.parser,n=e.funcName,i=h(t[0],"color-token").color,a=t[1];return{type:"enclose",mode:r.mode,label:n,backgroundColor:i,body:a}},htmlBuilder:Vr,mathmlBuilder:Ur}),a({type:"enclose",names:["\\fcolorbox"],props:{numArgs:3,allowedInText:!0,argTypes:["color","color","text"]},handler:function(e,t){var r=e.parser,n=e.funcName,i=h(t[0],"color-token").color,a=h(t[1],"color-token").color,o=t[2];return{type:"enclose",mode:r.mode,label:n,backgroundColor:a,borderColor:i,body:o}},htmlBuilder:Vr,mathmlBuilder:Ur}),a({type:"enclose",names:["\\fbox"],props:{numArgs:1,argTypes:["hbox"],allowedInText:!0},handler:function(e,t){return{type:"enclose",mode:e.parser.mode,label:"\\fbox",body:t[0]}}}),a({type:"enclose",names:["\\cancel","\\bcancel","\\xcancel","\\sout","\\phase"],props:{numArgs:1},handler:function(e,t){var r=e.parser,n=e.funcName,i=t[0];return{type:"enclose",mode:r.mode,label:n,body:i}},htmlBuilder:Vr,mathmlBuilder:Ur}),a({type:"enclose",names:["\\angl"],props:{numArgs:1,argTypes:["hbox"],allowedInText:!1},handler:function(e,t){return{type:"enclose",mode:e.parser.mode,label:"\\angl",body:t[0]}}});var _r={},Wr={},Gr=function(){function e(e,t,r){this.lexer=void 0,this.start=void 0,this.end=void 0,this.lexer=e,this.start=t,this.end=r}return e.range=function(t,r){return r?t&&t.loc&&r.loc&&t.loc.lexer===r.loc.lexer?new e(t.loc.lexer,t.loc.start,r.loc.end):null:t&&t.loc},e}(),Yr=function(){function e(e,t){this.text=void 0,this.loc=void 0,this.noexpand=void 0,this.treatAsRelax=void 0,this.text=e,this.loc=t}return e.prototype.range=function(t,r){return new e(r,Gr.range(this,t))},e}(),Xr=function(e){if(!e.parser.settings.displayMode)throw new C("{"+e.envName+"} can be used only in display mode.")},$r=function(e,t){function r(e){for(var t=0;t0&&(y+=.25),c.push({pos:y,isDashed:e[t]})}var n,i,a=e.body.length,o=e.hLinesBeforeRow,s=0,l=new Array(a),c=[],u=Math.max(t.fontMetrics().arrayRuleWidth,t.minRuleThickness),h=1/t.fontMetrics().ptPerEm,d=5*h;e.colSeparationType&&"small"===e.colSeparationType&&(d=t.havingStyle(W.SCRIPT).sizeMultiplier/t.sizeMultiplier*.2778);var p="CD"===e.colSeparationType?ce({number:3,unit:"ex"},t):12*h,m=3*h,f=e.arraystretch*p,g=.7*f,v=.3*f,y=0;for(r(o[0]),n=0;n0&&(x<(E+=v)&&(x=E),E=0),e.addJot&&(x+=m),k.height=w,k.depth=x,y+=w,k.pos=y,y+=x+E,l[n]=k,r(o[n+1])}var M,T,R=y/2+t.fontMetrics().axisHeight,L=e.cols||[],z=[],N=[];if(e.tags&&e.tags.some(function(e){return e}))for(n=0;n=s)){var U=void 0;(i>0||e.hskipBeforeAndAfter)&&0!==(U=B.deflt(D.pregap,d))&&((M=yt.makeSpan(["arraycolsep"],[])).style.width=ue(U),z.push(M));var _=[];for(n=0;n0){for(var $=yt.makeLineSpan("hline",t,u),K=yt.makeLineSpan("hdashline",t,u),J=[{type:"elem",elem:l,shift:0}];c.length>0;){var Z=c.pop(),Q=Z.pos-R;Z.isDashed?J.push({type:"elem",elem:K,shift:Q}):J.push({type:"elem",elem:$,shift:Q})}l=yt.makeVList({positionType:"individualShift",children:J},t)}if(0===N.length)return yt.makeSpan(["mord"],[l],t);var ee=yt.makeVList({positionType:"individualShift",children:N},t);return ee=yt.makeSpan(["tag"],[ee],t),yt.makeFragment([l,ee])},Kr={c:"center ",l:"left ",r:"right "},Jr=function(e,t){for(var r=[],n=new Ut.MathNode("mtd",[],["mtr-glue"]),i=new Ut.MathNode("mtd",[],["mml-eqn-num"]),a=0;a0){var p=e.cols,m="",f=!1,g=0,v=p.length;"separator"===p[0].type&&(h+="top ",g=1),"separator"===p[p.length-1].type&&(h+="bottom ",v-=1);for(var y=g;y0?"left ":"",h+=S[S.length-1].length>0?"right ":"";for(var A=1;A-1?"alignat":"align",a="split"===e.envName,o=S(e.parser,{cols:n,addJot:!0,autoTag:a?void 0:k(e.envName),emptySingleRow:!0,colSeparationType:i,maxNumCols:a?2:void 0,leqno:e.parser.settings.leqno},"display"),s=0,l={type:"ordgroup",mode:e.mode,body:[]};if(t[0]&&"ordgroup"===t[0].type){for(var c="",u=0;u0&&d&&(f=1),n[p]={type:"align",align:m,pregap:f,postgap:0}}return o.colSeparationType=d?"align":"alignat",o};b({type:"array",names:["array","darray"],props:{numArgs:1},handler:function(e,t){var r=(p(t[0])?[t[0]]:h(t[0],"ordgroup").body).map(function(e){var t=d(e).text;if(-1!=="lcr".indexOf(t))return{type:"align",align:t};if("|"===t)return{type:"separator",separator:"|"};if(":"===t)return{type:"separator",separator:":"};throw new C("Unknown column alignment: "+t,e)}),n={cols:r,hskipBeforeAndAfter:!0,maxNumCols:r.length};return S(e.parser,n,A(e.envName))},htmlBuilder:$r,mathmlBuilder:Jr}),b({type:"array",names:["matrix","pmatrix","bmatrix","Bmatrix","vmatrix","Vmatrix","matrix*","pmatrix*","bmatrix*","Bmatrix*","vmatrix*","Vmatrix*"],props:{numArgs:0},handler:function(e){var t={matrix:null,pmatrix:["(",")"],bmatrix:["[","]"],Bmatrix:["\\{","\\}"],vmatrix:["|","|"],Vmatrix:["\\Vert","\\Vert"]}[e.envName.replace("*","")],r="c",n={hskipBeforeAndAfter:!1,cols:[{type:"align",align:r}]};if("*"===e.envName.charAt(e.envName.length-1)){var i=e.parser;if(i.consumeSpaces(),"["===i.fetch().text){if(i.consume(),i.consumeSpaces(),r=i.fetch().text,-1==="lcr".indexOf(r))throw new C("Expected l or c or r",i.nextToken);i.consume(),i.consumeSpaces(),i.expect("]"),i.consume(),n.cols=[{type:"align",align:r}]}}var a=S(e.parser,n,A(e.envName)),o=Math.max.apply(Math,[0].concat(a.body.map(function(e){return e.length})));return a.cols=new Array(o).fill({type:"align",align:r}),t?{type:"leftright",mode:e.mode,body:[a],left:t[0],right:t[1],rightColor:void 0}:a},htmlBuilder:$r,mathmlBuilder:Jr}),b({type:"array",names:["smallmatrix"],props:{numArgs:0},handler:function(e){var t=S(e.parser,{arraystretch:.5},"script");return t.colSeparationType="small",t},htmlBuilder:$r,mathmlBuilder:Jr}),b({type:"array",names:["subarray"],props:{numArgs:1},handler:function(e,t){var r=(p(t[0])?[t[0]]:h(t[0],"ordgroup").body).map(function(e){var t=d(e).text;if(-1!=="lc".indexOf(t))return{type:"align",align:t};throw new C("Unknown column alignment: "+t,e)});if(r.length>1)throw new C("{subarray} can contain only one column");var n={cols:r,hskipBeforeAndAfter:!1,arraystretch:.5};if((n=S(e.parser,n,"script")).body.length>0&&n.body[0].length>1)throw new C("{subarray} can contain only one column");return n},htmlBuilder:$r,mathmlBuilder:Jr}),b({type:"array",names:["cases","dcases","rcases","drcases"],props:{numArgs:0},handler:function(e){var t=S(e.parser,{arraystretch:1.2,cols:[{type:"align",align:"l",pregap:0,postgap:1},{type:"align",align:"l",pregap:0,postgap:0}]},A(e.envName));return{type:"leftright",mode:e.mode,body:[t],left:e.envName.indexOf("r")>-1?".":"\\{",right:e.envName.indexOf("r")>-1?"\\}":".",rightColor:void 0}},htmlBuilder:$r,mathmlBuilder:Jr}),b({type:"array",names:["align","align*","aligned","split"],props:{numArgs:0},handler:Zr,htmlBuilder:$r,mathmlBuilder:Jr}),b({type:"array",names:["gathered","gather","gather*"],props:{numArgs:0},handler:function(e){B.contains(["gather","gather*"],e.envName)&&Xr(e);var t={cols:[{type:"align",align:"c"}],addJot:!0,colSeparationType:"gather",autoTag:k(e.envName),emptySingleRow:!0,leqno:e.parser.settings.leqno};return S(e.parser,t,"display")},htmlBuilder:$r,mathmlBuilder:Jr}),b({type:"array",names:["alignat","alignat*","alignedat"],props:{numArgs:1},handler:Zr,htmlBuilder:$r,mathmlBuilder:Jr}),b({type:"array",names:["equation","equation*"],props:{numArgs:0},handler:function(e){Xr(e);var t={autoTag:k(e.envName),emptySingleRow:!0,singleRow:!0,maxNumCols:1,leqno:e.parser.settings.leqno};return S(e.parser,t,"display")},htmlBuilder:$r,mathmlBuilder:Jr}),b({type:"array",names:["CD"],props:{numArgs:0},handler:function(e){return Xr(e),function(e){var t=[];for(e.gullet.beginGroup(),e.gullet.macros.set("\\cr","\\\\\\relax"),e.gullet.beginGroup();;){t.push(e.parseExpression(!1,"\\\\")),e.gullet.endGroup(),e.gullet.beginGroup();var r=e.fetch().text;if("&"!==r&&"\\\\"!==r){if("\\end"===r){0===t[t.length-1].length&&t.pop();break}throw new C("Expected \\\\ or \\cr or \\end",e.nextToken)}e.consume()}for(var n,i,a=[],o=[a],s=0;s-1);else{if(!("<>AV".indexOf(h)>-1))throw new C('Expected one of "<>AV=|." after @',l[u]);for(var m=0;m<2;m++){for(var f=!0,v=u+1;v=W.SCRIPT.id?r.text():W.DISPLAY:"text"===e&&r.size===W.DISPLAY.size?r=W.TEXT:"script"===e?r=W.SCRIPT:"scriptscript"===e&&(r=W.SCRIPTSCRIPT),r},an=function(e,t){var r,n=nn(e.size,t.style),i=n.fracNum(),a=n.fracDen();r=t.havingStyle(i);var o=Ft(e.numer,r,t);if(e.continued){var s=8.5/t.fontMetrics().ptPerEm,l=3.5/t.fontMetrics().ptPerEm;o.height=o.height0?3*h:7*h,m=t.fontMetrics().denom1):(u>0?(d=t.fontMetrics().num2,p=h):(d=t.fontMetrics().num3,p=3*h),m=t.fontMetrics().denom2),c){var w=t.fontMetrics().axisHeight;d-o.depth-(w+.5*u)0&&(t="."===(t=e)?null:t),t};a({type:"genfrac",names:["\\genfrac"],props:{numArgs:6,allowedInArgument:!0,argTypes:["math","math","size","text","math","math"]},handler:function(e,t){var r,n=e.parser,i=t[4],a=t[5],o=Tt(t[0]),s="atom"===o.type&&"open"===o.family?ln(o.text):null,l=Tt(t[1]),c="atom"===l.type&&"close"===l.family?ln(l.text):null,u=h(t[2],"size"),d=null;r=!!u.isBlank||(d=u.value).number>0;var p="auto",m=t[3];if("ordgroup"===m.type){if(m.body.length>0){var f=h(m.body[0],"textord");p=sn[Number(f.text)]}}else m=h(m,"textord"),p=sn[Number(m.text)];return{type:"genfrac",mode:n.mode,numer:i,denom:a,continued:!1,hasBarLine:r,barSize:d,leftDelim:s,rightDelim:c,size:p}},htmlBuilder:an,mathmlBuilder:on}),a({type:"infix",names:["\\above"],props:{numArgs:1,argTypes:["size"],infix:!0},handler:function(e,t){var r=e.parser,n=(e.funcName,e.token);return{type:"infix",mode:r.mode,replaceWith:"\\\\abovefrac",size:h(t[0],"size").value,token:n}}}),a({type:"genfrac",names:["\\\\abovefrac"],props:{numArgs:3,argTypes:["math","size","math"]},handler:function(e,t){var r=e.parser,n=(e.funcName,t[0]),i=function(e){if(!e)throw new Error("Expected non-null, but got "+String(e));return e}(h(t[1],"infix").size),a=t[2],o=i.number>0;return{type:"genfrac",mode:r.mode,numer:n,denom:a,continued:!1,hasBarLine:o,barSize:i,leftDelim:null,rightDelim:null,size:"auto"}},htmlBuilder:an,mathmlBuilder:on});var cn=function(e,t){var r,n,i=t.style;"supsub"===e.type?(r=e.sup?Ft(e.sup,t.havingStyle(i.sup()),t):Ft(e.sub,t.havingStyle(i.sub()),t),n=h(e.base,"horizBrace")):n=h(e,"horizBrace");var a,o=Ft(n.base,t.havingBaseStyle(W.DISPLAY)),s=nr(n,t);if(n.isOver?(a=yt.makeVList({positionType:"firstBaseline",children:[{type:"elem",elem:o},{type:"kern",size:.1},{type:"elem",elem:s}]},t)).children[0].children[0].children[1].classes.push("svg-align"):(a=yt.makeVList({positionType:"bottom",positionData:o.depth+.1+s.height,children:[{type:"elem",elem:s},{type:"kern",size:.1},{type:"elem",elem:o}]},t)).children[0].children[0].children[0].classes.push("svg-align"),r){var l=yt.makeSpan(["mord",n.isOver?"mover":"munder"],[a],t);a=n.isOver?yt.makeVList({positionType:"firstBaseline",children:[{type:"elem",elem:l},{type:"kern",size:.2},{type:"elem",elem:r}]},t):yt.makeVList({positionType:"bottom",positionData:l.depth+.2+r.height+r.depth,children:[{type:"elem",elem:r},{type:"kern",size:.2},{type:"elem",elem:l}]},t)}return yt.makeSpan(["mord",n.isOver?"mover":"munder"],[a],t)};a({type:"horizBrace",names:["\\overbrace","\\underbrace"],props:{numArgs:1},handler:function(e,t){var r=e.parser,n=e.funcName;return{type:"horizBrace",mode:r.mode,label:n,isOver:/^\\over/.test(n),base:t[0]}},htmlBuilder:cn,mathmlBuilder:function(e,t){var r=rr(e.label);return new Ut.MathNode(e.isOver?"mover":"munder",[$t(e.base,t),r])}}),a({type:"href",names:["\\href"],props:{numArgs:2,argTypes:["url","original"],allowedInText:!0},handler:function(e,t){var r=e.parser,n=t[1],i=h(t[0],"url").url;return r.settings.isTrusted({command:"\\href",url:i})?{type:"href",mode:r.mode,href:i,body:Rt(n)}:r.formatUnsupportedCmd("\\href")},htmlBuilder:function(e,t){var r=Bt(e.body,t,!1);return yt.makeAnchor(e.href,[],r,t)},mathmlBuilder:function(e,t){var r=Xt(e.body,t);return r instanceof jt||(r=new jt("mrow",[r])),r.setAttribute("href",e.href),r}}),a({type:"href",names:["\\url"],props:{numArgs:1,argTypes:["url"],allowedInText:!0},handler:function(e,t){var r=e.parser,n=h(t[0],"url").url;if(!r.settings.isTrusted({command:"\\url",url:n}))return r.formatUnsupportedCmd("\\url");for(var i=[],a=0;a0&&(n=ce(e.totalheight,t)-r);var i=0;e.width.number>0&&(i=ce(e.width,t));var a={height:ue(r+n)};i>0&&(a.width=ue(i)),n>0&&(a.verticalAlign=ue(-n));var o=new ve(e.src,e.alt,a);return o.height=r,o.depth=n,o},mathmlBuilder:function(e,t){var r=new Ut.MathNode("mglyph",[]);r.setAttribute("alt",e.alt);var n=ce(e.height,t),i=0;if(e.totalheight.number>0&&(i=ce(e.totalheight,t)-n,r.setAttribute("valign",ue(-i))),r.setAttribute("height",ue(n+i)),e.width.number>0){var a=ce(e.width,t);r.setAttribute("width",ue(a))}return r.setAttribute("src",e.src),r}}),a({type:"kern",names:["\\kern","\\mkern","\\hskip","\\mskip"],props:{numArgs:1,argTypes:["size"],primitive:!0,allowedInText:!0},handler:function(e,t){var r=e.parser,n=e.funcName,i=h(t[0],"size");if(r.settings.strict){var a="m"===n[1],o="mu"===i.value.unit;a?(o||r.settings.reportNonstrict("mathVsTextUnits","LaTeX's "+n+" supports only mu units, not "+i.value.unit+" units"),"math"!==r.mode&&r.settings.reportNonstrict("mathVsTextUnits","LaTeX's "+n+" works only in math mode")):o&&r.settings.reportNonstrict("mathVsTextUnits","LaTeX's "+n+" doesn't support mu units")}return{type:"kern",mode:r.mode,dimension:i.value}},htmlBuilder:function(e,t){return yt.makeGlue(e.dimension,t)},mathmlBuilder:function(e,t){var r=ce(e.dimension,t);return new Ut.SpaceNode(r)}}),a({type:"lap",names:["\\mathllap","\\mathrlap","\\mathclap"],props:{numArgs:1,allowedInText:!0},handler:function(e,t){var r=e.parser,n=e.funcName,i=t[0];return{type:"lap",mode:r.mode,alignment:n.slice(5),body:i}},htmlBuilder:function(e,t){var r;"clap"===e.alignment?(r=yt.makeSpan([],[Ft(e.body,t)]),r=yt.makeSpan(["inner"],[r],t)):r=yt.makeSpan(["inner"],[Ft(e.body,t)]);var n=yt.makeSpan(["fix"],[]),i=yt.makeSpan([e.alignment],[r,n],t),a=yt.makeSpan(["strut"]);return a.style.height=ue(i.height+i.depth),i.depth&&(a.style.verticalAlign=ue(-i.depth)),i.children.unshift(a),i=yt.makeSpan(["thinbox"],[i],t),yt.makeSpan(["mord","vbox"],[i],t)},mathmlBuilder:function(e,t){var r=new Ut.MathNode("mpadded",[$t(e.body,t)]);if("rlap"!==e.alignment){var n="llap"===e.alignment?"-1":"-0.5";r.setAttribute("lspace",n+"width")}return r.setAttribute("width","0px"),r}}),a({type:"styling",names:["\\(","$"],props:{numArgs:0,allowedInText:!0,allowedInMath:!1},handler:function(e){var t=e.funcName,r=e.parser,n=r.mode;r.switchMode("math");var i="\\("===t?"\\)":"$",a=r.parseExpression(!1,i);return r.expect(i),r.switchMode(n),{type:"styling",mode:r.mode,style:"text",body:a}}}),a({type:"text",names:["\\)","\\]"],props:{numArgs:0,allowedInText:!0,allowedInMath:!1},handler:function(e){throw new C("Mismatched "+e.funcName)}});var hn=function(e,t){switch(t.style.size){case W.DISPLAY.size:return e.display;case W.TEXT.size:return e.text;case W.SCRIPT.size:return e.script;case W.SCRIPTSCRIPT.size:return e.scriptscript;default:return e.text}};a({type:"mathchoice",names:["\\mathchoice"],props:{numArgs:4,primitive:!0},handler:function(e,t){return{type:"mathchoice",mode:e.parser.mode,display:Rt(t[0]),text:Rt(t[1]),script:Rt(t[2]),scriptscript:Rt(t[3])}},htmlBuilder:function(e,t){var r=hn(e,t),n=Bt(r,t,!1);return yt.makeFragment(n)},mathmlBuilder:function(e,t){var r=hn(e,t);return Xt(r,t)}});var dn=function(e,t,r,n,i,a,o){e=yt.makeSpan([],[e]);var s,l,c,u=r&&B.isCharacterBox(r);if(t){var h=Ft(t,n.havingStyle(i.sup()),n);l={elem:h,kern:Math.max(n.fontMetrics().bigOpSpacing1,n.fontMetrics().bigOpSpacing3-h.depth)}}if(r){var d=Ft(r,n.havingStyle(i.sub()),n);s={elem:d,kern:Math.max(n.fontMetrics().bigOpSpacing2,n.fontMetrics().bigOpSpacing4-d.height)}}if(l&&s){var p=n.fontMetrics().bigOpSpacing5+s.elem.height+s.elem.depth+s.kern+e.depth+o;c=yt.makeVList({positionType:"bottom",positionData:p,children:[{type:"kern",size:n.fontMetrics().bigOpSpacing5},{type:"elem",elem:s.elem,marginLeft:ue(-a)},{type:"kern",size:s.kern},{type:"elem",elem:e},{type:"kern",size:l.kern},{type:"elem",elem:l.elem,marginLeft:ue(a)},{type:"kern",size:n.fontMetrics().bigOpSpacing5}]},n)}else if(s){var m=e.height-o;c=yt.makeVList({positionType:"top",positionData:m,children:[{type:"kern",size:n.fontMetrics().bigOpSpacing5},{type:"elem",elem:s.elem,marginLeft:ue(-a)},{type:"kern",size:s.kern},{type:"elem",elem:e}]},n)}else{if(!l)return e;var f=e.depth+o;c=yt.makeVList({positionType:"bottom",positionData:f,children:[{type:"elem",elem:e},{type:"kern",size:l.kern},{type:"elem",elem:l.elem,marginLeft:ue(a)},{type:"kern",size:n.fontMetrics().bigOpSpacing5}]},n)}var g=[c];if(s&&0!==a&&!u){var v=yt.makeSpan(["mspace"],[],n);v.style.marginRight=ue(a),g.unshift(v)}return yt.makeSpan(["mop","op-limits"],g,n)},pn=["\\smallint"],mn=function(e,t){var r,n,i,a=!1;"supsub"===e.type?(r=e.sup,n=e.sub,i=h(e.base,"op"),a=!0):i=h(e,"op");var o,s=t.style,l=!1;if(s.size===W.DISPLAY.size&&i.symbol&&!B.contains(pn,i.name)&&(l=!0),i.symbol){var c=l?"Size2-Regular":"Size1-Regular",u="";if("\\oiint"!==i.name&&"\\oiiint"!==i.name||(u=i.name.slice(1),i.name="oiint"===u?"\\iint":"\\iiint"),o=yt.makeSymbol(i.name,c,"math",t,["mop","op-symbol",l?"large-op":"small-op"]),u.length>0){var d=o.italic,p=yt.staticSvg(u+"Size"+(l?"2":"1"),t);o=yt.makeVList({positionType:"individualShift",children:[{type:"elem",elem:o,shift:0},{type:"elem",elem:p,shift:l?.08:0}]},t),i.name="\\"+u,o.classes.unshift("mop"),o.italic=d}}else if(i.body){var m=Bt(i.body,t,!0);1===m.length&&m[0]instanceof be?(o=m[0]).classes[0]="mop":o=yt.makeSpan(["mop"],m,t)}else{for(var f=[],g=1;g0){for(var s=i.body.map(function(e){var t=e.text;return"string"==typeof t?{type:"textord",mode:e.mode,text:t}:e}),l=Bt(s,t.withFont("mathrm"),!0),c=0;c=0?s.setAttribute("height",ue(i)):(s.setAttribute("height",ue(i)),s.setAttribute("depth",ue(-i))),s.setAttribute("voffset",ue(i)),s}});var bn=["\\tiny","\\sixptsize","\\scriptsize","\\footnotesize","\\small","\\normalsize","\\large","\\Large","\\LARGE","\\huge","\\Huge"];a({type:"sizing",names:bn,props:{numArgs:0,allowedInText:!0},handler:function(e){var t=e.breakOnTokenText,r=e.funcName,n=e.parser,i=n.parseExpression(!1,t);return{type:"sizing",mode:n.mode,size:bn.indexOf(r)+1,body:i}},htmlBuilder:function(e,t){var r=t.havingSize(e.size);return E(e.body,r,t)},mathmlBuilder:function(e,t){var r=t.havingSize(e.size),n=Yt(e.body,r),i=new Ut.MathNode("mstyle",n);return i.setAttribute("mathsize",ue(r.sizeMultiplier)),i}}),a({type:"smash",names:["\\smash"],props:{numArgs:1,numOptionalArgs:1,allowedInText:!0},handler:function(e,t,r){var n=e.parser,i=!1,a=!1,o=r[0]&&h(r[0],"ordgroup");if(o)for(var s="",l=0;lr.height+r.depth+a&&(a=(a+h-r.height-r.depth)/2);var d=l.height-r.height-a-c;r.style.paddingLeft=ue(u);var p=yt.makeVList({positionType:"firstBaseline",children:[{type:"elem",elem:r,wrapperClasses:["svg-align"]},{type:"kern",size:-(r.height+d)},{type:"elem",elem:l},{type:"kern",size:c}]},t);if(e.index){var m=t.havingStyle(W.SCRIPTSCRIPT),f=Ft(e.index,m,t),g=.6*(p.height-p.depth),v=yt.makeVList({positionType:"shift",positionData:-g,children:[{type:"elem",elem:f}]},t),y=yt.makeSpan(["root"],[v]);return yt.makeSpan(["mord","sqrt"],[y,p],t)}return yt.makeSpan(["mord","sqrt"],[p],t)},mathmlBuilder:function(e,t){var r=e.body,n=e.index;return n?new Ut.MathNode("mroot",[$t(r,t),$t(n,t)]):new Ut.MathNode("msqrt",[$t(r,t)])}});var wn={display:W.DISPLAY,text:W.TEXT,script:W.SCRIPT,scriptscript:W.SCRIPTSCRIPT};a({type:"styling",names:["\\displaystyle","\\textstyle","\\scriptstyle","\\scriptscriptstyle"],props:{numArgs:0,allowedInText:!0,primitive:!0},handler:function(e){var t=e.breakOnTokenText,r=e.funcName,n=e.parser,i=n.parseExpression(!0,t),a=r.slice(1,r.length-5);return{type:"styling",mode:n.mode,style:a,body:i}},htmlBuilder:function(e,t){var r=wn[e.style],n=t.havingStyle(r).withFont("");return E(e.body,n,t)},mathmlBuilder:function(e,t){var r=wn[e.style],n=t.havingStyle(r),i=Yt(e.body,n),a=new Ut.MathNode("mstyle",i),o={display:["0","true"],text:["0","false"],script:["1","false"],scriptscript:["2","false"]}[e.style];return a.setAttribute("scriptlevel",o[0]),a.setAttribute("displaystyle",o[1]),a}});var xn=function(e,t){var r=e.base;return r?"op"===r.type?r.limits&&(t.style.size===W.DISPLAY.size||r.alwaysHandleSupSub)?mn:null:"operatorname"===r.type?r.alwaysHandleSupSub&&(t.style.size===W.DISPLAY.size||r.limits)?yn:null:"accent"===r.type?B.isCharacterBox(r.base)?ir:null:"horizBrace"===r.type&&!e.sub===r.isOver?cn:null:null};o({type:"supsub",htmlBuilder:function(e,t){var r=xn(e,t);if(r)return r(e,t);var n,i,a,o=e.base,s=e.sup,l=e.sub,c=Ft(o,t),u=t.fontMetrics(),h=0,d=0,p=o&&B.isCharacterBox(o);if(s){var m=t.havingStyle(t.style.sup());n=Ft(s,m,t),p||(h=c.height-m.fontMetrics().supDrop*m.sizeMultiplier/t.sizeMultiplier)}if(l){var f=t.havingStyle(t.style.sub());i=Ft(l,f,t),p||(d=c.depth+f.fontMetrics().subDrop*f.sizeMultiplier/t.sizeMultiplier)}a=t.style===W.DISPLAY?u.sup1:t.style.cramped?u.sup3:u.sup2;var g,v=t.sizeMultiplier,y=ue(.5/u.ptPerEm/v),b=null;if(i){var w=e.base&&"op"===e.base.type&&e.base.name&&("\\oiint"===e.base.name||"\\oiiint"===e.base.name);(c instanceof be||w)&&(b=ue(-c.italic))}if(n&&i){h=Math.max(h,a,n.depth+.25*u.xHeight),d=Math.max(d,u.sub2);var x=4*u.defaultRuleThickness;if(h-n.depth-(i.height-d)0&&(h+=k,d-=k)}var S=[{type:"elem",elem:i,shift:d,marginRight:y,marginLeft:b},{type:"elem",elem:n,shift:-h,marginRight:y}];g=yt.makeVList({positionType:"individualShift",children:S},t)}else if(i){d=Math.max(d,u.sub1,i.height-.8*u.xHeight);var A=[{type:"elem",elem:i,marginLeft:b,marginRight:y}];g=yt.makeVList({positionType:"shift",positionData:d,children:A},t)}else{if(!n)throw new Error("supsub must have either sup or sub.");h=Math.max(h,a,n.depth+.25*u.xHeight),g=yt.makeVList({positionType:"shift",positionData:-h,children:[{type:"elem",elem:n,marginRight:y}]},t)}var E=Dt(c,"right")||"mord";return yt.makeSpan([E],[c,yt.makeSpan(["msupsub"],[g])],t)},mathmlBuilder:function(e,t){var r,n=!1;e.base&&"horizBrace"===e.base.type&&!!e.sup===e.base.isOver&&(n=!0,r=e.base.isOver),!e.base||"op"!==e.base.type&&"operatorname"!==e.base.type||(e.base.parentIsSupSub=!0);var i,a=[$t(e.base,t)];if(e.sub&&a.push($t(e.sub,t)),e.sup&&a.push($t(e.sup,t)),n)i=r?"mover":"munder";else if(e.sub)if(e.sup){var o=e.base;i=o&&"op"===o.type&&o.limits&&t.style===W.DISPLAY||o&&"operatorname"===o.type&&o.alwaysHandleSupSub&&(t.style===W.DISPLAY||o.limits)?"munderover":"msubsup"}else{var s=e.base;i=s&&"op"===s.type&&s.limits&&(t.style===W.DISPLAY||s.alwaysHandleSupSub)||s&&"operatorname"===s.type&&s.alwaysHandleSupSub&&(s.limits||t.style===W.DISPLAY)?"munder":"msub"}else{var l=e.base;i=l&&"op"===l.type&&l.limits&&(t.style===W.DISPLAY||l.alwaysHandleSupSub)||l&&"operatorname"===l.type&&l.alwaysHandleSupSub&&(l.limits||t.style===W.DISPLAY)?"mover":"msup"}return new Ut.MathNode(i,a)}}),o({type:"atom",htmlBuilder:function(e,t){return yt.mathsym(e.text,e.mode,t,["m"+e.family])},mathmlBuilder:function(e,t){var r=new Ut.MathNode("mo",[_t(e.text,e.mode)]);if("bin"===e.family){var n=Gt(e,t);"bold-italic"===n&&r.setAttribute("mathvariant",n)}else"punct"===e.family?r.setAttribute("separator","true"):"open"!==e.family&&"close"!==e.family||r.setAttribute("stretchy","false");return r}});var kn={mi:"italic",mn:"normal",mtext:"normal"};o({type:"mathord",htmlBuilder:function(e,t){return yt.makeOrd(e,t,"mathord")},mathmlBuilder:function(e,t){var r=new Ut.MathNode("mi",[_t(e.text,e.mode,t)]),n=Gt(e,t)||"italic";return n!==kn[r.type]&&r.setAttribute("mathvariant",n),r}}),o({type:"textord",htmlBuilder:function(e,t){return yt.makeOrd(e,t,"textord")},mathmlBuilder:function(e,t){var r,n=_t(e.text,e.mode,t),i=Gt(e,t)||"normal";return r="text"===e.mode?new Ut.MathNode("mtext",[n]):/[0-9]/.test(e.text)?new Ut.MathNode("mn",[n]):"\\prime"===e.text?new Ut.MathNode("mo",[n]):new Ut.MathNode("mi",[n]),i!==kn[r.type]&&r.setAttribute("mathvariant",i),r}});var Sn={"\\nobreak":"nobreak","\\allowbreak":"allowbreak"},An={" ":{},"\\ ":{},"~":{className:"nobreak"},"\\space":{},"\\nobreakspace":{className:"nobreak"}};o({type:"spacing",htmlBuilder:function(e,t){if(An.hasOwnProperty(e.text)){var r=An[e.text].className||"";if("text"===e.mode){var n=yt.makeOrd(e,t,"textord");return n.classes.push(r),n}return yt.makeSpan(["mspace",r],[yt.mathsym(e.text,e.mode,t)],t)}if(Sn.hasOwnProperty(e.text))return yt.makeSpan(["mspace",Sn[e.text]],[],t);throw new C('Unknown type of space "'+e.text+'"')},mathmlBuilder:function(e){if(!An.hasOwnProperty(e.text)){if(Sn.hasOwnProperty(e.text))return new Ut.MathNode("mspace");throw new C('Unknown type of space "'+e.text+'"')}return new Ut.MathNode("mtext",[new Ut.TextNode("\xa0")])}});var En=function(){var e=new Ut.MathNode("mtd",[]);return e.setAttribute("width","50%"),e};o({type:"tag",mathmlBuilder:function(e,t){var r=new Ut.MathNode("mtable",[new Ut.MathNode("mtr",[En(),new Ut.MathNode("mtd",[Xt(e.body,t)]),En(),new Ut.MathNode("mtd",[Xt(e.tag,t)])])]);return r.setAttribute("width","100%"),r}});var Mn={"\\text":void 0,"\\textrm":"textrm","\\textsf":"textsf","\\texttt":"texttt","\\textnormal":"textrm"},Tn={"\\textbf":"textbf","\\textmd":"textmd"},Rn={"\\textit":"textit","\\textup":"textup"},Cn=function(e,t){var r=e.font;return r?Mn[r]?t.withTextFontFamily(Mn[r]):Tn[r]?t.withTextFontWeight(Tn[r]):t.withTextFontShape(Rn[r]):t};a({type:"text",names:["\\text","\\textrm","\\textsf","\\texttt","\\textnormal","\\textbf","\\textmd","\\textit","\\textup"],props:{numArgs:1,argTypes:["text"],allowedInArgument:!0,allowedInText:!0},handler:function(e,t){var r=e.parser,n=e.funcName,i=t[0];return{type:"text",mode:r.mode,body:Rt(i),font:n}},htmlBuilder:function(e,t){var r=Cn(e,t),n=Bt(e.body,r,!0);return yt.makeSpan(["mord","text"],n,r)},mathmlBuilder:function(e,t){var r=Cn(e,t);return Xt(e.body,r)}}),a({type:"underline",names:["\\underline"],props:{numArgs:1,allowedInText:!0},handler:function(e,t){return{type:"underline",mode:e.parser.mode,body:t[0]}},htmlBuilder:function(e,t){var r=Ft(e.body,t),n=yt.makeLineSpan("underline-line",t),i=t.fontMetrics().defaultRuleThickness,a=yt.makeVList({positionType:"top",positionData:r.height,children:[{type:"kern",size:i},{type:"elem",elem:n},{type:"kern",size:3*i},{type:"elem",elem:r}]},t);return yt.makeSpan(["mord","underline"],[a],t)},mathmlBuilder:function(e,t){var r=new Ut.MathNode("mo",[new Ut.TextNode("\u203e")]);r.setAttribute("stretchy","true");var n=new Ut.MathNode("munder",[$t(e.body,t),r]);return n.setAttribute("accentunder","true"),n}}),a({type:"vcenter",names:["\\vcenter"],props:{numArgs:1,argTypes:["original"],allowedInText:!1},handler:function(e,t){return{type:"vcenter",mode:e.parser.mode,body:t[0]}},htmlBuilder:function(e,t){var r=Ft(e.body,t),n=t.fontMetrics().axisHeight,i=.5*(r.height-n-(r.depth+n));return yt.makeVList({positionType:"shift",positionData:i,children:[{type:"elem",elem:r}]},t)},mathmlBuilder:function(e,t){return new Ut.MathNode("mpadded",[$t(e.body,t)],["vcenter"])}}),a({type:"verb",names:["\\verb"],props:{numArgs:0,allowedInText:!0},handler:function(){throw new C("\\verb ended by end of line instead of matching delimiter")},htmlBuilder:function(e,t){for(var r=Ln(e),n=[],i=t.havingStyle(t.style.text()),a=0;a0;)this.endGroup()},t.has=function(e){return this.current.hasOwnProperty(e)||this.builtins.hasOwnProperty(e)},t.get=function(e){return this.current.hasOwnProperty(e)?this.current[e]:this.builtins[e]},t.set=function(e,t,r){if(void 0===r&&(r=!1),r){for(var n=0;n0&&(this.undefStack[this.undefStack.length-1][e]=t)}else{var i=this.undefStack[this.undefStack.length-1];i&&!i.hasOwnProperty(e)&&(i[e]=this.current[e])}null==t?delete this.current[e]:this.current[e]=t},e}(),Hn=Wr;w("\\noexpand",function(e){var t=e.popToken();return e.isExpandable(t.text)&&(t.noexpand=!0,t.treatAsRelax=!0),{tokens:[t],numArgs:0}}),w("\\expandafter",function(e){var t=e.popToken();return e.expandOnce(!0),{tokens:[t],numArgs:0}}),w("\\@firstoftwo",function(e){return{tokens:e.consumeArgs(2)[0],numArgs:0}}),w("\\@secondoftwo",function(e){return{tokens:e.consumeArgs(2)[1],numArgs:0}}),w("\\@ifnextchar",function(e){var t=e.consumeArgs(3);e.consumeSpaces();var r=e.future();return 1===t[0].length&&t[0][0].text===r.text?{tokens:t[1],numArgs:0}:{tokens:t[2],numArgs:0}}),w("\\@ifstar","\\@ifnextchar *{\\@firstoftwo{#1}}"),w("\\TextOrMath",function(e){var t=e.consumeArgs(2);return"text"===e.mode?{tokens:t[0],numArgs:0}:{tokens:t[1],numArgs:0}});var Fn={0:0,1:1,2:2,3:3,4:4,5:5,6:6,7:7,8:8,9:9,a:10,A:10,b:11,B:11,c:12,C:12,d:13,D:13,e:14,E:14,f:15,F:15};w("\\char",function(e){var t,r=e.popToken(),n="";if("'"===r.text)t=8,r=e.popToken();else if('"'===r.text)t=16,r=e.popToken();else if("`"===r.text)if("\\"===(r=e.popToken()).text[0])n=r.text.charCodeAt(1);else{if("EOF"===r.text)throw new C("\\char` missing argument");n=r.text.charCodeAt(0)}else t=10;if(t){if(null==(n=Fn[r.text])||n>=t)throw new C("Invalid base-"+t+" digit "+r.text);for(var i;null!=(i=Fn[e.future().text])&&i":"\\dotsb","-":"\\dotsb","*":"\\dotsb",":":"\\dotsb","\\DOTSB":"\\dotsb","\\coprod":"\\dotsb","\\bigvee":"\\dotsb","\\bigwedge":"\\dotsb","\\biguplus":"\\dotsb","\\bigcap":"\\dotsb","\\bigcup":"\\dotsb","\\prod":"\\dotsb","\\sum":"\\dotsb","\\bigotimes":"\\dotsb","\\bigoplus":"\\dotsb","\\bigodot":"\\dotsb","\\bigsqcup":"\\dotsb","\\And":"\\dotsb","\\longrightarrow":"\\dotsb","\\Longrightarrow":"\\dotsb","\\longleftarrow":"\\dotsb","\\Longleftarrow":"\\dotsb","\\longleftrightarrow":"\\dotsb","\\Longleftrightarrow":"\\dotsb","\\mapsto":"\\dotsb","\\longmapsto":"\\dotsb","\\hookrightarrow":"\\dotsb","\\doteq":"\\dotsb","\\mathbin":"\\dotsb","\\mathrel":"\\dotsb","\\relbar":"\\dotsb","\\Relbar":"\\dotsb","\\xrightarrow":"\\dotsb","\\xleftarrow":"\\dotsb","\\DOTSI":"\\dotsi","\\int":"\\dotsi","\\oint":"\\dotsi","\\iint":"\\dotsi","\\iiint":"\\dotsi","\\iiiint":"\\dotsi","\\idotsint":"\\dotsi","\\DOTSX":"\\dotsx"};w("\\dots",function(e){var t="\\dotso",r=e.expandAfterFuture().text;return r in Vn?t=Vn[r]:("\\not"===r.slice(0,4)||r in Me.math&&B.contains(["bin","rel"],Me.math[r].group))&&(t="\\dotsb"),t});var Un={")":!0,"]":!0,"\\rbrack":!0,"\\}":!0,"\\rbrace":!0,"\\rangle":!0,"\\rceil":!0,"\\rfloor":!0,"\\rgroup":!0,"\\rmoustache":!0,"\\right":!0,"\\bigr":!0,"\\biggr":!0,"\\Bigr":!0,"\\Biggr":!0,$:!0,";":!0,".":!0,",":!0};w("\\dotso",function(e){return e.future().text in Un?"\\ldots\\,":"\\ldots"}),w("\\dotsc",function(e){var t=e.future().text;return t in Un&&","!==t?"\\ldots\\,":"\\ldots"}),w("\\cdots",function(e){return e.future().text in Un?"\\@cdots\\,":"\\@cdots"}),w("\\dotsb","\\cdots"),w("\\dotsm","\\cdots"),w("\\dotsi","\\!\\cdots"),w("\\dotsx","\\ldots\\,"),w("\\DOTSI","\\relax"),w("\\DOTSB","\\relax"),w("\\DOTSX","\\relax"),w("\\tmspace","\\TextOrMath{\\kern#1#3}{\\mskip#1#2}\\relax"),w("\\,","\\tmspace+{3mu}{.1667em}"),w("\\thinspace","\\,"),w("\\>","\\mskip{4mu}"),w("\\:","\\tmspace+{4mu}{.2222em}"),w("\\medspace","\\:"),w("\\;","\\tmspace+{5mu}{.2777em}"),w("\\thickspace","\\;"),w("\\!","\\tmspace-{3mu}{.1667em}"),w("\\negthinspace","\\!"),w("\\negmedspace","\\tmspace-{4mu}{.2222em}"),w("\\negthickspace","\\tmspace-{5mu}{.277em}"),w("\\enspace","\\kern.5em "),w("\\enskip","\\hskip.5em\\relax"),w("\\quad","\\hskip1em\\relax"),w("\\qquad","\\hskip2em\\relax"),w("\\tag","\\@ifstar\\tag@literal\\tag@paren"),w("\\tag@paren","\\tag@literal{({#1})}"),w("\\tag@literal",function(e){if(e.macros.get("\\df@tag"))throw new C("Multiple \\tag");return"\\gdef\\df@tag{\\text{#1}}"}),w("\\bmod","\\mathchoice{\\mskip1mu}{\\mskip1mu}{\\mskip5mu}{\\mskip5mu}\\mathbin{\\rm mod}\\mathchoice{\\mskip1mu}{\\mskip1mu}{\\mskip5mu}{\\mskip5mu}"),w("\\pod","\\allowbreak\\mathchoice{\\mkern18mu}{\\mkern8mu}{\\mkern8mu}{\\mkern8mu}(#1)"),w("\\pmod","\\pod{{\\rm mod}\\mkern6mu#1}"),w("\\mod","\\allowbreak\\mathchoice{\\mkern18mu}{\\mkern12mu}{\\mkern12mu}{\\mkern12mu}{\\rm mod}\\,\\,#1"),w("\\newline","\\\\\\relax"),w("\\TeX","\\textrm{\\html@mathml{T\\kern-.1667em\\raisebox{-.5ex}{E}\\kern-.125emX}{TeX}}");var _n=ue(J["Main-Regular"]["T".charCodeAt(0)][1]-.7*J["Main-Regular"]["A".charCodeAt(0)][1]);w("\\LaTeX","\\textrm{\\html@mathml{L\\kern-.36em\\raisebox{"+_n+"}{\\scriptstyle A}\\kern-.15em\\TeX}{LaTeX}}"),w("\\KaTeX","\\textrm{\\html@mathml{K\\kern-.17em\\raisebox{"+_n+"}{\\scriptstyle A}\\kern-.15em\\TeX}{KaTeX}}"),w("\\hspace","\\@ifstar\\@hspacer\\@hspace"),w("\\@hspace","\\hskip #1\\relax"),w("\\@hspacer","\\rule{0pt}{0pt}\\hskip #1\\relax"),w("\\ordinarycolon",":"),w("\\vcentcolon","\\mathrel{\\mathop\\ordinarycolon}"),w("\\dblcolon",'\\html@mathml{\\mathrel{\\vcentcolon\\mathrel{\\mkern-.9mu}\\vcentcolon}}{\\mathop{\\char"2237}}'),w("\\coloneqq",'\\html@mathml{\\mathrel{\\vcentcolon\\mathrel{\\mkern-1.2mu}=}}{\\mathop{\\char"2254}}'),w("\\Coloneqq",'\\html@mathml{\\mathrel{\\dblcolon\\mathrel{\\mkern-1.2mu}=}}{\\mathop{\\char"2237\\char"3d}}'),w("\\coloneq",'\\html@mathml{\\mathrel{\\vcentcolon\\mathrel{\\mkern-1.2mu}\\mathrel{-}}}{\\mathop{\\char"3a\\char"2212}}'),w("\\Coloneq",'\\html@mathml{\\mathrel{\\dblcolon\\mathrel{\\mkern-1.2mu}\\mathrel{-}}}{\\mathop{\\char"2237\\char"2212}}'),w("\\eqqcolon",'\\html@mathml{\\mathrel{=\\mathrel{\\mkern-1.2mu}\\vcentcolon}}{\\mathop{\\char"2255}}'),w("\\Eqqcolon",'\\html@mathml{\\mathrel{=\\mathrel{\\mkern-1.2mu}\\dblcolon}}{\\mathop{\\char"3d\\char"2237}}'),w("\\eqcolon",'\\html@mathml{\\mathrel{\\mathrel{-}\\mathrel{\\mkern-1.2mu}\\vcentcolon}}{\\mathop{\\char"2239}}'),w("\\Eqcolon",'\\html@mathml{\\mathrel{\\mathrel{-}\\mathrel{\\mkern-1.2mu}\\dblcolon}}{\\mathop{\\char"2212\\char"2237}}'),w("\\colonapprox",'\\html@mathml{\\mathrel{\\vcentcolon\\mathrel{\\mkern-1.2mu}\\approx}}{\\mathop{\\char"3a\\char"2248}}'),w("\\Colonapprox",'\\html@mathml{\\mathrel{\\dblcolon\\mathrel{\\mkern-1.2mu}\\approx}}{\\mathop{\\char"2237\\char"2248}}'),w("\\colonsim",'\\html@mathml{\\mathrel{\\vcentcolon\\mathrel{\\mkern-1.2mu}\\sim}}{\\mathop{\\char"3a\\char"223c}}'),w("\\Colonsim",'\\html@mathml{\\mathrel{\\dblcolon\\mathrel{\\mkern-1.2mu}\\sim}}{\\mathop{\\char"2237\\char"223c}}'),w("\u2237","\\dblcolon"),w("\u2239","\\eqcolon"),w("\u2254","\\coloneqq"),w("\u2255","\\eqqcolon"),w("\u2a74","\\Coloneqq"),w("\\ratio","\\vcentcolon"),w("\\coloncolon","\\dblcolon"),w("\\colonequals","\\coloneqq"),w("\\coloncolonequals","\\Coloneqq"),w("\\equalscolon","\\eqqcolon"),w("\\equalscoloncolon","\\Eqqcolon"),w("\\colonminus","\\coloneq"),w("\\coloncolonminus","\\Coloneq"),w("\\minuscolon","\\eqcolon"),w("\\minuscoloncolon","\\Eqcolon"),w("\\coloncolonapprox","\\Colonapprox"),w("\\coloncolonsim","\\Colonsim"),w("\\simcolon","\\mathrel{\\sim\\mathrel{\\mkern-1.2mu}\\vcentcolon}"),w("\\simcoloncolon","\\mathrel{\\sim\\mathrel{\\mkern-1.2mu}\\dblcolon}"),w("\\approxcolon","\\mathrel{\\approx\\mathrel{\\mkern-1.2mu}\\vcentcolon}"),w("\\approxcoloncolon","\\mathrel{\\approx\\mathrel{\\mkern-1.2mu}\\dblcolon}"),w("\\notni","\\html@mathml{\\not\\ni}{\\mathrel{\\char`\u220c}}"),w("\\limsup","\\DOTSB\\operatorname*{lim\\,sup}"),w("\\liminf","\\DOTSB\\operatorname*{lim\\,inf}"),w("\\injlim","\\DOTSB\\operatorname*{inj\\,lim}"),w("\\projlim","\\DOTSB\\operatorname*{proj\\,lim}"),w("\\varlimsup","\\DOTSB\\operatorname*{\\overline{lim}}"),w("\\varliminf","\\DOTSB\\operatorname*{\\underline{lim}}"),w("\\varinjlim","\\DOTSB\\operatorname*{\\underrightarrow{lim}}"),w("\\varprojlim","\\DOTSB\\operatorname*{\\underleftarrow{lim}}"),w("\\gvertneqq","\\html@mathml{\\@gvertneqq}{\u2269}"),w("\\lvertneqq","\\html@mathml{\\@lvertneqq}{\u2268}"),w("\\ngeqq","\\html@mathml{\\@ngeqq}{\u2271}"),w("\\ngeqslant","\\html@mathml{\\@ngeqslant}{\u2271}"),w("\\nleqq","\\html@mathml{\\@nleqq}{\u2270}"),w("\\nleqslant","\\html@mathml{\\@nleqslant}{\u2270}"),w("\\nshortmid","\\html@mathml{\\@nshortmid}{\u2224}"),w("\\nshortparallel","\\html@mathml{\\@nshortparallel}{\u2226}"),w("\\nsubseteqq","\\html@mathml{\\@nsubseteqq}{\u2288}"),w("\\nsupseteqq","\\html@mathml{\\@nsupseteqq}{\u2289}"),w("\\varsubsetneq","\\html@mathml{\\@varsubsetneq}{\u228a}"),w("\\varsubsetneqq","\\html@mathml{\\@varsubsetneqq}{\u2acb}"),w("\\varsupsetneq","\\html@mathml{\\@varsupsetneq}{\u228b}"),w("\\varsupsetneqq","\\html@mathml{\\@varsupsetneqq}{\u2acc}"),w("\\imath","\\html@mathml{\\@imath}{\u0131}"),w("\\jmath","\\html@mathml{\\@jmath}{\u0237}"),w("\\llbracket","\\html@mathml{\\mathopen{[\\mkern-3.2mu[}}{\\mathopen{\\char`\u27e6}}"),w("\\rrbracket","\\html@mathml{\\mathclose{]\\mkern-3.2mu]}}{\\mathclose{\\char`\u27e7}}"),w("\u27e6","\\llbracket"),w("\u27e7","\\rrbracket"),w("\\lBrace","\\html@mathml{\\mathopen{\\{\\mkern-3.2mu[}}{\\mathopen{\\char`\u2983}}"),w("\\rBrace","\\html@mathml{\\mathclose{]\\mkern-3.2mu\\}}}{\\mathclose{\\char`\u2984}}"),w("\u2983","\\lBrace"),w("\u2984","\\rBrace"),w("\\minuso","\\mathbin{\\html@mathml{{\\mathrlap{\\mathchoice{\\kern{0.145em}}{\\kern{0.145em}}{\\kern{0.1015em}}{\\kern{0.0725em}}\\circ}{-}}}{\\char`\u29b5}}"),w("\u29b5","\\minuso"),w("\\darr","\\downarrow"),w("\\dArr","\\Downarrow"),w("\\Darr","\\Downarrow"),w("\\lang","\\langle"),w("\\rang","\\rangle"),w("\\uarr","\\uparrow"),w("\\uArr","\\Uparrow"),w("\\Uarr","\\Uparrow"),w("\\N","\\mathbb{N}"),w("\\R","\\mathbb{R}"),w("\\Z","\\mathbb{Z}"),w("\\alef","\\aleph"),w("\\alefsym","\\aleph"),w("\\Alpha","\\mathrm{A}"),w("\\Beta","\\mathrm{B}"),w("\\bull","\\bullet"),w("\\Chi","\\mathrm{X}"),w("\\clubs","\\clubsuit"),w("\\cnums","\\mathbb{C}"),w("\\Complex","\\mathbb{C}"),w("\\Dagger","\\ddagger"),w("\\diamonds","\\diamondsuit"),w("\\empty","\\emptyset"),w("\\Epsilon","\\mathrm{E}"),w("\\Eta","\\mathrm{H}"),w("\\exist","\\exists"),w("\\harr","\\leftrightarrow"),w("\\hArr","\\Leftrightarrow"),w("\\Harr","\\Leftrightarrow"),w("\\hearts","\\heartsuit"),w("\\image","\\Im"),w("\\infin","\\infty"),w("\\Iota","\\mathrm{I}"),w("\\isin","\\in"),w("\\Kappa","\\mathrm{K}"),w("\\larr","\\leftarrow"),w("\\lArr","\\Leftarrow"),w("\\Larr","\\Leftarrow"),w("\\lrarr","\\leftrightarrow"),w("\\lrArr","\\Leftrightarrow"),w("\\Lrarr","\\Leftrightarrow"),w("\\Mu","\\mathrm{M}"),w("\\natnums","\\mathbb{N}"),w("\\Nu","\\mathrm{N}"),w("\\Omicron","\\mathrm{O}"),w("\\plusmn","\\pm"),w("\\rarr","\\rightarrow"),w("\\rArr","\\Rightarrow"),w("\\Rarr","\\Rightarrow"),w("\\real","\\Re"),w("\\reals","\\mathbb{R}"),w("\\Reals","\\mathbb{R}"),w("\\Rho","\\mathrm{P}"),w("\\sdot","\\cdot"),w("\\sect","\\S"),w("\\spades","\\spadesuit"),w("\\sub","\\subset"),w("\\sube","\\subseteq"),w("\\supe","\\supseteq"),w("\\Tau","\\mathrm{T}"),w("\\thetasym","\\vartheta"),w("\\weierp","\\wp"),w("\\Zeta","\\mathrm{Z}"),w("\\argmin","\\DOTSB\\operatorname*{arg\\,min}"),w("\\argmax","\\DOTSB\\operatorname*{arg\\,max}"),w("\\plim","\\DOTSB\\mathop{\\operatorname{plim}}\\limits"),w("\\bra","\\mathinner{\\langle{#1}|}"),w("\\ket","\\mathinner{|{#1}\\rangle}"),w("\\braket","\\mathinner{\\langle{#1}\\rangle}"),w("\\Bra","\\left\\langle#1\\right|"),w("\\Ket","\\left|#1\\right\\rangle");var Wn=function(e){return function(t){var r=t.consumeArg().tokens,n=t.consumeArg().tokens,i=t.consumeArg().tokens,a=t.consumeArg().tokens,o=t.macros.get("|"),s=t.macros.get("\\|");t.macros.beginGroup();var l=function(t){return function(r){e&&(r.macros.set("|",o),i.length&&r.macros.set("\\|",s));var a=t;return!t&&i.length&&"|"===r.future().text&&(r.popToken(),a=!0),{tokens:a?i:n,numArgs:0}}};t.macros.set("|",l(!1)),i.length&&t.macros.set("\\|",l(!0));var c=t.consumeArg().tokens,u=t.expandTokens([].concat(a,c,r));return t.macros.endGroup(),{tokens:u.reverse(),numArgs:0}}};w("\\bra@ket",Wn(!1)),w("\\bra@set",Wn(!0)),w("\\Braket","\\bra@ket{\\left\\langle}{\\,\\middle\\vert\\,}{\\,\\middle\\vert\\,}{\\right\\rangle}"),w("\\Set","\\bra@set{\\left\\{\\:}{\\;\\middle\\vert\\;}{\\;\\middle\\Vert\\;}{\\:\\right\\}}"),w("\\set","\\bra@set{\\{\\,}{\\mid}{}{\\,\\}}"),w("\\angln","{\\angl n}"),w("\\blue","\\textcolor{##6495ed}{#1}"),w("\\orange","\\textcolor{##ffa500}{#1}"),w("\\pink","\\textcolor{##ff00af}{#1}"),w("\\red","\\textcolor{##df0030}{#1}"),w("\\green","\\textcolor{##28ae7b}{#1}"),w("\\gray","\\textcolor{gray}{#1}"),w("\\purple","\\textcolor{##9d38bd}{#1}"),w("\\blueA","\\textcolor{##ccfaff}{#1}"),w("\\blueB","\\textcolor{##80f6ff}{#1}"),w("\\blueC","\\textcolor{##63d9ea}{#1}"),w("\\blueD","\\textcolor{##11accd}{#1}"),w("\\blueE","\\textcolor{##0c7f99}{#1}"),w("\\tealA","\\textcolor{##94fff5}{#1}"),w("\\tealB","\\textcolor{##26edd5}{#1}"),w("\\tealC","\\textcolor{##01d1c1}{#1}"),w("\\tealD","\\textcolor{##01a995}{#1}"),w("\\tealE","\\textcolor{##208170}{#1}"),w("\\greenA","\\textcolor{##b6ffb0}{#1}"),w("\\greenB","\\textcolor{##8af281}{#1}"),w("\\greenC","\\textcolor{##74cf70}{#1}"),w("\\greenD","\\textcolor{##1fab54}{#1}"),w("\\greenE","\\textcolor{##0d923f}{#1}"),w("\\goldA","\\textcolor{##ffd0a9}{#1}"),w("\\goldB","\\textcolor{##ffbb71}{#1}"),w("\\goldC","\\textcolor{##ff9c39}{#1}"),w("\\goldD","\\textcolor{##e07d10}{#1}"),w("\\goldE","\\textcolor{##a75a05}{#1}"),w("\\redA","\\textcolor{##fca9a9}{#1}"),w("\\redB","\\textcolor{##ff8482}{#1}"),w("\\redC","\\textcolor{##f9685d}{#1}"),w("\\redD","\\textcolor{##e84d39}{#1}"),w("\\redE","\\textcolor{##bc2612}{#1}"),w("\\maroonA","\\textcolor{##ffbde0}{#1}"),w("\\maroonB","\\textcolor{##ff92c6}{#1}"),w("\\maroonC","\\textcolor{##ed5fa6}{#1}"),w("\\maroonD","\\textcolor{##ca337c}{#1}"),w("\\maroonE","\\textcolor{##9e034e}{#1}"),w("\\purpleA","\\textcolor{##ddd7ff}{#1}"),w("\\purpleB","\\textcolor{##c6b9fc}{#1}"),w("\\purpleC","\\textcolor{##aa87ff}{#1}"),w("\\purpleD","\\textcolor{##7854ab}{#1}"),w("\\purpleE","\\textcolor{##543b78}{#1}"),w("\\mintA","\\textcolor{##f5f9e8}{#1}"),w("\\mintB","\\textcolor{##edf2df}{#1}"),w("\\mintC","\\textcolor{##e0e5cc}{#1}"),w("\\grayA","\\textcolor{##f6f7f7}{#1}"),w("\\grayB","\\textcolor{##f0f1f2}{#1}"),w("\\grayC","\\textcolor{##e3e5e6}{#1}"),w("\\grayD","\\textcolor{##d6d8da}{#1}"),w("\\grayE","\\textcolor{##babec2}{#1}"),w("\\grayF","\\textcolor{##888d93}{#1}"),w("\\grayG","\\textcolor{##626569}{#1}"),w("\\grayH","\\textcolor{##3b3e40}{#1}"),w("\\grayI","\\textcolor{##21242c}{#1}"),w("\\kaBlue","\\textcolor{##314453}{#1}"),w("\\kaGreen","\\textcolor{##71B307}{#1}");var Gn={"^":!0,_:!0,"\\limits":!0,"\\nolimits":!0},Yn=function(){function e(e,t,r){this.settings=void 0,this.expansionCount=void 0,this.lexer=void 0,this.macros=void 0,this.stack=void 0,this.mode=void 0,this.settings=t,this.expansionCount=0,this.feed(e),this.macros=new Dn(Hn,t.macros),this.mode=r,this.stack=[]}var t=e.prototype;return t.feed=function(e){this.lexer=new qn(e,this.settings)},t.switchMode=function(e){this.mode=e},t.beginGroup=function(){this.macros.beginGroup()},t.endGroup=function(){this.macros.endGroup()},t.endGroups=function(){this.macros.endGroups()},t.future=function(){return 0===this.stack.length&&this.pushToken(this.lexer.lex()),this.stack[this.stack.length-1]},t.popToken=function(){return this.future(),this.stack.pop()},t.pushToken=function(e){this.stack.push(e)},t.pushTokens=function(e){var t;(t=this.stack).push.apply(t,e)},t.scanArgument=function(e){var t,r,n;if(e){if(this.consumeSpaces(),"["!==this.future().text)return null;t=this.popToken();var i=this.consumeArg(["]"]);n=i.tokens,r=i.end}else{var a=this.consumeArg();n=a.tokens,t=a.start,r=a.end}return this.pushToken(new Yr("EOF",r.loc)),this.pushTokens(n),t.range(r,"")},t.consumeSpaces=function(){for(;" "===this.future().text;)this.stack.pop()},t.consumeArg=function(e){var t=[],r=e&&e.length>0;r||this.consumeSpaces();var n,i=this.future(),a=0,o=0;do{if(n=this.popToken(),t.push(n),"{"===n.text)++a;else if("}"===n.text){if(-1==--a)throw new C("Extra }",n)}else if("EOF"===n.text)throw new C("Unexpected end of input in a macro argument, expected '"+(e&&r?e[o]:"}")+"'",n);if(e&&r)if((0===a||1===a&&"{"===e[o])&&n.text===e[o]){if(++o===e.length){t.splice(-o,o);break}}else o=0}while(0!==a||r);return"{"===i.text&&"}"===t[t.length-1].text&&(t.pop(),t.shift()),t.reverse(),{tokens:t,start:i,end:n}},t.consumeArgs=function(e,t){if(t){if(t.length!==e+1)throw new C("The length of delimiters doesn't match the number of args!");for(var r=t[0],n=0;nthis.settings.maxExpand)throw new C("Too many expansions: infinite loop or need to increase maxExpand setting");var i=n.tokens,a=this.consumeArgs(n.numArgs,n.delimiters);if(n.numArgs)for(var o=(i=i.slice()).length-1;o>=0;--o){var s=i[o];if("#"===s.text){if(0===o)throw new C("Incomplete placeholder at end of macro body",s);if("#"===(s=i[--o]).text)i.splice(o+1,1);else{if(!/^[1-9]$/.test(s.text))throw new C("Not a valid argument number",s);var l;(l=i).splice.apply(l,[o,2].concat(a[+s.text-1]))}}}return this.pushTokens(i),i.length},t.expandAfterFuture=function(){return this.expandOnce(),this.future()},t.expandNextToken=function(){for(;;)if(!1===this.expandOnce()){var e=this.stack.pop();return e.treatAsRelax&&(e.text="\\relax"),e}throw new Error},t.expandMacro=function(e){return this.macros.has(e)?this.expandTokens([new Yr(e)]):void 0},t.expandTokens=function(e){var t=[],r=this.stack.length;for(this.pushTokens(e);this.stack.length>r;)if(!1===this.expandOnce(!0)){var n=this.stack.pop();n.treatAsRelax&&(n.noexpand=!1,n.treatAsRelax=!1),t.push(n)}return t},t.expandMacroAsText=function(e){var t=this.expandMacro(e);return t?t.map(function(e){return e.text}).join(""):t},t._getExpansion=function(e){var t=this.macros.get(e);if(null==t)return t;if(1===e.length){var r=this.lexer.catcodes[e];if(null!=r&&13!==r)return}var n="function"==typeof t?t(this):t;if("string"==typeof n){var i=0;if(-1!==n.indexOf("#"))for(var a=n.replace(/##/g,"");-1!==a.indexOf("#"+(i+1));)++i;for(var o=new qn(n,this.settings),s=[],l=o.lex();"EOF"!==l.text;)s.push(l),l=o.lex();return s.reverse(),{tokens:s,numArgs:i}}return n},t.isDefined=function(e){return this.macros.has(e)||zn.hasOwnProperty(e)||Me.math.hasOwnProperty(e)||Me.text.hasOwnProperty(e)||Gn.hasOwnProperty(e)},t.isExpandable=function(e){var t=this.macros.get(e);return null!=t?"string"==typeof t||"function"==typeof t||!t.unexpandable:zn.hasOwnProperty(e)&&!zn[e].primitive},e}(),Xn=/^[\u208a\u208b\u208c\u208d\u208e\u2080\u2081\u2082\u2083\u2084\u2085\u2086\u2087\u2088\u2089\u2090\u2091\u2095\u1d62\u2c7c\u2096\u2097\u2098\u2099\u2092\u209a\u1d63\u209b\u209c\u1d64\u1d65\u2093\u1d66\u1d67\u1d68\u1d69\u1d6a]/,$n=Object.freeze({"\u208a":"+","\u208b":"-","\u208c":"=","\u208d":"(","\u208e":")","\u2080":"0","\u2081":"1","\u2082":"2","\u2083":"3","\u2084":"4","\u2085":"5","\u2086":"6","\u2087":"7","\u2088":"8","\u2089":"9","\u2090":"a","\u2091":"e","\u2095":"h","\u1d62":"i","\u2c7c":"j","\u2096":"k","\u2097":"l","\u2098":"m","\u2099":"n","\u2092":"o","\u209a":"p","\u1d63":"r","\u209b":"s","\u209c":"t","\u1d64":"u","\u1d65":"v","\u2093":"x","\u1d66":"\u03b2","\u1d67":"\u03b3","\u1d68":"\u03c1","\u1d69":"\u03d5","\u1d6a":"\u03c7","\u207a":"+","\u207b":"-","\u207c":"=","\u207d":"(","\u207e":")","\u2070":"0","\xb9":"1","\xb2":"2","\xb3":"3","\u2074":"4","\u2075":"5","\u2076":"6","\u2077":"7","\u2078":"8","\u2079":"9","\u1d2c":"A","\u1d2e":"B","\u1d30":"D","\u1d31":"E","\u1d33":"G","\u1d34":"H","\u1d35":"I","\u1d36":"J","\u1d37":"K","\u1d38":"L","\u1d39":"M","\u1d3a":"N","\u1d3c":"O","\u1d3e":"P","\u1d3f":"R","\u1d40":"T","\u1d41":"U","\u2c7d":"V","\u1d42":"W","\u1d43":"a","\u1d47":"b","\u1d9c":"c","\u1d48":"d","\u1d49":"e","\u1da0":"f","\u1d4d":"g","\u02b0":"h","\u2071":"i","\u02b2":"j","\u1d4f":"k","\u02e1":"l","\u1d50":"m","\u207f":"n","\u1d52":"o","\u1d56":"p","\u02b3":"r","\u02e2":"s","\u1d57":"t","\u1d58":"u","\u1d5b":"v","\u02b7":"w","\u02e3":"x","\u02b8":"y","\u1dbb":"z","\u1d5d":"\u03b2","\u1d5e":"\u03b3","\u1d5f":"\u03b4","\u1d60":"\u03d5","\u1d61":"\u03c7","\u1dbf":"\u03b8"}),Kn={"\u0301":{text:"\\'",math:"\\acute"},"\u0300":{text:"\\`",math:"\\grave"},"\u0308":{text:'\\"',math:"\\ddot"},"\u0303":{text:"\\~",math:"\\tilde"},"\u0304":{text:"\\=",math:"\\bar"},"\u0306":{text:"\\u",math:"\\breve"},"\u030c":{text:"\\v",math:"\\check"},"\u0302":{text:"\\^",math:"\\hat"},"\u0307":{text:"\\.",math:"\\dot"},"\u030a":{text:"\\r",math:"\\mathring"},"\u030b":{text:"\\H"},"\u0327":{text:"\\c"}},Jn={"\xe1":"a\u0301","\xe0":"a\u0300","\xe4":"a\u0308","\u01df":"a\u0308\u0304","\xe3":"a\u0303","\u0101":"a\u0304","\u0103":"a\u0306","\u1eaf":"a\u0306\u0301","\u1eb1":"a\u0306\u0300","\u1eb5":"a\u0306\u0303","\u01ce":"a\u030c","\xe2":"a\u0302","\u1ea5":"a\u0302\u0301","\u1ea7":"a\u0302\u0300","\u1eab":"a\u0302\u0303","\u0227":"a\u0307","\u01e1":"a\u0307\u0304","\xe5":"a\u030a","\u01fb":"a\u030a\u0301","\u1e03":"b\u0307","\u0107":"c\u0301","\u1e09":"c\u0327\u0301","\u010d":"c\u030c","\u0109":"c\u0302","\u010b":"c\u0307","\xe7":"c\u0327","\u010f":"d\u030c","\u1e0b":"d\u0307","\u1e11":"d\u0327","\xe9":"e\u0301","\xe8":"e\u0300","\xeb":"e\u0308","\u1ebd":"e\u0303","\u0113":"e\u0304","\u1e17":"e\u0304\u0301","\u1e15":"e\u0304\u0300","\u0115":"e\u0306","\u1e1d":"e\u0327\u0306","\u011b":"e\u030c","\xea":"e\u0302","\u1ebf":"e\u0302\u0301","\u1ec1":"e\u0302\u0300","\u1ec5":"e\u0302\u0303","\u0117":"e\u0307","\u0229":"e\u0327","\u1e1f":"f\u0307","\u01f5":"g\u0301","\u1e21":"g\u0304","\u011f":"g\u0306","\u01e7":"g\u030c","\u011d":"g\u0302","\u0121":"g\u0307","\u0123":"g\u0327","\u1e27":"h\u0308","\u021f":"h\u030c","\u0125":"h\u0302","\u1e23":"h\u0307","\u1e29":"h\u0327","\xed":"i\u0301","\xec":"i\u0300","\xef":"i\u0308","\u1e2f":"i\u0308\u0301","\u0129":"i\u0303","\u012b":"i\u0304","\u012d":"i\u0306","\u01d0":"i\u030c","\xee":"i\u0302","\u01f0":"j\u030c","\u0135":"j\u0302","\u1e31":"k\u0301","\u01e9":"k\u030c","\u0137":"k\u0327","\u013a":"l\u0301","\u013e":"l\u030c","\u013c":"l\u0327","\u1e3f":"m\u0301","\u1e41":"m\u0307","\u0144":"n\u0301","\u01f9":"n\u0300","\xf1":"n\u0303","\u0148":"n\u030c","\u1e45":"n\u0307","\u0146":"n\u0327","\xf3":"o\u0301","\xf2":"o\u0300","\xf6":"o\u0308","\u022b":"o\u0308\u0304","\xf5":"o\u0303","\u1e4d":"o\u0303\u0301","\u1e4f":"o\u0303\u0308","\u022d":"o\u0303\u0304","\u014d":"o\u0304","\u1e53":"o\u0304\u0301","\u1e51":"o\u0304\u0300","\u014f":"o\u0306","\u01d2":"o\u030c","\xf4":"o\u0302","\u1ed1":"o\u0302\u0301","\u1ed3":"o\u0302\u0300","\u1ed7":"o\u0302\u0303","\u022f":"o\u0307","\u0231":"o\u0307\u0304","\u0151":"o\u030b","\u1e55":"p\u0301","\u1e57":"p\u0307","\u0155":"r\u0301","\u0159":"r\u030c","\u1e59":"r\u0307","\u0157":"r\u0327","\u015b":"s\u0301","\u1e65":"s\u0301\u0307","\u0161":"s\u030c","\u1e67":"s\u030c\u0307","\u015d":"s\u0302","\u1e61":"s\u0307","\u015f":"s\u0327","\u1e97":"t\u0308","\u0165":"t\u030c","\u1e6b":"t\u0307","\u0163":"t\u0327","\xfa":"u\u0301","\xf9":"u\u0300","\xfc":"u\u0308","\u01d8":"u\u0308\u0301","\u01dc":"u\u0308\u0300","\u01d6":"u\u0308\u0304","\u01da":"u\u0308\u030c","\u0169":"u\u0303","\u1e79":"u\u0303\u0301","\u016b":"u\u0304","\u1e7b":"u\u0304\u0308","\u016d":"u\u0306","\u01d4":"u\u030c","\xfb":"u\u0302","\u016f":"u\u030a","\u0171":"u\u030b","\u1e7d":"v\u0303","\u1e83":"w\u0301","\u1e81":"w\u0300","\u1e85":"w\u0308","\u0175":"w\u0302","\u1e87":"w\u0307","\u1e98":"w\u030a","\u1e8d":"x\u0308","\u1e8b":"x\u0307","\xfd":"y\u0301","\u1ef3":"y\u0300","\xff":"y\u0308","\u1ef9":"y\u0303","\u0233":"y\u0304","\u0177":"y\u0302","\u1e8f":"y\u0307","\u1e99":"y\u030a","\u017a":"z\u0301","\u017e":"z\u030c","\u1e91":"z\u0302","\u017c":"z\u0307","\xc1":"A\u0301","\xc0":"A\u0300","\xc4":"A\u0308","\u01de":"A\u0308\u0304","\xc3":"A\u0303","\u0100":"A\u0304","\u0102":"A\u0306","\u1eae":"A\u0306\u0301","\u1eb0":"A\u0306\u0300","\u1eb4":"A\u0306\u0303","\u01cd":"A\u030c","\xc2":"A\u0302","\u1ea4":"A\u0302\u0301","\u1ea6":"A\u0302\u0300","\u1eaa":"A\u0302\u0303","\u0226":"A\u0307","\u01e0":"A\u0307\u0304","\xc5":"A\u030a","\u01fa":"A\u030a\u0301","\u1e02":"B\u0307","\u0106":"C\u0301","\u1e08":"C\u0327\u0301","\u010c":"C\u030c","\u0108":"C\u0302","\u010a":"C\u0307","\xc7":"C\u0327","\u010e":"D\u030c","\u1e0a":"D\u0307","\u1e10":"D\u0327","\xc9":"E\u0301","\xc8":"E\u0300","\xcb":"E\u0308","\u1ebc":"E\u0303","\u0112":"E\u0304","\u1e16":"E\u0304\u0301","\u1e14":"E\u0304\u0300","\u0114":"E\u0306","\u1e1c":"E\u0327\u0306","\u011a":"E\u030c","\xca":"E\u0302","\u1ebe":"E\u0302\u0301","\u1ec0":"E\u0302\u0300","\u1ec4":"E\u0302\u0303","\u0116":"E\u0307","\u0228":"E\u0327","\u1e1e":"F\u0307","\u01f4":"G\u0301","\u1e20":"G\u0304","\u011e":"G\u0306","\u01e6":"G\u030c","\u011c":"G\u0302","\u0120":"G\u0307","\u0122":"G\u0327","\u1e26":"H\u0308","\u021e":"H\u030c","\u0124":"H\u0302","\u1e22":"H\u0307","\u1e28":"H\u0327","\xcd":"I\u0301","\xcc":"I\u0300","\xcf":"I\u0308","\u1e2e":"I\u0308\u0301","\u0128":"I\u0303","\u012a":"I\u0304","\u012c":"I\u0306","\u01cf":"I\u030c","\xce":"I\u0302","\u0130":"I\u0307","\u0134":"J\u0302","\u1e30":"K\u0301","\u01e8":"K\u030c","\u0136":"K\u0327","\u0139":"L\u0301","\u013d":"L\u030c","\u013b":"L\u0327","\u1e3e":"M\u0301","\u1e40":"M\u0307","\u0143":"N\u0301","\u01f8":"N\u0300","\xd1":"N\u0303","\u0147":"N\u030c","\u1e44":"N\u0307","\u0145":"N\u0327","\xd3":"O\u0301","\xd2":"O\u0300","\xd6":"O\u0308","\u022a":"O\u0308\u0304","\xd5":"O\u0303","\u1e4c":"O\u0303\u0301","\u1e4e":"O\u0303\u0308","\u022c":"O\u0303\u0304","\u014c":"O\u0304","\u1e52":"O\u0304\u0301","\u1e50":"O\u0304\u0300","\u014e":"O\u0306","\u01d1":"O\u030c","\xd4":"O\u0302","\u1ed0":"O\u0302\u0301","\u1ed2":"O\u0302\u0300","\u1ed6":"O\u0302\u0303","\u022e":"O\u0307","\u0230":"O\u0307\u0304","\u0150":"O\u030b","\u1e54":"P\u0301","\u1e56":"P\u0307","\u0154":"R\u0301","\u0158":"R\u030c","\u1e58":"R\u0307","\u0156":"R\u0327", +"\u015a":"S\u0301","\u1e64":"S\u0301\u0307","\u0160":"S\u030c","\u1e66":"S\u030c\u0307","\u015c":"S\u0302","\u1e60":"S\u0307","\u015e":"S\u0327","\u0164":"T\u030c","\u1e6a":"T\u0307","\u0162":"T\u0327","\xda":"U\u0301","\xd9":"U\u0300","\xdc":"U\u0308","\u01d7":"U\u0308\u0301","\u01db":"U\u0308\u0300","\u01d5":"U\u0308\u0304","\u01d9":"U\u0308\u030c","\u0168":"U\u0303","\u1e78":"U\u0303\u0301","\u016a":"U\u0304","\u1e7a":"U\u0304\u0308","\u016c":"U\u0306","\u01d3":"U\u030c","\xdb":"U\u0302","\u016e":"U\u030a","\u0170":"U\u030b","\u1e7c":"V\u0303","\u1e82":"W\u0301","\u1e80":"W\u0300","\u1e84":"W\u0308","\u0174":"W\u0302","\u1e86":"W\u0307","\u1e8c":"X\u0308","\u1e8a":"X\u0307","\xdd":"Y\u0301","\u1ef2":"Y\u0300","\u0178":"Y\u0308","\u1ef8":"Y\u0303","\u0232":"Y\u0304","\u0176":"Y\u0302","\u1e8e":"Y\u0307","\u0179":"Z\u0301","\u017d":"Z\u030c","\u1e90":"Z\u0302","\u017b":"Z\u0307","\u03ac":"\u03b1\u0301","\u1f70":"\u03b1\u0300","\u1fb1":"\u03b1\u0304","\u1fb0":"\u03b1\u0306","\u03ad":"\u03b5\u0301","\u1f72":"\u03b5\u0300","\u03ae":"\u03b7\u0301","\u1f74":"\u03b7\u0300","\u03af":"\u03b9\u0301","\u1f76":"\u03b9\u0300","\u03ca":"\u03b9\u0308","\u0390":"\u03b9\u0308\u0301","\u1fd2":"\u03b9\u0308\u0300","\u1fd1":"\u03b9\u0304","\u1fd0":"\u03b9\u0306","\u03cc":"\u03bf\u0301","\u1f78":"\u03bf\u0300","\u03cd":"\u03c5\u0301","\u1f7a":"\u03c5\u0300","\u03cb":"\u03c5\u0308","\u03b0":"\u03c5\u0308\u0301","\u1fe2":"\u03c5\u0308\u0300","\u1fe1":"\u03c5\u0304","\u1fe0":"\u03c5\u0306","\u03ce":"\u03c9\u0301","\u1f7c":"\u03c9\u0300","\u038e":"\u03a5\u0301","\u1fea":"\u03a5\u0300","\u03ab":"\u03a5\u0308","\u1fe9":"\u03a5\u0304","\u1fe8":"\u03a5\u0306","\u038f":"\u03a9\u0301","\u1ffa":"\u03a9\u0300"},Zn=function(){function e(e,t){this.mode=void 0,this.gullet=void 0,this.settings=void 0,this.leftrightDepth=void 0,this.nextToken=void 0,this.mode="math",this.gullet=new Yn(e,t,this.mode),this.settings=t,this.leftrightDepth=0}var r=e.prototype;return r.expect=function(e,t){if(void 0===t&&(t=!0),this.fetch().text!==e)throw new C("Expected '"+e+"', got '"+this.fetch().text+"'",this.fetch());t&&this.consume()},r.consume=function(){this.nextToken=null},r.fetch=function(){return null==this.nextToken&&(this.nextToken=this.gullet.expandNextToken()),this.nextToken},r.switchMode=function(e){this.mode=e,this.gullet.switchMode(e)},r.parse=function(){this.settings.globalGroup||this.gullet.beginGroup(),this.settings.colorIsTextColor&&this.gullet.macros.set("\\color","\\textcolor");try{var e=this.parseExpression(!1);return this.expect("EOF"),this.settings.globalGroup||this.gullet.endGroup(),e}finally{this.gullet.endGroups()}},r.subparse=function(e){var t=this.nextToken;this.consume(),this.gullet.pushToken(new Yr("}")),this.gullet.pushTokens(e);var r=this.parseExpression(!1);return this.expect("}"),this.nextToken=t,r},r.parseExpression=function(t,r){for(var n=[];;){"math"===this.mode&&this.consumeSpaces();var i=this.fetch();if(-1!==e.endOfExpression.indexOf(i.text))break;if(r&&i.text===r)break;if(t&&zn[i.text]&&zn[i.text].infix)break;var a=this.parseAtom(r);if(!a)break;"internal"!==a.type&&n.push(a)}return"text"===this.mode&&this.formLigatures(n),this.handleInfixNodes(n)},r.handleInfixNodes=function(e){for(var t,r=-1,n=0;n=0&&this.settings.reportNonstrict("unicodeTextInMathMode",'Latin-1/Unicode text character "'+r[0]+'" used in math mode',e);var s,l=Me[this.mode][r].group,c=Gr.range(e);if(Se.hasOwnProperty(l)){var u=l;s={type:"atom",mode:this.mode,family:u,loc:c,text:r}}else s={type:l,mode:this.mode,loc:c,text:r};a=s}else{if(!(r.charCodeAt(0)>=128))return null;this.settings.strict&&(t(r.charCodeAt(0))?"math"===this.mode&&this.settings.reportNonstrict("unicodeTextInMathMode",'Unicode text character "'+r[0]+'" used in math mode',e):this.settings.reportNonstrict("unknownSymbol",'Unrecognized Unicode character "'+r[0]+'" ('+r.charCodeAt(0)+")",e)),a={type:"textord",mode:"text",loc:Gr.range(e),text:r}}if(this.consume(),o)for(var h=0;h0&&(i.push({type:"text",data:e.slice(0,r)}),e=e.slice(r));var s=t.findIndex(function(t){return e.startsWith(t.left)});if(-1===(r=n(t[s].right,e,t[s].left.length)))break;var l=e.slice(0,r+t[s].right.length),c=a.test(l)?l:e.slice(t[s].left.length,r);i.push({type:"math",data:c,rawData:l,display:t[s].display}),e=e.slice(r+t[s].right.length)}return""!==e&&i.push({type:"text",data:e}),i},s=function(e,t){var n=o(e,t.delimiters);if(1===n.length&&"text"===n[0].type)return null;for(var i=document.createDocumentFragment(),a=0;a=0;--n){var i=this.tryEntries[n],a=i.completion;if("root"===i.tryLoc)return t("end");if(i.tryLoc<=this.prev){var o=y.call(i,"catchLoc"),s=y.call(i,"finallyLoc");if(o&&s){if(this.prev=0;--r){var n=this.tryEntries[r];if(n.tryLoc<=this.prev&&y.call(n,"finallyLoc")&&this.prev=0;--t){var r=this.tryEntries[t];if(r.finallyLoc===e)return this.complete(r.completion,r.afterLoc),d(r),R}},"catch":function(e){for(var t=this.tryEntries.length-1;t>=0;--t){var r=this.tryEntries[t];if(r.tryLoc===e){var n=r.completion;if("throw"===n.type){var i=n.arg;d(r)}return i}}throw new Error("illegal catch attempt")},delegateYield:function(e,t,r){return this.delegate={iterator:m(e),resultName:t,nextLoc:r},"next"===this.method&&(this.arg=f),R}},g}function n(e){return(n="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e})(e)}function i(e,t,r,n,i,a,o){try{var s=e[a](o),l=s.value}catch(e){return void r(e)}s.done?t(l):Promise.resolve(l).then(n,i)}function a(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}function o(e,t){for(var r=0;re.length)&&(t=e.length);for(var r=0,n=new Array(t);r3&&void 0!==arguments[3]?arguments[3]:"",i=e.querySelectorAll("."+r),a=0;aResume presentation':null),Ce.statusElement=((e=Ce.wrapper.querySelector(".aria-status"))||((e=document.createElement("div")).style.position="absolute",e.style.height="1px",e.style.width="1px",e.style.overflow="hidden",e.style.clip="rect( 1px, 1px, 1px, 1px )",e.classList.add("aria-status"),e.setAttribute("aria-live","polite"),e.setAttribute("aria-atomic","true"),Ce.wrapper.appendChild(e)),e),Ce.wrapper.setAttribute("role","application")}(),Se.postMessage&&window.addEventListener("message",ce,!1),setInterval(function(){(!Fe.isActive()&&0!==Ce.wrapper.scrollTop||0!==Ce.wrapper.scrollLeft)&&(Ce.wrapper.scrollTop=0,Ce.wrapper.scrollLeft=0)},1e3),document.addEventListener("fullscreenchange",me),document.addEventListener("webkitfullscreenchange",me),G().forEach(function(e){fb(e,"section").forEach(function(e,t){t>0&&(e.classList.remove("present"),e.classList.remove("past"),e.classList.add("future"),e.setAttribute("aria-hidden","true"))})}),s(),He.update(!0),e="print"===Se.view,t="scroll"===Se.view||"reader"===Se.view,(e||t)&&(e?c():Je.unbind(),Ce.viewport.classList.add("loading-scroll-mode"),e?"complete"===document.readyState?je.activate():window.addEventListener("load",function(){return je.activate()}):Fe.activate()),We.readURL(),setTimeout(function(){Ce.slides.classList.remove("no-transition"),Ce.wrapper.classList.add("ready"),p({type:"ready",data:{indexh:ve,indexv:ye,currentSlide:we}})},1)}function a(e){Ce.statusElement.textContent=e}function o(e){var t="";if(3===e.nodeType)t+=e.textContent;else if(1===e.nodeType){var r=e.getAttribute("aria-hidden"),n="none"===window.getComputedStyle(e).display;"true"===r||n||Array.from(e.childNodes).forEach(function(e){t+=o(e)})}return""===(t=t.trim())?"":t+" "}function s(e){var r=t({},Se);if("object"===n(e)&&mb(Se,e),!1!==ke.isReady()){var i=Ce.wrapper.querySelectorAll(Cb).length;Ce.wrapper.classList.remove(r.transition),Ce.wrapper.classList.add(Se.transition),Ce.wrapper.setAttribute("data-transition-speed",Se.transitionSpeed),Ce.wrapper.setAttribute("data-background-transition",Se.backgroundTransition),Ce.viewport.style.setProperty("--slide-width","string"==typeof Se.width?Se.width:Se.width+"px"),Ce.viewport.style.setProperty("--slide-height","string"==typeof Se.height?Se.height:Se.height+"px"),Se.shuffle&&q(),gb(Ce.wrapper,"embedded",Se.embedded),gb(Ce.wrapper,"rtl",Se.rtl),gb(Ce.wrapper,"center",Se.center),!1===Se.pause&&z(),Se.previewLinks?(g(),v("[data-preview-link=false]")):(v(),g("[data-preview-link]:not([data-preview-link=false])")),De.reset(),xe&&(xe.destroy(),xe=null),i>1&&Se.autoSlide&&Se.autoSlideStoppable&&((xe=new Rk(Ce.wrapper,function(){return Math.min(Math.max((Date.now()-Ie)/ze,0),1)})).on("click",ge),Be=!1),"default"!==Se.navigationMode?Ce.wrapper.setAttribute("data-navigation-mode",Se.navigationMode):Ce.wrapper.removeAttribute("data-navigation-mode"),Ze.configure(Se,r),Ke.configure(Se,r),Xe.configure(Se,r),Ge.configure(Se,r),Ye.configure(Se,r),_e.configure(Se,r),Ve.configure(Se,r),Oe.configure(Se,r),O()}}function l(){window.addEventListener("resize",de,!1),Se.touch&&Je.bind(),Se.keyboard&&_e.bind(),Se.progress&&Ye.bind(),Se.respondToHashChanges&&We.bind(),Ge.bind(),Ke.bind(),Ce.slides.addEventListener("click",he,!1),Ce.slides.addEventListener("transitionend",ue,!1),Ce.pauseOverlay.addEventListener("click",z,!1),Se.focusBodyOnPageVisibilityChange&&document.addEventListener("visibilitychange",pe,!1)}function c(){Je.unbind(),Ke.unbind(),_e.unbind(),Ge.unbind(),Ye.unbind(),We.unbind(),window.removeEventListener("resize",de,!1),Ce.slides.removeEventListener("click",he,!1),Ce.slides.removeEventListener("transitionend",ue,!1),Ce.pauseOverlay.removeEventListener("click",z,!1)}function u(t,r,n){e.addEventListener(t,r,n)}function h(t,r,n){e.removeEventListener(t,r,n)}function d(e){"string"==typeof e.layout&&(Re.layout=e.layout),"string"==typeof e.overview&&(Re.overview=e.overview),Re.layout?yb(Ce.slides,Re.layout+" "+Re.overview):yb(Ce.slides,Re.overview)}function p(e){var t=e.target,r=void 0===t?Ce.wrapper:t,n=e.type,i=e.data,a=e.bubbles,o=void 0===a||a,s=document.createEvent("HTMLEvents",1,2);return s.initEvent(n,o,!0),mb(s,i),r.dispatchEvent(s),r===Ce.wrapper&&f(n),s}function m(e){p({type:"slidechanged",data:{indexh:ve,indexv:ye,previousSlide:be,currentSlide:we,origin:e}})}function f(e,t){if(Se.postMessageEvents&&window.parent!==window.self){var r={namespace:"reveal",eventName:e,state:Z()};mb(r,t),window.parent.postMessage(JSON.stringify(r),"*")}}function g(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:"a";Array.from(Ce.wrapper.querySelectorAll(e)).forEach(function(e){/^(http|www)/gi.test(e.getAttribute("href"))&&e.addEventListener("click",fe,!1)})}function v(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:"a";Array.from(Ce.wrapper.querySelectorAll(e)).forEach(function(e){/^(http|www)/gi.test(e.getAttribute("href"))&&e.removeEventListener("click",fe,!1)})}function y(e){w(),Ce.overlay=document.createElement("div"),Ce.overlay.classList.add("overlay"),Ce.overlay.classList.add("overlay-preview"),Ce.wrapper.appendChild(Ce.overlay), +Ce.overlay.innerHTML='
\n\t\t\t\t\n\t\t\t\t\n\t\t\t
\n\t\t\t
\n\t\t\t
\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\tUnable to load iframe. This is likely due to the site\'s policy (x-frame-options).\n\t\t\t\t\n\t\t\t
'),Ce.overlay.querySelector("iframe").addEventListener("load",function(){Ce.overlay.classList.add("loaded")},!1),Ce.overlay.querySelector(".close").addEventListener("click",function(e){w(),e.preventDefault()},!1),Ce.overlay.querySelector(".external").addEventListener("click",function(){w()},!1)}function b(){if(Se.help){w(),Ce.overlay=document.createElement("div"),Ce.overlay.classList.add("overlay"),Ce.overlay.classList.add("overlay-help"),Ce.wrapper.appendChild(Ce.overlay);var e='

Keyboard Shortcuts


',t=_e.getShortcuts(),r=_e.getBindings();for(var n in e+="",t)e+="");for(var i in r)r[i].key&&r[i].description&&(e+=""));e+="
KEYACTION
".concat(n,"").concat(t[n],"
".concat(r[i].key,"").concat(r[i].description,"
",Ce.overlay.innerHTML='\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t
'.concat(e,"
\n\t\t\t\t
\n\t\t\t"),Ce.overlay.querySelector(".close").addEventListener("click",function(e){w(),e.preventDefault()},!1)}}function w(){return!!Ce.overlay&&(Ce.overlay.parentNode.removeChild(Ce.overlay),Ce.overlay=null,!0)}function x(){if(Ce.wrapper&&!je.isActive()){var e=Ce.viewport.offsetWidth,t=Ce.viewport.offsetHeight;if(!Se.disableLayout){Eb&&!Se.embedded&&document.documentElement.style.setProperty("--vh",.01*window.innerHeight+"px");var r=Fe.isActive()?S(e,t):S(),n=Te;k(Se.width,Se.height),Ce.slides.style.width=r.width+"px",Ce.slides.style.height=r.height+"px",Te=Math.min(r.presentationWidth/r.width,r.presentationHeight/r.height),Te=Math.max(Te,Se.minScale),1===(Te=Math.min(Te,Se.maxScale))||Fe.isActive()?(Ce.slides.style.zoom="",Ce.slides.style.left="",Ce.slides.style.top="",Ce.slides.style.bottom="",Ce.slides.style.right="",d({layout:""})):(Ce.slides.style.zoom="",Ce.slides.style.left="50%",Ce.slides.style.top="50%",Ce.slides.style.bottom="auto",Ce.slides.style.right="auto",d({layout:"translate(-50%, -50%) scale("+Te+")"}));for(var i=Array.from(Ce.wrapper.querySelectorAll(Cb)),a=0,o=i.length;a0&&r.presentationWidth<=Se.scrollActivationWidth?Fe.isActive()||Fe.activate():Fe.isActive()&&Fe.deactivate())}Ce.viewport.style.setProperty("--slide-scale",Te),Ce.viewport.style.setProperty("--viewport-width",e+"px"),Ce.viewport.style.setProperty("--viewport-height",t+"px"),Fe.layout(),Ye.update(),He.updateParallax(),Ue.isActive()&&Ue.update()}}function k(e,t){fb(Ce.slides,"section > .stretch, section > .r-stretch").forEach(function(r){var n=function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:0;if(e){var r,n=e.style.height;return e.style.height="0px",e.parentNode.style.height="auto",r=t-e.parentNode.offsetHeight,e.style.height=n+"px",e.parentNode.style.removeProperty("height"),r}return t}(r,t);if(/(img|video)/gi.test(r.nodeName)){var i=r.naturalWidth||r.videoWidth,a=r.naturalHeight||r.videoHeight,o=Math.min(e/i,n/a);r.style.width=i*o+"px",r.style.height=a*o+"px"}else r.style.width=e+"px",r.style.height=n+"px"})}function S(e,t){var r=Se.width,n=Se.height;Se.disableLayout&&(r=Ce.slides.offsetWidth,n=Ce.slides.offsetHeight);var i={width:r,height:n,presentationWidth:e||Ce.wrapper.offsetWidth,presentationHeight:t||Ce.wrapper.offsetHeight};return i.presentationWidth-=i.presentationWidth*Se.margin,i.presentationHeight-=i.presentationHeight*Se.margin,"string"==typeof i.width&&/%$/.test(i.width)&&(i.width=parseInt(i.width,10)/100*i.presentationWidth),"string"==typeof i.height&&/%$/.test(i.height)&&(i.height=parseInt(i.height,10)/100*i.presentationHeight),i}function A(e,t){"object"===n(e)&&"function"==typeof e.setAttribute&&e.setAttribute("data-previous-indexv",t||0)}function E(e){if("object"===n(e)&&"function"==typeof e.setAttribute&&e.classList.contains("stack")){var t=e.hasAttribute("data-start-indexv")?"data-start-indexv":"data-previous-indexv";return parseInt(e.getAttribute(t)||0,10)}return 0}function M(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:we;return e&&e.parentNode&&!!e.parentNode.nodeName.match(/section/i)}function T(){return!(!we||!M(we)||we.nextElementSibling)}function R(){return 0===ve&&0===ye}function C(){return!(!we||we.nextElementSibling||M(we)&&we.parentNode.nextElementSibling)}function L(){if(Se.pause){var e=Ce.wrapper.classList.contains("paused");ee(),Ce.wrapper.classList.add("paused"),!1===e&&p({type:"paused"})}}function z(){var e=Ce.wrapper.classList.contains("paused");Ce.wrapper.classList.remove("paused"),Q(),e&&p({type:"resumed"})}function N(e){"boolean"==typeof e?e?L():z():I()?z():L()}function I(){return Ce.wrapper.classList.contains("paused")}function B(e,t,r,n){if(!p({type:"beforeslidechange",data:{indexh:void 0===e?ve:e,indexv:void 0===t?ye:t,origin:n}}).defaultPrevented){be=we;var i=Ce.wrapper.querySelectorAll(Lb);if(Fe.isActive()){var s=Fe.getSlideByIndices(e,t);s&&Fe.scrollToSlide(s)}else if(0!==i.length){void 0!==t||Ue.isActive()||(t=E(i[e])),be&&be.parentNode&&be.parentNode.classList.contains("stack")&&A(be.parentNode,ye);var l=Me.concat();Me.length=0;var c=ve||0,u=ye||0;ve=D(Lb,void 0===e?ve:e),ye=D(zb,void 0===t?ye:t);var h=ve!==c||ye!==u;h||(be=null);var d=i[ve],f=d.querySelectorAll("section");we=f[ye]||d;var g=!1;h&&be&&we&&!Ue.isActive()&&(Le="running",(g=P(be,we,c,u))&&Ce.slides.classList.add("disable-slide-transitions")),j(),x(),Ue.isActive()&&Ue.update(),void 0!==r&&Ve.goto(r),be&&be!==we&&(be.classList.remove("present"),be.setAttribute("aria-hidden","true"),R()&&setTimeout(function(){fb(Ce.wrapper,Lb+".stack").forEach(function(e){A(e,0)})},0));e:for(var v=0,y=Me.length;vr||ye>n?t:e).hasAttribute("data-auto-animate-restart")}function O(){c(),l(),x(),ze=Se.autoSlide,Q(),He.create(),We.writeURL(),!0===Se.sortFragmentsOnSync&&Ve.sortAll(),Ge.update(),Ye.update(),j(),Ze.update(),Ze.updateVisibility(),He.update(!0),Oe.update(),Pe.formatEmbeddedContent(),!1===Se.autoPlayMedia?Pe.stopEmbeddedContent(we,{unloadIframes:!1}):Pe.startEmbeddedContent(we),Ue.isActive()&&Ue.layout()}function q(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:G();e.forEach(function(t){var r=e[Math.floor(Math.random()*e.length)];r.parentNode===t.parentNode&&t.parentNode.insertBefore(t,r);var n=t.querySelectorAll("section");n.length&&q(n)})}function D(e,t){var r=fb(Ce.wrapper,e),n=r.length,i=Fe.isActive()||je.isActive(),a=!1,o=!1;if(n){Se.loop&&(t>=n&&(a=!0),(t%=n)<0&&(t=n+t,o=!0)),t=Math.max(Math.min(t,n-1),0);for(var s=0;st?(l.classList.add(c?"past":"future"),Se.fragments&&F(l)):s===t&&Se.fragments&&(a?F(l):o&&H(l))}var u=r[t],h=u.classList.contains("present");u.classList.add("present"),u.removeAttribute("hidden"),u.removeAttribute("aria-hidden"),h||p({target:u,type:"visible",bubbles:!1});var d=u.getAttribute("data-state");d&&(Me=Me.concat(d.split(" ")))}else t=0;return t}function H(e){fb(e,".fragment").forEach(function(e){e.classList.add("visible"),e.classList.remove("current-fragment")})}function F(e){fb(e,".fragment.visible").forEach(function(e){e.classList.remove("visible","current-fragment")})}function j(){var e,t=G(),r=t.length;if(r&&void 0!==ve){var n=Ue.isActive()?10:Se.viewDistance;Eb&&(n=Ue.isActive()?6:Se.mobileViewDistance),je.isActive()&&(n=Number.MAX_VALUE);for(var i=0;i0&&void 0!==arguments[0]?arguments[0]:{}).includeFragments,t=void 0!==e&&e,r=Ce.wrapper.querySelectorAll(Lb),n=Ce.wrapper.querySelectorAll(zb),i={left:ve>0,right:ve0,down:ye1&&(i.left=!0,i.right=!0),n.length>1&&(i.up=!0,i.down=!0)),r.length>1&&"linear"===Se.navigationMode&&(i.right=i.right||i.down,i.left=i.left||i.up),!0===t){var a=Ve.availableRoutes();i.left=i.left||a.prev,i.up=i.up||a.prev,i.down=i.down||a.next,i.right=i.right||a.next}if(Se.rtl){var o=i.left;i.left=i.right,i.right=o}return i}function U(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:we,t=G(),r=0;e:for(var n=0;n0){var s=we.querySelector(".current-fragment");t=s&&s.hasAttribute("data-fragment-index")?parseInt(s.getAttribute("data-fragment-index"),10):we.querySelectorAll(".fragment.visible").length-1}return{h:r,v:n,f:t}}function W(){return fb(Ce.wrapper,Cb+':not(.stack):not([data-visibility="uncounted"])')}function G(){return fb(Ce.wrapper,Lb)}function Y(){return fb(Ce.wrapper,".slides>section>section")}function X(){return G().length>1}function $(){return Y().length>1}function K(){return W().length}function J(e,t){var r=G()[e],n=r&&r.querySelectorAll("section");return n&&n.length&&"number"==typeof t?n?n[t]:void 0:r}function Z(){var e=_();return{indexh:e.h,indexv:e.v,indexf:e.f,paused:I(),overview:Ue.isActive()}}function Q(){if(ee(),we&&!1!==Se.autoSlide){var e=we.querySelector(".current-fragment[data-autoslide]"),t=e?e.getAttribute("data-autoslide"):null,r=we.parentNode?we.parentNode.getAttribute("data-autoslide"):null,n=we.getAttribute("data-autoslide");t?ze=parseInt(t,10):n?ze=parseInt(n,10):r?ze=parseInt(r,10):(ze=Se.autoSlide,0===we.querySelectorAll(".fragment").length&&fb(we,"video, audio").forEach(function(e){e.hasAttribute("data-autoplay")&&ze&&1e3*e.duration/e.playbackRate>ze&&(ze=1e3*e.duration/e.playbackRate+1e3)})),!ze||Be||I()||Ue.isActive()||C()&&!Ve.availableRoutes().next&&!0!==Se.loop||(Ne=setTimeout(function(){"function"==typeof Se.autoSlideMethod?Se.autoSlideMethod():le(),Q()},ze),Ie=Date.now()),xe&&xe.setPlaying(-1!==Ne)}}function ee(){clearTimeout(Ne),Ne=-1}function te(){ze&&!Be&&(Be=!0,p({type:"autoslidepaused"}),clearTimeout(Ne),xe&&xe.setPlaying(!1))}function re(){ze&&Be&&(Be=!1,p({type:"autoslideresumed"}),Q())}function ne(){var e=(arguments.length>0&&void 0!==arguments[0]?arguments[0]:{}).skipFragments,t=void 0!==e&&e;Ee.hasNavigatedHorizontally=!0,Se.rtl?(Ue.isActive()||t||!1===Ve.next())&&V().left&&B(ve+1,"grid"===Se.navigationMode?ye:void 0):(Ue.isActive()||t||!1===Ve.prev())&&V().left&&B(ve-1,"grid"===Se.navigationMode?ye:void 0)}function ie(){var e=(arguments.length>0&&void 0!==arguments[0]?arguments[0]:{}).skipFragments,t=void 0!==e&&e;Ee.hasNavigatedHorizontally=!0,Se.rtl?(Ue.isActive()||t||!1===Ve.prev())&&V().right&&B(ve-1,"grid"===Se.navigationMode?ye:void 0):(Ue.isActive()||t||!1===Ve.next())&&V().right&&B(ve+1,"grid"===Se.navigationMode?ye:void 0)}function ae(){var e=(arguments.length>0&&void 0!==arguments[0]?arguments[0]:{}).skipFragments,t=void 0!==e&&e;(Ue.isActive()||t||!1===Ve.prev())&&V().up&&B(ve,ye-1)}function oe(){var e=(arguments.length>0&&void 0!==arguments[0]?arguments[0]:{}).skipFragments,t=void 0!==e&&e;Ee.hasNavigatedVertically=!0,(Ue.isActive()||t||!1===Ve.next())&&V().down&&B(ve,ye+1)}function se(){var e,t=(arguments.length>0&&void 0!==arguments[0]?arguments[0]:{}).skipFragments,r=void 0!==t&&t;if(r||!1===Ve.prev())if(V().up)ae({skipFragments:r});else if((e=Se.rtl?fb(Ce.wrapper,Lb+".future").pop():fb(Ce.wrapper,Lb+".past").pop())&&e.classList.contains("stack")){var n=e.querySelectorAll("section").length-1||void 0;B(ve-1,n)}else ne({skipFragments:r})}function le(){var e=(arguments.length>0&&void 0!==arguments[0]?arguments[0]:{}).skipFragments,t=void 0!==e&&e;if(Ee.hasNavigatedHorizontally=!0,Ee.hasNavigatedVertically=!0,t||!1===Ve.next()){var r=V();r.down&&r.right&&Se.loop&&T()&&(r.down=!1),r.down?oe({skipFragments:t}):Se.rtl?ne({skipFragments:t}):ie({skipFragments:t})}}function ce(e){var t=e.data;if("string"==typeof t&&"{"===t.charAt(0)&&"}"===t.charAt(t.length-1)&&(t=JSON.parse(t)).method&&"function"==typeof ke[t.method])if(!1===Nb.test(t.method)){var r=ke[t.method].apply(ke,t.args);f("callback",{method:t.method,result:r})}else console.warn('reveal.js: "'+t.method+'" is is blacklisted from the postMessage API')}function ue(e){"running"===Le&&/section/gi.test(e.target.nodeName)&&(Le="idle",p({type:"slidetransitionend",data:{indexh:ve,indexv:ye,previousSlide:be,currentSlide:we}}))}function he(e){var t=wb(e.target,'a[href^="#"]');if(t){var r=t.getAttribute("href"),n=We.getIndicesFromHash(r);n&&(ke.slide(n.h,n.v,n.f),e.preventDefault())}}function de(){x()}function pe(){!1===document.hidden&&document.activeElement!==document.body&&("function"==typeof document.activeElement.blur&&document.activeElement.blur(),document.body.focus())}function me(e){(document.fullscreenElement||document.webkitFullscreenElement)===Ce.wrapper&&(e.stopImmediatePropagation(),setTimeout(function(){ke.layout(),ke.focus.focus()},1))}function fe(e){if(e.currentTarget&&e.currentTarget.hasAttribute("href")){var t=e.currentTarget.getAttribute("href");t&&(y(t),e.preventDefault())}}function ge(){C()&&!1===Se.loop?(B(0,0),re()):Be?re():te()}arguments.length<2&&(r=arguments[0],e=document.querySelector(".reveal"));var ve,ye,be,we,xe,ke={},Se={},Ae=!1,Ee={hasNavigatedHorizontally:!1,hasNavigatedVertically:!1},Me=[],Te=1,Re={layout:"",overview:""},Ce={},Le="idle",ze=0,Ne=0,Ie=-1,Be=!1,Pe=new Rb(ke),Oe=new Bb(ke),qe=new Bw(ke),De=new ox(ke),He=new Kw(ke),Fe=new $x(ke),je=new Kx(ke),Ve=new Jx(ke),Ue=new Zx(ke),_e=new tk(ke),We=new rk(ke),Ge=new nk(ke),Ye=new ik(ke),Xe=new ak(ke),$e=new yk(ke),Ke=new kk(ke),Je=new bk(ke),Ze=new Sk(ke),Qe={VERSION:Lk,initialize:function(n){if(!e)throw'Unable to find presentation root (
).';if(Ce.wrapper=e,Ce.slides=e.querySelector(".slides"),!Ce.slides)throw'Unable to find slides container (
).';return Se=t(t(t(t(t({},Ck),Se),r),n),kb()),/print-pdf/gi.test(window.location.search)&&(Se.view="print"),!0===Se.embedded?Ce.viewport=wb(e,".reveal-viewport")||e:(Ce.viewport=document.body,document.documentElement.classList.add("reveal-full-page")),Ce.viewport.classList.add("reveal-viewport"),window.addEventListener("load",x,!1),$e.load(Se.plugins,Se.dependencies).then(i),new Promise(function(e){return ke.on("ready",e)})},configure:s,destroy:function(){c(),ee(),v(),Ze.destroy(),Ke.destroy(),$e.destroy(),Xe.destroy(),Ge.destroy(),Ye.destroy(),He.destroy(),Oe.destroy(),qe.destroy(),document.removeEventListener("fullscreenchange",me),document.removeEventListener("webkitfullscreenchange",me),document.removeEventListener("visibilitychange",pe,!1),window.removeEventListener("message",ce,!1),window.removeEventListener("load",x,!1),Ce.pauseOverlay&&Ce.pauseOverlay.remove(),Ce.statusElement&&Ce.statusElement.remove(),document.documentElement.classList.remove("reveal-full-page"),Ce.wrapper.classList.remove("ready","center","has-horizontal-slides","has-vertical-slides"),Ce.wrapper.removeAttribute("data-transition-speed"),Ce.wrapper.removeAttribute("data-background-transition"),Ce.viewport.classList.remove("reveal-viewport"),Ce.viewport.style.removeProperty("--slide-width"),Ce.viewport.style.removeProperty("--slide-height"),Ce.slides.style.removeProperty("width"),Ce.slides.style.removeProperty("height"),Ce.slides.style.removeProperty("zoom"),Ce.slides.style.removeProperty("left"),Ce.slides.style.removeProperty("top"),Ce.slides.style.removeProperty("bottom"),Ce.slides.style.removeProperty("right"),Ce.slides.style.removeProperty("transform"),Array.from(Ce.wrapper.querySelectorAll(Cb)).forEach(function(e){e.style.removeProperty("display"),e.style.removeProperty("top"),e.removeAttribute("hidden"),e.removeAttribute("aria-hidden")})},sync:O,syncSlide:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:we;He.sync(e),Ve.sync(e),Pe.load(e),He.update(),Ze.update()},syncFragments:Ve.sync.bind(Ve),slide:B,left:ne,right:ie,up:ae,down:oe,prev:se,next:le,navigateLeft:ne,navigateRight:ie,navigateUp:ae,navigateDown:oe,navigatePrev:se,navigateNext:le,navigateFragment:Ve.goto.bind(Ve),prevFragment:Ve.prev.bind(Ve),nextFragment:Ve.next.bind(Ve),on:u,off:h,addEventListener:u,removeEventListener:h,layout:x,shuffle:q,availableRoutes:V,availableFragments:Ve.availableRoutes.bind(Ve),toggleHelp:function(e){"boolean"==typeof e?e?b():w():Ce.overlay?w():b()},toggleOverview:Ue.toggle.bind(Ue),toggleScrollView:Fe.toggle.bind(Fe),togglePause:N,toggleAutoSlide:function(e){"boolean"==typeof e?e?re():te():Be?re():te()},toggleJumpToSlide:function(e){"boolean"==typeof e?e?qe.show():qe.hide():qe.isVisible()?qe.hide():qe.show()},isFirstSlide:R,isLastSlide:C,isLastVerticalSlide:T,isVerticalSlide:M,isVerticalStack:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:we;return e.classList.contains(".stack")||null!==e.querySelector("section")},isPaused:I,isAutoSliding:function(){return!(!ze||Be)},isSpeakerNotes:Ze.isSpeakerNotesWindow.bind(Ze),isOverview:Ue.isActive.bind(Ue),isFocused:Ke.isFocused.bind(Ke),isScrollView:Fe.isActive.bind(Fe),isPrintView:je.isActive.bind(je),isReady:function(){return Ae},loadSlide:Pe.load.bind(Pe),unloadSlide:Pe.unload.bind(Pe),startEmbeddedContent:function(){return Pe.startEmbeddedContent(we)},stopEmbeddedContent:function(){return Pe.stopEmbeddedContent(we,{unloadIframes:!1})},showPreview:y,hidePreview:w,addEventListeners:l,removeEventListeners:c,dispatchEvent:p,getState:Z,setState:function(e){if("object"===n(e)){B(vb(e.indexh),vb(e.indexv),vb(e.indexf));var t=vb(e.paused),r=vb(e.overview);"boolean"==typeof t&&t!==I()&&N(t),"boolean"==typeof r&&r!==Ue.isActive()&&Ue.toggle(r)}},getProgress:function(){var e=K(),t=U();if(we){var r=we.querySelectorAll(".fragment");r.length>0&&(t+=we.querySelectorAll(".fragment.visible").length/r.length*.9)}return Math.min(t/(e-1),1)},getIndices:_,getSlidesAttributes:function(){return W().map(function(e){for(var t={},r=0;r0&&T[0]<4?1:+(T[0]+T[1])),!R&&ue&&(!(T=ue.match(/Edge\/(\d+)/))||T[1]>=74)&&(T=ue.match(/Chrome\/(\d+)/))&&(R=+T[1]);var fe=R,ge=fe,ve=v,ye=f.String,be=!!Object.getOwnPropertySymbols&&!ve(function(){var e=Symbol("symbol detection");return!ye(e)||!(Object(e)instanceof Symbol)||!Symbol.sham&&ge&&ge<41}),we=be&&!Symbol.sham&&"symbol"==typeof Symbol.iterator,xe=oe,ke=ee,Se=se,Ae=Object,Ee=we?function(e){return"symbol"==typeof e}:function(e){var t=xe("Symbol");return ke(t)&&Se(t.prototype,Ae(e))},Me=String,Te=function(e){try{return Me(e)}catch(e){return"Object"}},Re=ee,Ce=Te,Le=TypeError,ze=function(e){if(Re(e))return e;throw new Le(Ce(e)+" is not a function")},Ne=ze,Ie=_,Be=function(e,t){var r=e[t];return Ie(r)?void 0:Ne(r)},Pe=k,Oe=ee,qe=ne,De=TypeError,He={exports:{}},Fe=f,je=Object.defineProperty,Ve=function(e,t){try{je(Fe,e,{value:t,configurable:!0,writable:!0})}catch(f){Fe[e]=t}return t},Ue=Ve,_e="__core-js_shared__",We=f[_e]||Ue(_e,{}),Ge=We;(He.exports=function(e,t){return Ge[e]||(Ge[e]=void 0!==t?t:{})})("versions",[]).push({version:"3.33.1",mode:"global",copyright:"\xa9 2014-2023 Denis Pushkarev (zloirock.ru)",license:"https://github.com/zloirock/core-js/blob/v3.33.1/LICENSE",source:"https://github.com/zloirock/core-js"});var Ye=He.exports,Xe=Y,$e=Object,Ke=function(e){return $e(Xe(e))},Je=Ke,Ze=B({}.hasOwnProperty),Qe=Object.hasOwn||function(e,t){return Ze(Je(e),t)},et=B,tt=0,rt=Math.random(),nt=et(1..toString),it=function(e){return"Symbol("+(void 0===e?"":e)+")_"+nt(++tt+rt,36)},at=Ye,ot=Qe,st=it,lt=be,ct=we,ut=f.Symbol,ht=at("wks"),dt=ct?ut["for"]||ut:ut&&ut.withoutSetter||st,pt=function(e){return ot(ht,e)||(ht[e]=lt&&ot(ut,e)?ut[e]:dt("Symbol."+e)),ht[e]},mt=k,ft=ne,gt=Ee,vt=Be,yt=function(e,t){var r,n;if("string"===t&&Oe(r=e.toString)&&!qe(n=Pe(r,e)))return n;if(Oe(r=e.valueOf)&&!qe(n=Pe(r,e)))return n;if("string"!==t&&Oe(r=e.toString)&&!qe(n=Pe(r,e)))return n;throw new De("Can't convert object to primitive value")},bt=TypeError,wt=pt("toPrimitive"),xt=function(e,t){if(!ft(e)||gt(e))return e;var r,n=vt(e,wt);if(n){if(void 0===t&&(t="default"),r=mt(n,e,t),!ft(r)||gt(r))return r;throw new bt("Can't convert object to primitive value")}return void 0===t&&(t="number"),yt(e,t)},kt=xt,St=Ee,At=function(e){var t=kt(e,"string");return St(t)?t:t+""},Et=ne,Mt=f.document,Tt=Et(Mt)&&Et(Mt.createElement),Rt=function(e){return Tt?Mt.createElement(e):{}},Ct=Rt,Lt=!y&&!v(function(){return 7!==Object.defineProperty(Ct("div"),"a",{get:function(){return 7}}).a}),zt=y,Nt=k,It=S,Bt=C,Pt=K,Ot=At,qt=Qe,Dt=Lt,Ht=Object.getOwnPropertyDescriptor;g.f=zt?Ht:function(e,t){if(e=Pt(e),t=Ot(t),Dt)try{return Ht(e,t)}catch(e){}if(qt(e,t))return Bt(!Nt(It.f,e,t),e[t])};var Ft={},jt=y&&v(function(){return 42!==Object.defineProperty(function(){},"prototype",{value:42,writable:!1}).prototype}),Vt=ne,Ut=String,_t=TypeError,Wt=function(e){if(Vt(e))return e;throw new _t(Ut(e)+" is not an object")},Gt=y,Yt=Lt,Xt=jt,$t=Wt,Kt=At,Jt=TypeError,Zt=Object.defineProperty,Qt=Object.getOwnPropertyDescriptor,er="enumerable",tr="configurable",rr="writable";Ft.f=Gt?Xt?function(e,t,r){if($t(e),t=Kt(t),$t(r),"function"==typeof e&&"prototype"===t&&"value"in r&&rr in r&&!r[rr]){var n=Qt(e,t);n&&n[rr]&&(e[t]=r.value,r={configurable:tr in r?r[tr]:n[tr],enumerable:er in r?r[er]:n[er],writable:!1})}return Zt(e,t,r)}:Zt:function(e,t,r){if($t(e),t=Kt(t),$t(r),Yt)try{return Zt(e,t,r)}catch(e){}if("get"in r||"set"in r)throw new Jt("Accessors not supported");return"value"in r&&(e[t]=r.value),e};var nr=Ft,ir=C,ar=y?function(e,t,r){return nr.f(e,t,ir(1,r))}:function(e,t,r){return e[t]=r,e},or={exports:{}},sr=y,lr=Qe,cr=Function.prototype,ur=sr&&Object.getOwnPropertyDescriptor,hr=lr(cr,"name"),dr={EXISTS:hr,PROPER:hr&&"something"===function(){}.name,CONFIGURABLE:hr&&(!sr||sr&&ur(cr,"name").configurable)},pr=ee,mr=We,fr=B(Function.toString);pr(mr.inspectSource)||(mr.inspectSource=function(e){return fr(e)});var gr,vr,yr,br=mr.inspectSource,wr=ee,xr=f.WeakMap,kr=wr(xr)&&/native code/.test(String(xr)),Sr=it,Ar=Ye("keys"),Er=function(e){return Ar[e]||(Ar[e]=Sr(e))},Mr={},Tr=kr,Rr=f,Cr=ne,Lr=ar,zr=Qe,Nr=We,Ir=Er,Br=Mr,Pr="Object already initialized",Or=Rr.TypeError,qr=Rr.WeakMap;if(Tr||Nr.state){var Dr=Nr.state||(Nr.state=new qr);Dr.get=Dr.get,Dr.has=Dr.has,Dr.set=Dr.set,gr=function(e,t){if(Dr.has(e))throw new Or(Pr);return t.facade=e,Dr.set(e,t),t},vr=function(e){return Dr.get(e)||{}},yr=function(e){return Dr.has(e)}}else{var Hr=Ir("state");Br[Hr]=!0,gr=function(e,t){if(zr(e,Hr))throw new Or(Pr);return t.facade=e,Lr(e,Hr,t),t},vr=function(e){return zr(e,Hr)?e[Hr]:{}},yr=function(e){return zr(e,Hr)}}var Fr={set:gr,get:vr,has:yr,enforce:function(e){return yr(e)?vr(e):gr(e,{})},getterFor:function(e){return function(t){var r;if(!Cr(t)||(r=vr(t)).type!==e)throw new Or("Incompatible receiver, "+e+" required");return r}}},jr=B,Vr=v,Ur=ee,_r=Qe,Wr=y,Gr=dr.CONFIGURABLE,Yr=br,Xr=Fr.enforce,$r=Fr.get,Kr=String,Jr=Object.defineProperty,Zr=jr("".slice),Qr=jr("".replace),en=jr([].join),tn=Wr&&!Vr(function(){return 8!==Jr(function(){},"length",{value:8}).length}),rn=String(String).split("String"),nn=or.exports=function(e,t,r){"Symbol("===Zr(Kr(t),0,7)&&(t="["+Qr(Kr(t),/^Symbol\(([^)]*)\)/,"$1")+"]"),r&&r.getter&&(t="get "+t),r&&r.setter&&(t="set "+t),(!_r(e,"name")||Gr&&e.name!==t)&&(Wr?Jr(e,"name",{value:t,configurable:!0}):e.name=t),tn&&r&&_r(r,"arity")&&e.length!==r.arity&&Jr(e,"length",{value:r.arity});try{r&&_r(r,"constructor")&&r.constructor?Wr&&Jr(e,"prototype",{writable:!1}):e.prototype&&(e.prototype=void 0)}catch(e){}var n=Xr(e);return _r(n,"source")||(n.source=en(rn,"string"==typeof t?t:"")),e};Function.prototype.toString=nn(function(){return Ur(this)&&$r(this).source||Yr(this)},"toString");var an=or.exports,on=ee,sn=Ft,ln=an,cn=Ve,un=function(e,t,r,n){n||(n={});var i=n.enumerable,a=void 0!==n.name?n.name:t;if(on(r)&&ln(r,a,n),n.global)i?e[t]=r:cn(t,r);else{try{n.unsafe?e[t]&&(i=!0):delete e[t]}catch(e){}i?e[t]=r:sn.f(e,t,{value:r,enumerable:!1,configurable:!n.nonConfigurable,writable:!n.nonWritable})}return e},hn={},dn=Math.ceil,pn=Math.floor,mn=Math.trunc||function(e){var t=+e;return(t>0?pn:dn)(t)},fn=function(e){var t=+e;return t!=t||0===t?0:mn(t)},gn=fn,vn=Math.max,yn=Math.min,bn=function(e,t){var r=gn(e);return r<0?vn(r+t,0):yn(r,t)},wn=fn,xn=Math.min,kn=function(e){return e>0?xn(wn(e),9007199254740991):0},Sn=kn,An=function(e){return Sn(e.length)},En=K,Mn=bn,Tn=An,Rn=function(e){return function(t,r,n){var i,a=En(t),o=Tn(a),s=Mn(n,o);if(e&&r!=r){for(;o>s;)if((i=a[s++])!=i)return!0}else for(;o>s;s++)if((e||s in a)&&a[s]===r)return e||s||0;return!e&&-1}},Cn={includes:Rn(!0),indexOf:Rn(!1)},Ln=Qe,zn=K,Nn=Cn.indexOf,In=Mr,Bn=B([].push),Pn=function(e,t){var r,n=zn(e),i=0,a=[];for(r in n)!Ln(In,r)&&Ln(n,r)&&Bn(a,r);for(;t.length>i;)Ln(n,r=t[i++])&&(~Nn(a,r)||Bn(a,r));return a},On=["constructor","hasOwnProperty","isPrototypeOf","propertyIsEnumerable","toLocaleString","toString","valueOf"],qn=Pn,Dn=On.concat("length","prototype");hn.f=Object.getOwnPropertyNames||function(e){return qn(e,Dn)};var Hn={};Hn.f=Object.getOwnPropertySymbols;var Fn=oe,jn=hn,Vn=Hn,Un=Wt,_n=B([].concat),Wn=Fn("Reflect","ownKeys")||function(e){var t=jn.f(Un(e)),r=Vn.f;return r?_n(t,r(e)):t},Gn=Qe,Yn=Wn,Xn=g,$n=Ft,Kn=function(e,t,r){for(var n=Yn(t),i=$n.f,a=Xn.f,o=0;on;)for(var o,s=Ei(arguments[n++]),l=i?Ri(xi(s),i(s)):xi(s),c=l.length,u=0;c>u;)o=l[u++],vi&&!bi(a,s,o)||(t[o]=s[o]);return t}:Mi;pi({target:"Object",stat:!0,arity:2,forced:Object.assign!==Ci},{assign:Ci});var Li=D,zi=B,Ni=function(e){if("Function"===Li(e))return zi(e)},Ii=ze,Bi=b,Pi=Ni(Ni.bind),Oi=function(e,t){return Ii(e),void 0===t?e:Bi?Pi(e,t):function(){return e.apply(t,arguments)}},qi=D,Di=Array.isArray||function(e){return"Array"===qi(e)},Hi={};Hi[pt("toStringTag")]="z";var Fi="[object z]"===String(Hi),ji=Fi,Vi=ee,Ui=D,_i=pt("toStringTag"),Wi=Object,Gi="Arguments"===Ui(function(){return arguments}()),Yi=ji?Ui:function(e){var t,r,n;return void 0===e?"Undefined":null===e?"Null":"string"==typeof(r=function(e,t){try{return e[t]}catch(e){}}(t=Wi(e),_i))?r:Gi?Ui(t):"Object"===(n=Ui(t))&&Vi(t.callee)?"Arguments":n},Xi=B,$i=v,Ki=ee,Ji=Yi,Zi=br,Qi=function(){},ea=[],ta=oe("Reflect","construct"),ra=/^\s*(?:class|function)\b/,na=Xi(ra.exec),ia=!ra.test(Qi),aa=function(e){if(!Ki(e))return!1;try{return ta(Qi,ea,e),!0}catch(e){return!1}},oa=function(e){if(!Ki(e))return!1;switch(Ji(e)){case"AsyncFunction":case"GeneratorFunction":case"AsyncGeneratorFunction":return!1}try{return ia||!!na(ra,Zi(e))}catch(e){return!0}};oa.sham=!0;var sa=!ta||$i(function(){var e;return aa(aa.call)||!aa(Object)||!aa(function(){e=!0})||e})?oa:aa,la=Di,ca=sa,ua=ne,ha=pt("species"),da=Array,pa=function(e){var t;return la(e)&&(t=e.constructor,(ca(t)&&(t===da||la(t.prototype))||ua(t)&&null===(t=t[ha]))&&(t=void 0)),void 0===t?da:t},ma=function(e,t){return new(pa(e))(0===t?0:t)},fa=Oi,ga=U,va=Ke,ya=An,ba=ma,wa=B([].push),xa=function(e){var t=1===e,r=2===e,n=3===e,i=4===e,a=6===e,o=7===e,s=5===e||a;return function(l,c,u,h){for(var d,p,m=va(l),f=ga(m),g=fa(c,u),v=ya(f),y=0,b=h||ba,w=t?b(l,v):r||o?b(l,0):void 0;v>y;y++)if((s||y in f)&&(p=g(d=f[y],y,m),e))if(t)w[y]=p;else if(p)switch(e){case 3:return!0;case 5:return d;case 6:return y;case 2:wa(w,d)}else switch(e){case 4:return!1;case 7:wa(w,d)}return a?-1:n||i?i:w}},ka={forEach:xa(0),map:xa(1),filter:xa(2),some:xa(3),every:xa(4),find:xa(5),findIndex:xa(6),filterReject:xa(7)},Sa=v,Aa=fe,Ea=pt("species"),Ma=function(e){return Aa>=51||!Sa(function(){var t=[];return(t.constructor={})[Ea]=function(){return{foo:1}},1!==t[e](Boolean).foo})},Ta=ka.map;pi({target:"Array",proto:!0,forced:!Ma("map")},{map:function(e){return Ta(this,e,arguments.length>1?arguments[1]:void 0)}});var Ra=Yi,Ca=Fi?{}.toString:function(){return"[object "+Ra(this)+"]"};Fi||un(Object.prototype,"toString",Ca,{unsafe:!0});var La=TypeError,za=function(e){if(e>9007199254740991)throw La("Maximum allowed index exceeded");return e},Na=At,Ia=Ft,Ba=C,Pa=function(e,t,r){var n=Na(t);n in e?Ia.f(e,n,Ba(0,r)):e[n]=r},Oa=pi,qa=v,Da=Di,Ha=ne,Fa=Ke,ja=An,Va=za,Ua=Pa,_a=ma,Wa=Ma,Ga=fe,Ya=pt("isConcatSpreadable"),Xa=Ga>=51||!qa(function(){var e=[];return e[Ya]=!1,e.concat()[0]!==e}),$a=function(e){if(!Ha(e))return!1;var t=e[Ya];return void 0!==t?!!t:Da(e)};Oa({target:"Array",proto:!0,arity:1,forced:!Xa||!Wa("concat")},{concat:function(){var e,t,r,n,i,a=Fa(this),o=_a(a,0),s=0;for(e=-1,r=arguments.length;eo;)co.f(e,r=i[o++],n[r]);return e};var mo,fo=oe("document","documentElement"),go=Wt,vo=oo,yo=On,bo=Mr,wo=fo,xo=Rt,ko="prototype",So="script",Ao=Er("IE_PROTO"),Eo=function(){},Mo=function(e){return"<"+So+">"+e+""},To=function(e){e.write(Mo("")),e.close();var t=e.parentWindow.Object;return e=null,t},Ro=function(){try{mo=new ActiveXObject("htmlfile")}catch(e){}var e,t,r;Ro="undefined"!=typeof document?document.domain&&mo?To(mo):(t=xo("iframe"),r="java"+So+":",t.style.display="none",wo.appendChild(t),t.src=String(r),(e=t.contentWindow.document).open(),e.write(Mo("document.F=Object")),e.close(),e.F):To(mo);for(var n=yo.length;n--;)delete Ro[ko][yo[n]];return Ro()};bo[Ao]=!0;var Co=Object.create||function(e,t){var r;return null!==e?(Eo[ko]=go(e),r=new Eo,Eo[ko]=null,r[Ao]=e):r=Ro(),void 0===t?r:vo.f(r,t)},Lo=v,zo=f.RegExp,No=Lo(function(){var e=zo(".","s");return!(e.dotAll&&e.test("\n")&&"s"===e.flags)}),Io=v,Bo=f.RegExp,Po=Io(function(){var e=Bo("(?b)","g");return"b"!==e.exec("b").groups.a||"bc"!=="b".replace(e,"$c")}),Oo=k,qo=B,Do=Za,Ho=eo,Fo=ao,jo=Co,Vo=Fr.get,Uo=No,_o=Po,Wo=Ye("native-string-replace",String.prototype.replace),Go=RegExp.prototype.exec,Yo=Go,Xo=qo("".charAt),$o=qo("".indexOf),Ko=qo("".replace),Jo=qo("".slice),Zo=function(){var e=/a/,t=/b*/g;return Oo(Go,e,"a"),Oo(Go,t,"a"),0!==e.lastIndex||0!==t.lastIndex}(),Qo=Fo.BROKEN_CARET,es=void 0!==/()??/.exec("")[1];(Zo||es||Qo||Uo||_o)&&(Yo=function(e){var t,r,n,i,a,o,s,l=this,c=Vo(l),u=Do(e),h=c.raw;if(h)return h.lastIndex=l.lastIndex,t=Oo(Yo,h,u),l.lastIndex=h.lastIndex,t;var d=c.groups,p=Qo&&l.sticky,m=Oo(Ho,l),f=l.source,g=0,v=u;if(p&&(m=Ko(m,"y",""),-1===$o(m,"g")&&(m+="g"),v=Jo(u,l.lastIndex),l.lastIndex>0&&(!l.multiline||l.multiline&&"\n"!==Xo(u,l.lastIndex-1))&&(f="(?: "+f+")",v=" "+v,g++),r=new RegExp("^(?:"+f+")",m)),es&&(r=new RegExp("^"+f+"$(?!\\s)",m)),Zo&&(n=l.lastIndex),i=Oo(Go,p?r:l,v),p?i?(i.input=Jo(i.input,g),i[0]=Jo(i[0],g),i.index=l.lastIndex,l.lastIndex+=i[0].length):l.lastIndex=0:Zo&&i&&(l.lastIndex=l.global?i.index+i[0].length:n),es&&i&&i.length>1&&Oo(Wo,i[0],r,function(){for(a=1;aa;a++)if((s=v(e[a]))&&sh(ph,s))return s;return new dh(!1)}n=lh(e,i)}for(l=d?e.next:n.next;!(c=rh(l,n)).done;){try{s=v(c.value)}catch(e){uh(n,"throw",e)}if("object"==typeof s&&s&&sh(ph,s))return s}return new dh(!1)},fh=pt("iterator"),gh=!1;try{var vh=0,yh={next:function(){return{done:!!vh++}},"return":function(){gh=!0}};yh[fh]=function(){return this},Array.from(yh,function(){throw 2})}catch(p){}var bh=function(e,t){try{if(!t&&!gh)return!1}catch(e){return!1}var r=!1;try{var n={};n[fh]=function(){return{next:function(){return{done:r=!0}}}},e(n)}catch(e){}return r},wh=vc,xh=Nc.CONSTRUCTOR||!bh(function(e){wh.all(e).then(void 0,function(){})}),kh=k,Sh=ze,Ah=Ic,Eh=gc,Mh=mh;pi({target:"Promise",stat:!0,forced:xh},{all:function(e){var t=this,r=Ah.f(t),n=r.resolve,i=r.reject,a=Eh(function(){var r=Sh(t.resolve),a=[],o=0,s=1;Mh(e,function(e){var l=o++,c=!1;s++,kh(r,t,e).then(function(e){c||(c=!0,a[l]=e,--s||n(a))},i)}),--s||n(a)});return a.error&&i(a.value),r.promise}});var Th=pi,Rh=Nc.CONSTRUCTOR,Ch=vc,Lh=oe,zh=ee,Nh=un,Ih=Ch&&Ch.prototype;if(Th({target:"Promise",proto:!0,forced:Rh,real:!0},{"catch":function(e){return this.then(void 0,e)}}),zh(Ch)){var Bh=Lh("Promise").prototype["catch"];Ih["catch"]!==Bh&&Nh(Ih,"catch",Bh,{unsafe:!0})}var Ph=k,Oh=ze,qh=Ic,Dh=gc,Hh=mh;pi({target:"Promise",stat:!0,forced:xh},{race:function(e){var t=this,r=qh.f(t),n=r.reject,i=Dh(function(){var i=Oh(t.resolve);Hh(e,function(e){Ph(i,t,e).then(r.resolve,n)})});return i.error&&n(i.value),r.promise}});var Fh=k,jh=Ic;pi({target:"Promise",stat:!0,forced:Nc.CONSTRUCTOR},{reject:function(e){var t=jh.f(this);return Fh(t.reject,void 0,e),t.promise}});var Vh=Wt,Uh=ne,_h=Ic,Wh=pi,Gh=Nc.CONSTRUCTOR,Yh=function(e,t){if(Vh(e),Uh(t)&&t.constructor===e)return t;var r=_h.f(e);return(0,r.resolve)(t),r.promise};oe("Promise"),Wh({target:"Promise",stat:!0,forced:Gh},{resolve:function(e){return Yh(this,e)}});var Xh=Rt("span").classList,$h=Xh&&Xh.constructor&&Xh.constructor.prototype,Kh=$h===Object.prototype?void 0:$h,Jh=v,Zh=function(e,t){var r=[][e];return!!r&&Jh(function(){r.call(null,t||function(){return 1},1)})},Qh=ka.forEach,ed=Zh("forEach")?[].forEach:function(e){return Qh(this,e,arguments.length>1?arguments[1]:void 0)},td=f,rd={CSSRuleList:0,CSSStyleDeclaration:0,CSSValueList:0,ClientRectList:0,DOMRectList:0,DOMStringList:0,DOMTokenList:1,DataTransferItemList:0,FileList:0,HTMLAllCollection:0,HTMLCollection:0,HTMLFormElement:0,HTMLSelectElement:0,MediaList:0,MimeTypeArray:0,NamedNodeMap:0,NodeList:1,PaintRequestList:0,Plugin:0,PluginArray:0,SVGLengthList:0,SVGNumberList:0,SVGPathSegList:0,SVGPointList:0,SVGStringList:0,SVGTransformList:0,SourceBufferList:0,StyleSheetList:0,TextTrackCueList:0,TextTrackList:0,TouchList:0},nd=Kh,id=ed,ad=ar,od=function(e){if(e&&e.forEach!==id)try{ad(e,"forEach",id)}catch(m){e.forEach=id}};for(var sd in rd)rd[sd]&&od(td[sd]&&td[sd].prototype);od(nd);var ld=Wt,cd=eh,ud=Oi,hd=k,dd=Ke,pd=function(e,t,r,n){try{return n?t(ld(r)[0],r[1]):t(r)}catch(t){cd(e,"throw",t)}},md=qu,fd=sa,gd=An,vd=Pa,yd=Ku,bd=Uu,wd=Array,xd=function(e){var t=dd(e),r=fd(this),n=arguments.length,i=n>1?arguments[1]:void 0,a=void 0!==i;a&&(i=ud(i,n>2?arguments[2]:void 0));var o,s,l,c,u,h,d=bd(t),p=0;if(!d||this===wd&&md(d))for(o=gd(t),s=r?new this(o):wd(o);o>p;p++)h=a?i(t[p],p):t[p],vd(s,p,h);else for(u=(c=yd(t,d)).next,s=r?new this:[];!(l=hd(u,c)).done;p++)h=a?pd(c,i,[l.value,p],!0):l.value,vd(s,p,h);return s.length=p,s};pi({target:"Array",stat:!0,forced:!bh(function(e){Array.from(e)})},{from:xd});var kd,Sd,Ad,Ed=B,Md=fn,Td=Za,Rd=Y,Cd=Ed("".charAt),Ld=Ed("".charCodeAt),zd=Ed("".slice),Nd=function(e){return function(t,r){var n,i,a=Td(Rd(t)),o=Md(r),s=a.length;return o<0||o>=s?e?"":void 0:(n=Ld(a,o))<55296||n>56319||o+1===s||(i=Ld(a,o+1))<56320||i>57343?e?Cd(a,o):n:e?zd(a,o,o+2):i-56320+(n-55296<<10)+65536}},Id={codeAt:Nd(!1),charAt:Nd(!0)},Bd=!v(function(){function e(){}return e.prototype.constructor=null,Object.getPrototypeOf(new e)!==e.prototype}),Pd=Qe,Od=ee,qd=Ke,Dd=Bd,Hd=Er("IE_PROTO"),Fd=Object,jd=Fd.prototype,Vd=Dd?Fd.getPrototypeOf:function(e){var t=qd(e);if(Pd(t,Hd))return t[Hd];var r=t.constructor;return Od(r)&&t instanceof r?r.prototype:t instanceof Fd?jd:null},Ud=v,_d=ee,Wd=ne,Gd=Vd,Yd=un,Xd=pt("iterator"),$d=!1;[].keys&&("next"in(Ad=[].keys())?(Sd=Gd(Gd(Ad)))!==Object.prototype&&(kd=Sd):$d=!0),(!Wd(kd)||Ud(function(){var e={};return kd[Xd].call(e)!==e}))&&(kd={}),_d(kd[Xd])||Yd(kd,Xd,function(){return this});var Kd={IteratorPrototype:kd,BUGGY_SAFARI_ITERATORS:$d},Jd=Kd.IteratorPrototype,Zd=Co,Qd=C,ep=_s,tp=Iu,rp=function(){return this},np=pi,ip=k,ap=ee,op=function(e,t,r,n){var i=t+" Iterator";return e.prototype=Zd(Jd,{next:Qd(+!n,r)}),ep(e,i,!1),tp[i]=rp,e},sp=Vd,lp=Fs,cp=_s,up=ar,hp=un,dp=Iu,pp=dr.PROPER,mp=dr.CONFIGURABLE,fp=Kd.IteratorPrototype,gp=Kd.BUGGY_SAFARI_ITERATORS,vp=pt("iterator"),yp="keys",bp="values",wp="entries",xp=function(){return this},kp=Id.charAt,Sp=Za,Ap=Fr,Ep=function(e,t,r,n,i,a,o){op(r,t,n);var s,l,c,u=function(e){if(e===i&&f)return f;if(!gp&&e&&e in p)return p[e];switch(e){case yp:case bp:case wp:return function(){return new r(this,e)}}return function(){return new r(this)}},h=t+" Iterator",d=!1,p=e.prototype,m=p[vp]||p["@@iterator"]||i&&p[i],f=!gp&&m||u(i),g="Array"===t&&p.entries||m;if(g&&(s=sp(g.call(new e)))!==Object.prototype&&s.next&&(sp(s)!==fp&&(lp?lp(s,fp):ap(s[vp])||hp(s,vp,xp)),cp(s,h,!0)),pp&&i===bp&&m&&m.name!==bp&&(mp?up(p,"name",bp):(d=!0,f=function(){return ip(m,this)})),i)if(l={values:u(bp),keys:a?f:u(yp),entries:u(wp)},o)for(c in l)(gp||d||!(c in p))&&hp(p,c,l[c]);else np({target:t,proto:!0,forced:gp||d},l);return p[vp]!==f&&hp(p,vp,f,{name:i}),dp[t]=f,l},Mp=function(e,t){return{value:e,done:t}},Tp="String Iterator",Rp=Ap.set,Cp=Ap.getterFor(Tp);Ep(String,"String",function(e){Rp(this,{type:Tp,string:Sp(e),index:0})},function(){var e,t=Cp(this),r=t.string,n=t.index;return n>=r.length?Mp(void 0,!0):(e=kp(r,n),t.index+=e.length,Mp(e,!1))});var Lp="\t\n\x0B\f\r \xa0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\ufeff",zp=Y,Np=Za,Ip=Lp,Bp=B("".replace),Pp=RegExp("^["+Ip+"]+"),Op=RegExp("(^|[^"+Ip+"])["+Ip+"]+$"),qp=function(e){return function(t){var r=Np(zp(t));return 1&e&&(r=Bp(r,Pp,"")),2&e&&(r=Bp(r,Op,"$1")),r}},Dp={start:qp(1),end:qp(2),trim:qp(3)},Hp=dr.PROPER,Fp=v,jp=Lp,Vp=Dp.trim;pi({target:"String",proto:!0,forced:function(e){return Fp(function(){return!!jp[e]()||"\u200b\x85\u180e"!=="\u200b\x85\u180e"[e]()||Hp&&jp[e].name!==e})}("trim")},{trim:function(){return Vp(this)}});var Up={},_p=bn,Wp=An,Gp=Pa,Yp=Array,Xp=Math.max,$p=function(e,t,r){for(var n=Wp(e),i=_p(t,n),a=_p(void 0===r?n:r,n),o=Yp(Xp(a-i,0)),s=0;i>>0||(Wg(_g,r)?16:10))}:jg;pi({global:!0,forced:parseInt!==Gg},{parseInt:Gg});var Yg=Id.charAt,Xg=function(e,t,r){return t+(r?Yg(e,t).length:1)},$g=k,Kg=Wt,Jg=_,Zg=kn,Qg=Za,ev=Y,tv=Be,rv=Xg,nv=ys;us("match",function(e,t,r){return[function(t){var r=ev(this),n=Jg(t)?void 0:tv(t,e);return n?$g(n,t,r):new RegExp(t)[e](Qg(r))},function(e){var n=Kg(this),i=Qg(e),a=r(t,n,i);if(a.done)return a.value;if(!n.global)return nv(n,i);var o=n.unicode;n.lastIndex=0;for(var s,l=[],c=0;null!==(s=nv(n,i));){var u=Qg(s[0]);l[c]=u,""===u&&(n.lastIndex=rv(i,Zg(n.lastIndex),o)),c++}return 0===c?null:l}]});var iv=y,av=Di,ov=TypeError,sv=Object.getOwnPropertyDescriptor,lv=iv&&!function(){if(void 0!==this)return!0;try{Object.defineProperty([],"length",{writable:!1}).length=1}catch(p){return p instanceof TypeError}}(),cv=Te,uv=TypeError,hv=function(e,t){if(!delete e[t])throw new uv("Cannot delete property "+cv(t)+" of "+cv(e))},dv=pi,pv=Ke,mv=bn,fv=fn,gv=An,vv=lv?function(e,t){if(av(e)&&!sv(e,"length").writable)throw new ov("Cannot set read only .length");return e.length=t}:function(e,t){return e.length=t},yv=za,bv=ma,wv=Pa,xv=hv,kv=Ma("splice"),Sv=Math.max,Av=Math.min;dv({target:"Array",proto:!0,forced:!kv},{splice:function(e,t){var r,n,i,a,o,s,l=pv(this),c=gv(l),u=mv(e,c),h=arguments.length;for(0===h?r=n=0:1===h?(r=0,n=c-u):(r=h-2,n=Av(Sv(fv(t),0),c-u)),yv(c+r-n),i=bv(l,n),a=0;ac-n+r;a--)xv(l,a-1)}else if(r>n)for(a=c-n;a>u;a--)s=a+r-1,(o=a+n-1)in l?l[s]=l[o]:xv(l,s);for(a=0;a2)if(c=Gv(c),43===(t=Zv(c,0))||45===t){if(88===(r=Zv(c,2))||120===r)return NaN}else if(48===t){switch(Zv(c,1)){case 66:case 98:n=2,i=49;break;case 79:case 111:n=8,i=55;break;default:return+c}for(o=(a=Jv(c,2)).length,s=0;si)return NaN;return parseInt(a,n)}return+c},ey=Pv(Yv,!Xv(" 0o1")||!Xv("0b1")||Xv("+0x1")),ty=function(e){var t,r=arguments.length<1?0:Xv(function(e){var t=Fv(e,"number");return"bigint"==typeof t?t:Qv(t)}(e));return Dv($v,t=this)&&jv(function(){Wv(t)})?qv(Object(r),this,ty):r};ty.prototype=$v,ey&&($v.constructor=ty),Lv({global:!0,constructor:!0,wrap:!0,forced:ey},{Number:ty}),ey&&function(e,t){for(var r,n=zv?Vv(t):"MAX_VALUE,MIN_VALUE,NaN,NEGATIVE_INFINITY,POSITIVE_INFINITY,EPSILON,MAX_SAFE_INTEGER,MIN_SAFE_INTEGER,isFinite,isInteger,isNaN,isSafeInteger,parseFloat,parseInt,fromString,range".split(","),i=0;n.length>i;i++)Ov(t,r=n[i])&&!Ov(e,r)&&_v(e,r,Uv(t,r))}(Iv[Yv],Xv);var ry=pi,ny=Cn.indexOf,iy=Zh,ay=Ni([].indexOf),oy=!!ay&&1/ay([1],1,-0)<0;ry({target:"Array",proto:!0,forced:oy||!iy("indexOf")},{indexOf:function(e){var t=arguments.length>1?arguments[1]:void 0;return oy?ay(this,e,t)||0:ny(this,e,t)}});var sy=Ke,ly=gi;pi({ +target:"Object",stat:!0,forced:v(function(){ly(1)})},{keys:function(e){return ly(sy(e))}});var cy=f,uy=v,hy=Za,dy=Dp.trim,py=Lp,my=B("".charAt),fy=cy.parseFloat,gy=cy.Symbol,vy=gy&&gy.iterator,yy=1/fy(py+"-0")!=-1/0||vy&&!uy(function(){fy(Object(vy))})?function(e){var t=dy(hy(e)),r=fy(t);return 0===r&&"-"===my(t,0)?-0:r}:fy;pi({global:!0,forced:parseFloat!==yy},{parseFloat:yy});var by=B,wy=Ke,xy=Math.floor,ky=by("".charAt),Sy=by("".replace),Ay=by("".slice),Ey=/\$([$&'`]|\d{1,2}|<[^>]*>)/g,My=/\$([$&'`]|\d{1,2})/g,Ty=dl,Ry=k,Cy=B,Ly=us,zy=v,Ny=Wt,Iy=ee,By=_,Py=fn,Oy=kn,qy=Za,Dy=Y,Hy=Xg,Fy=Be,jy=function(e,t,r,n,i,a){var o=r+e.length,s=n.length,l=My;return void 0!==i&&(i=wy(i),l=Ey),Sy(a,l,function(a,l){var c;switch(ky(l,0)){case"$":return"$";case"&":return e;case"`":return Ay(t,0,r);case"'":return Ay(t,o);case"<":c=i[Ay(l,1,-1)];break;default:var u=+l;if(0===u)return a;if(u>s){var h=xy(u/10);return 0===h?a:h<=s?void 0===n[h-1]?ky(l,1):n[h-1]+ky(l,1):a}c=n[u-1]}return void 0===c?"":c})},Vy=ys,Uy=pt("replace"),_y=Math.max,Wy=Math.min,Gy=Cy([].concat),Yy=Cy([].push),Xy=Cy("".indexOf),$y=Cy("".slice),Ky="$0"==="a".replace(/./,"$0"),Jy=!!/./[Uy]&&""===/./[Uy]("a","$0");Ly("replace",function(e,t,r){var n=Jy?"$":"$0";return[function(e,r){var n=Dy(this),i=By(e)?void 0:Fy(e,Uy);return i?Ry(i,e,n,r):Ry(t,qy(n),e,r)},function(e,i){var a=Ny(this),o=qy(e);if("string"==typeof i&&-1===Xy(i,n)&&-1===Xy(i,"$<")){var s=r(t,a,o,i);if(s.done)return s.value}var l=Iy(i);l||(i=qy(i));var c,u=a.global;u&&(c=a.unicode,a.lastIndex=0);for(var h,d=[];null!==(h=Vy(a,o))&&(Yy(d,h),u);)""===qy(h[0])&&(a.lastIndex=Hy(o,Oy(a.lastIndex),c));for(var p,m="",f=0,g=0;g=f&&(m+=$y(o,f,b)+v,f=b+y.length)}return m+$y(o,f)}]},!!zy(function(){var e=/./;return e.exec=function(){var e=[];return e.groups={a:"7"},e},"7"!=="".replace(e,"$")})||!Ky||Jy);var Zy=k,Qy=Qe,eb=se,tb=eo,rb=RegExp.prototype,nb=function(e){var t=e.flags;return void 0!==t||"flags"in rb||Qy(e,"flags")||!eb(rb,e)?t:Zy(tb,e)},ib=dr.PROPER,ab=un,ob=Wt,sb=Za,lb=v,cb=nb,ub="toString",hb=RegExp.prototype[ub],db=lb(function(){return"/a/b"!==hb.call({source:"a",flags:"b"})}),pb=ib&&hb.name!==ub;(db||pb)&&ab(RegExp.prototype,ub,function(){var e=ob(this);return"/"+sb(e.source)+"/"+sb(cb(e))},{unsafe:!0});var mb=function(e,t){for(var r in t)e[r]=t[r];return e},fb=function(e,t){return Array.from(e.querySelectorAll(t))},gb=function(e,t,r){r?e.classList.add(t):e.classList.remove(t)},vb=function(e){if("string"==typeof e){if("null"===e)return null;if("true"===e)return!0;if("false"===e)return!1;if(e.match(/^-?[\d\.]+$/))return parseFloat(e)}return e},yb=function(e,t){e.style.transform=t},bb=function(e,t){var r=e.matches||e.matchesSelector||e.msMatchesSelector;return!(!r||!r.call(e,t))},wb=function(e,t){if("function"==typeof e.closest)return e.closest(t);for(;e;){if(bb(e,t))return e;e=e.parentNode}return null},xb=function(e){var t=document.createElement("style");return t.type="text/css",e&&e.length>0&&(t.styleSheet?t.styleSheet.cssText=e:t.appendChild(document.createTextNode(e))),document.head.appendChild(t),t},kb=function(){var e={};for(var t in location.search.replace(/[A-Z0-9]+?=([\w\.%-]*)/gi,function(t){e[t.split("=").shift()]=t.split("=").pop()}),e){var r=e[t];e[t]=vb(unescape(r))}return void 0!==e.dependencies&&delete e.dependencies,e},Sb={mp4:"video/mp4",m4a:"video/mp4",ogv:"video/ogg",mpeg:"video/mpeg",webm:"video/webm"},Ab=navigator.userAgent,Eb=/(iphone|ipod|ipad|android)/gi.test(Ab)||"MacIntel"===navigator.platform&&navigator.maxTouchPoints>1,Mb=/android/gi.test(Ab),Tb=function(e){function t(e,t){var r=Object.assign({},k,t),n=e.map(function(e){var t=Object.assign({},r,{element:e,active:!0});return function(e){e.originalStyle={whiteSpace:e.element.style.whiteSpace,display:e.element.style.display,fontSize:e.element.style.fontSize},x(e),e.newbie=!0,e.dirty=!0,a.push(e)}(t),{element:e,fit:v(t,i),unfreeze:b(t),freeze:w(t),unsubscribe:y(t)}});return s(),n}function r(e){var r=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};return"string"==typeof e?t(n(document.querySelectorAll(e)),r):t([e],r)[0]}if(e){var n=function(e){return[].slice.call(e)},i=3,a=[],o=null,s="requestAnimationFrame"in e?function(){e.cancelAnimationFrame(o),o=e.requestAnimationFrame(function(){return c(a.filter(function(e){return e.dirty&&e.active}))})}:function(){},l=function(e){return function(){a.forEach(function(t){return t.dirty=e}),s()}},c=function(e){e.filter(function(e){return!e.styleComputed}).forEach(function(e){e.styleComputed=p(e)}),e.filter(m).forEach(f);var t=e.filter(d);t.forEach(h),t.forEach(function(e){f(e),u(e)}),t.forEach(g)},u=function(e){return e.dirty=0},h=function(e){e.availableWidth=e.element.parentNode.clientWidth,e.currentWidth=e.element.scrollWidth,e.previousFontSize=e.currentFontSize,e.currentFontSize=Math.min(Math.max(e.minSize,e.availableWidth/e.currentWidth*e.previousFontSize),e.maxSize),e.whiteSpace=e.multiLine&&e.currentFontSize===e.minSize?"normal":"nowrap"},d=function(e){return 2!==e.dirty||2===e.dirty&&e.element.parentNode.clientWidth!==e.availableWidth},p=function(t){var r=e.getComputedStyle(t.element,null);return t.currentFontSize=parseFloat(r.getPropertyValue("font-size")),t.display=r.getPropertyValue("display"),t.whiteSpace=r.getPropertyValue("white-space"),!0},m=function(e){var t=!1;return!e.preStyleTestCompleted&&(/inline-/.test(e.display)||(t=!0,e.display="inline-block"),"nowrap"!==e.whiteSpace&&(t=!0,e.whiteSpace="nowrap"),e.preStyleTestCompleted=!0,t)},f=function(e){e.element.style.whiteSpace=e.whiteSpace,e.element.style.display=e.display,e.element.style.fontSize=e.currentFontSize+"px"},g=function(e){e.element.dispatchEvent(new CustomEvent("fit",{detail:{oldValue:e.previousFontSize,newValue:e.currentFontSize,scaleFactor:e.currentFontSize/e.previousFontSize}}))},v=function(e,t){return function(){e.dirty=t,e.active&&s()}},y=function(e){return function(){a=a.filter(function(t){return t.element!==e.element}),e.observeMutations&&e.observer.disconnect(),e.element.style.whiteSpace=e.originalStyle.whiteSpace,e.element.style.display=e.originalStyle.display,e.element.style.fontSize=e.originalStyle.fontSize}},b=function(e){return function(){e.active||(e.active=!0,s())}},w=function(e){return function(){return e.active=!1}},x=function(e){e.observeMutations&&(e.observer=new MutationObserver(v(e,1)),e.observer.observe(e.element,e.observeMutations))},k={minSize:16,maxSize:512,multiLine:!0,observeMutations:"MutationObserver"in e&&{subtree:!0,childList:!0,characterData:!0}},S=null,A=function(){e.clearTimeout(S),S=e.setTimeout(l(2),r.observeWindowDelay)},E=["resize","orientationchange"];return Object.defineProperty(r,"observeWindow",{set:function(t){var r="".concat(t?"add":"remove","EventListener");E.forEach(function(t){e[r](t,A)})}}),r.observeWindow=!0,r.observeWindowDelay=100,r.fitAll=l(i),r}}("undefined"==typeof window?null:window),Rb=function(){function e(t){a(this,e),this.Reveal=t,this.startEmbeddedIframe=this.startEmbeddedIframe.bind(this)}return s(e,[{key:"shouldPreload",value:function(e){if(this.Reveal.isScrollView())return!0;var t=this.Reveal.getConfig().preloadIframes;return"boolean"!=typeof t&&(t=e.hasAttribute("data-preload")),t}},{key:"load",value:function(e){var t=this,r=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};e.style.display=this.Reveal.getConfig().display,fb(e,"img[data-src], video[data-src], audio[data-src], iframe[data-src]").forEach(function(e){("IFRAME"!==e.tagName||t.shouldPreload(e))&&(e.setAttribute("src",e.getAttribute("data-src")),e.setAttribute("data-lazy-loaded",""),e.removeAttribute("data-src"))}),fb(e,"video, audio").forEach(function(e){var t=0;fb(e,"source[data-src]").forEach(function(e){e.setAttribute("src",e.getAttribute("data-src")),e.removeAttribute("data-src"),e.setAttribute("data-lazy-loaded",""),t+=1}),Eb&&"VIDEO"===e.tagName&&e.setAttribute("playsinline",""),t>0&&e.load()});var n=e.slideBackgroundElement;if(n){n.style.display="block";var i=e.slideBackgroundContentElement,a=e.getAttribute("data-background-iframe");if(!1===n.hasAttribute("data-loaded")){n.setAttribute("data-loaded","true");var o=e.getAttribute("data-background-image"),s=e.getAttribute("data-background-video"),l=e.hasAttribute("data-background-video-loop"),c=e.hasAttribute("data-background-video-muted");if(o)/^data:/.test(o.trim())?i.style.backgroundImage="url(".concat(o.trim(),")"):i.style.backgroundImage=o.split(",").map(function(e){var t=decodeURI(e.trim());return"url(".concat(function(){return encodeURI(arguments.length>0&&void 0!==arguments[0]?arguments[0]:"").replace(/%5B/g,"[").replace(/%5D/g,"]").replace(/[!'()*]/g,function(e){return"%".concat(e.charCodeAt(0).toString(16).toUpperCase())})}(t),")")}).join(",");else if(s&&!this.Reveal.isSpeakerNotes()){var u=document.createElement("video");l&&u.setAttribute("loop",""),c&&(u.muted=!0),Eb&&(u.muted=!0,u.setAttribute("playsinline","")),s.split(",").forEach(function(e){var t=function(){return Sb[(arguments.length>0&&void 0!==arguments[0]?arguments[0]:"").split(".").pop()]}(e);u.innerHTML+=t?''):'')}),i.appendChild(u)}else if(a&&!0!==r.excludeIframes){var h=document.createElement("iframe");h.setAttribute("allowfullscreen",""),h.setAttribute("mozallowfullscreen",""),h.setAttribute("webkitallowfullscreen",""),h.setAttribute("allow","autoplay"),h.setAttribute("data-src",a),h.style.width="100%",h.style.height="100%",h.style.maxHeight="100%",h.style.maxWidth="100%",i.appendChild(h)}}var d=i.querySelector("iframe[data-src]");d&&this.shouldPreload(n)&&!/autoplay=(1|true|yes)/gi.test(a)&&d.getAttribute("src")!==a&&d.setAttribute("src",a)}this.layout(e)}},{key:"layout",value:function(e){var t=this;Array.from(e.querySelectorAll(".r-fit-text")).forEach(function(e){Tb(e,{minSize:24,maxSize:.8*t.Reveal.getConfig().height,observeMutations:!1,observeWindow:!1})})}},{key:"unload",value:function(e){e.style.display="none";var t=this.Reveal.getSlideBackground(e);t&&(t.style.display="none",fb(t,"iframe[src]").forEach(function(e){e.removeAttribute("src")})),fb(e,"video[data-lazy-loaded][src], audio[data-lazy-loaded][src], iframe[data-lazy-loaded][src]").forEach(function(e){e.setAttribute("data-src",e.getAttribute("src")),e.removeAttribute("src")}),fb(e,"video[data-lazy-loaded] source[src], audio source[src]").forEach(function(e){e.setAttribute("data-src",e.getAttribute("src")),e.removeAttribute("src")})}},{key:"formatEmbeddedContent",value:function(){var e=this,t=function(t,r,n){fb(e.Reveal.getSlidesElement(),"iframe["+t+'*="'+r+'"]').forEach(function(e){var r=e.getAttribute(t);r&&-1===r.indexOf(n)&&e.setAttribute(t,r+(/\?/.test(r)?"&":"?")+n)})};t("src","youtube.com/embed/","enablejsapi=1"),t("data-src","youtube.com/embed/","enablejsapi=1"),t("src","player.vimeo.com/","api=1"),t("data-src","player.vimeo.com/","api=1")}},{key:"startEmbeddedContent",value:function(e){var t=this;e&&!this.Reveal.isSpeakerNotes()&&(fb(e,'img[src$=".gif"]').forEach(function(e){e.setAttribute("src",e.getAttribute("src"))}),fb(e,"video, audio").forEach(function(e){if(!wb(e,".fragment")||wb(e,".fragment.visible")){var r=t.Reveal.getConfig().autoPlayMedia;if("boolean"!=typeof r&&(r=e.hasAttribute("data-autoplay")||!!wb(e,".slide-background")),r&&"function"==typeof e.play)if(e.readyState>1)t.startEmbeddedMedia({target:e});else if(Eb){var n=e.play();n&&"function"==typeof n["catch"]&&!1===e.controls&&n["catch"](function(){e.controls=!0,e.addEventListener("play",function(){e.controls=!1})})}else e.removeEventListener("loadeddata",t.startEmbeddedMedia),e.addEventListener("loadeddata",t.startEmbeddedMedia)}}),fb(e,"iframe[src]").forEach(function(e){wb(e,".fragment")&&!wb(e,".fragment.visible")||t.startEmbeddedIframe({target:e})}),fb(e,"iframe[data-src]").forEach(function(e){wb(e,".fragment")&&!wb(e,".fragment.visible")||e.getAttribute("src")!==e.getAttribute("data-src")&&(e.removeEventListener("load",t.startEmbeddedIframe),e.addEventListener("load",t.startEmbeddedIframe),e.setAttribute("src",e.getAttribute("data-src")))}))}},{key:"startEmbeddedMedia",value:function(e){var t=!!wb(e.target,"html"),r=!!wb(e.target,".present");t&&r&&(e.target.currentTime=0,e.target.play()),e.target.removeEventListener("loadeddata",this.startEmbeddedMedia)}},{key:"startEmbeddedIframe",value:function(e){var t=e.target;if(t&&t.contentWindow){var r=!!wb(e.target,"html"),n=!!wb(e.target,".present");if(r&&n){var i=this.Reveal.getConfig().autoPlayMedia;"boolean"!=typeof i&&(i=t.hasAttribute("data-autoplay")||!!wb(t,".slide-background")),/youtube\.com\/embed\//.test(t.getAttribute("src"))&&i?t.contentWindow.postMessage('{"event":"command","func":"playVideo","args":""}',"*"):/player\.vimeo\.com\//.test(t.getAttribute("src"))&&i?t.contentWindow.postMessage('{"method":"play"}',"*"):t.contentWindow.postMessage("slide:start","*")}}}},{key:"stopEmbeddedContent",value:function(e){var t=this,r=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};r=mb({unloadIframes:!0},r),e&&e.parentNode&&(fb(e,"video, audio").forEach(function(e){e.hasAttribute("data-ignore")||"function"!=typeof e.pause||(e.setAttribute("data-paused-by-reveal",""),e.pause())}),fb(e,"iframe").forEach(function(e){e.contentWindow&&e.contentWindow.postMessage("slide:stop","*"),e.removeEventListener("load",t.startEmbeddedIframe)}),fb(e,'iframe[src*="youtube.com/embed/"]').forEach(function(e){!e.hasAttribute("data-ignore")&&e.contentWindow&&"function"==typeof e.contentWindow.postMessage&&e.contentWindow.postMessage('{"event":"command","func":"pauseVideo","args":""}',"*")}),fb(e,'iframe[src*="player.vimeo.com/"]').forEach(function(e){!e.hasAttribute("data-ignore")&&e.contentWindow&&"function"==typeof e.contentWindow.postMessage&&e.contentWindow.postMessage('{"method":"pause"}',"*")}),!0===r.unloadIframes&&fb(e,"iframe[data-src]").forEach(function(e){e.setAttribute("src","about:blank"),e.removeAttribute("src")}))}}]),e}(),Cb=".slides section",Lb=".slides>section",zb=".slides>section.present>section",Nb=/registerPlugin|registerKeyboardShortcut|addKeyBinding|addEventListener|showPreview/,Ib=/fade-(down|up|right|left|out|in-then-out|in-then-semi-out)|semi-fade-out|current-visible|shrink|grow/,Bb=function(){function e(t){a(this,e),this.Reveal=t}return s(e,[{key:"render",value:function(){this.element=document.createElement("div"),this.element.className="slide-number",this.Reveal.getRevealElement().appendChild(this.element)}},{key:"configure",value:function(e){var t="none";e.slideNumber&&!this.Reveal.isPrintView()&&("all"===e.showSlideNumber||"speaker"===e.showSlideNumber&&this.Reveal.isSpeakerNotes())&&(t="block"),this.element.style.display=t}},{key:"update",value:function(){this.Reveal.getConfig().slideNumber&&this.element&&(this.element.innerHTML=this.getSlideNumber())}},{key:"getSlideNumber",value:function(){var e,t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:this.Reveal.getCurrentSlide(),r=this.Reveal.getConfig(),n="h.v";if("function"==typeof r.slideNumber)e=r.slideNumber(t);else{"string"==typeof r.slideNumber&&(n=r.slideNumber),/c/.test(n)||1!==this.Reveal.getHorizontalSlides().length||(n="c");var i=t&&"uncounted"===t.dataset.visibility?0:1;switch(e=[],n){case"c":e.push(this.Reveal.getSlidePastCount(t)+i);break;case"c/t":e.push(this.Reveal.getSlidePastCount(t)+i,"/",this.Reveal.getTotalSlides());break;default:var a=this.Reveal.getIndices(t);e.push(a.h+i);var o="h/v"===n?"/":".";this.Reveal.isVerticalSlide(t)&&e.push(o,a.v+1)}}var s="#"+this.Reveal.location.getHash(t);return this.formatNumber(e[0],e[1],e[2],s)}},{key:"formatNumber",value:function(e,t,r){var n=arguments.length>3&&void 0!==arguments[3]?arguments[3]:"#"+this.Reveal.location.getHash();return"number"!=typeof r||isNaN(r)?'\n\t\t\t\t\t').concat(e,"\n\t\t\t\t\t"):'\n\t\t\t\t\t').concat(e,'\n\t\t\t\t\t').concat(t,'\n\t\t\t\t\t').concat(r,"\n\t\t\t\t\t")}},{key:"destroy",value:function(){this.element.remove()}}]),e}(),Pb=ne,Ob=D,qb=pt("match"),Db=Ft.f,Hb=y,Fb=f,jb=B,Vb=ai,Ub=Rv,_b=ar,Wb=hn.f,Gb=se,Yb=function(e){var t;return Pb(e)&&(void 0!==(t=e[qb])?!!t:"RegExp"===Ob(e))},Xb=Za,$b=nb,Kb=ao,Jb=function(e,t,r){r in e||Db(e,r,{configurable:!0,get:function(){return t[r]},set:function(e){t[r]=e}})},Zb=un,Qb=v,ew=Qe,tw=Fr.enforce,rw=Zs,nw=No,iw=Po,aw=pt("match"),ow=Fb.RegExp,sw=ow.prototype,lw=Fb.SyntaxError,cw=jb(sw.exec),uw=jb("".charAt),hw=jb("".replace),dw=jb("".indexOf),pw=jb("".slice),mw=/^\?<[^\s\d!#%&*+<=>@^][^\s!#%&*+<=>@^]*>/,fw=/a/g,gw=/a/g,vw=new ow(fw)!==fw,yw=Kb.MISSED_STICKY,bw=Kb.UNSUPPORTED_Y;if(Vb("RegExp",Hb&&(!vw||yw||nw||iw||Qb(function(){return gw[aw]=!1,ow(fw)!==fw||ow(gw)===gw||"/a/i"!==String(ow(fw,"i"))})))){for(var ww=function(e,t){var r,n,i,a,o,s,l=Gb(sw,this),c=Yb(e),u=void 0===t,h=[],d=e;if(!l&&c&&u&&e.constructor===ww)return e;if((c||Gb(sw,e))&&(e=e.source,u&&(t=$b(d))),e=void 0===e?"":Xb(e),t=void 0===t?"":Xb(t),d=e,nw&&"dotAll"in fw&&(n=!!t&&dw(t,"s")>-1)&&(t=hw(t,/s/g,"")),r=t,yw&&"sticky"in fw&&(i=!!t&&dw(t,"y")>-1)&&bw&&(t=hw(t,/y/g,"")),iw&&(e=(a=function(e){for(var t,r=e.length,n=0,i="",a=[],o={},s=!1,l=!1,c=0,u="";n<=r;n++){if("\\"===(t=uw(e,n)))t+=uw(e,++n);else if("]"===t)s=!1;else if(!s)switch(!0){case"["===t:s=!0;break;case"("===t:cw(mw,pw(e,n+1))&&(n+=2,l=!0),i+=t,c++;continue;case">"===t&&l:if(""===u||ew(o,u))throw new lw("Invalid capture group name");o[u]=!0,a[a.length]=[u,c],l=!1,u="";continue}l?u+=t:i+=t}return[i,a]}(e))[0],h=a[1]),o=Ub(ow(e,t),l?this:sw,ww),(n||i||h.length)&&(s=tw(o),n&&(s.dotAll=!0,s.raw=ww(function(e){for(var t,r=e.length,n=0,i="",a=!1;n<=r;n++)"\\"!==(t=uw(e,n))?a||"."!==t?("["===t?a=!0:"]"===t&&(a=!1),i+=t):i+="[\\s\\S]":i+=t+uw(e,++n);return i}(e),r)),i&&(s.sticky=!0),h.length&&(s.groups=h)),e!==d)try{_b(o,"source",""===d?"(?:)":d)}catch(e){}return o},xw=Wb(ow),kw=0;xw.length>kw;)Jb(ww,ow,xw[kw++]);sw.constructor=ww,ww.prototype=sw,Zb(Fb,"RegExp",ww,{constructor:!0})}rw("RegExp");var Sw=pt,Aw=Co,Ew=Ft.f,Mw=Sw("unscopables"),Tw=Array.prototype;void 0===Tw[Mw]&&Ew(Tw,Mw,{configurable:!0,value:Aw(null)});var Rw=function(e){Tw[Mw][e]=!0},Cw=pi,Lw=ka.find,zw=Rw,Nw="find",Iw=!0;Nw in[]&&Array(1)[Nw](function(){Iw=!1}),Cw({target:"Array",proto:!0,forced:Iw},{find:function(e){return Lw(this,e,arguments.length>1?arguments[1]:void 0)}}),zw(Nw);var Bw=function(){function e(t){a(this,e),this.Reveal=t,this.onInput=this.onInput.bind(this),this.onBlur=this.onBlur.bind(this),this.onKeyDown=this.onKeyDown.bind(this)}return s(e,[{key:"render",value:function(){this.element=document.createElement("div"),this.element.className="jump-to-slide",this.jumpInput=document.createElement("input"),this.jumpInput.type="text",this.jumpInput.className="jump-to-slide-input",this.jumpInput.placeholder="Jump to slide",this.jumpInput.addEventListener("input",this.onInput),this.jumpInput.addEventListener("keydown",this.onKeyDown),this.jumpInput.addEventListener("blur",this.onBlur),this.element.appendChild(this.jumpInput)}},{key:"show",value:function(){this.indicesOnShow=this.Reveal.getIndices(),this.Reveal.getRevealElement().appendChild(this.element),this.jumpInput.focus()}},{key:"hide",value:function(){this.isVisible()&&(this.element.remove(),this.jumpInput.value="",clearTimeout(this.jumpTimeout),delete this.jumpTimeout)}},{key:"isVisible",value:function(){return!!this.element.parentNode}},{key:"jump",value:function(){clearTimeout(this.jumpTimeout),delete this.jumpTimeout;var e,t=this.jumpInput.value.trim("");if(/^\d+$/.test(t)){var r=this.Reveal.getConfig().slideNumber;if("c"===r||"c/t"===r){var n=this.Reveal.getSlides()[parseInt(t,10)-1];n&&(e=this.Reveal.getIndices(n))}}return e||(/^\d+\.\d+$/.test(t)&&(t=t.replace(".","/")),e=this.Reveal.location.getIndicesFromHash(t,{oneBasedIndex:!0})),!e&&/\S+/i.test(t)&&t.length>1&&(e=this.search(t)),e&&""!==t?(this.Reveal.slide(e.h,e.v,e.f),!0):(this.Reveal.slide(this.indicesOnShow.h,this.indicesOnShow.v,this.indicesOnShow.f),!1)}},{key:"jumpAfter",value:function(e){var t=this;clearTimeout(this.jumpTimeout),this.jumpTimeout=setTimeout(function(){return t.jump()},e)}},{key:"search",value:function(e){var t=new RegExp("\\b"+e.trim()+"\\b","i"),r=this.Reveal.getSlides().find(function(e){return t.test(e.innerText)});return r?this.Reveal.getIndices(r):null}},{key:"cancel",value:function(){this.Reveal.slide(this.indicesOnShow.h,this.indicesOnShow.v,this.indicesOnShow.f),this.hide()}},{key:"confirm",value:function(){this.jump(),this.hide()}},{key:"destroy",value:function(){this.jumpInput.removeEventListener("input",this.onInput),this.jumpInput.removeEventListener("keydown",this.onKeyDown),this.jumpInput.removeEventListener("blur",this.onBlur),this.element.remove()}},{key:"onKeyDown",value:function(e){13===e.keyCode?this.confirm():27===e.keyCode&&(this.cancel(),e.stopImmediatePropagation())}},{key:"onInput",value:function(){this.jumpAfter(200)}},{key:"onBlur",value:function(){var e=this;setTimeout(function(){return e.hide()},1)}}]),e}(),Pw=pi,Ow=Di,qw=sa,Dw=ne,Hw=bn,Fw=An,jw=K,Vw=Pa,Uw=pt,_w=pl,Ww=Ma("slice"),Gw=Uw("species"),Yw=Array,Xw=Math.max;Pw({target:"Array",proto:!0,forced:!Ww},{slice:function(e,t){var r,n,i,a=jw(this),o=Fw(a),s=Hw(e,o),l=Hw(void 0===t?o:t,o);if(Ow(a)&&(r=a.constructor,(qw(r)&&(r===Yw||Ow(r.prototype))||Dw(r)&&null===(r=r[Gw]))&&(r=void 0),r===Yw||void 0===r))return _w(a,s,l);for(n=new(void 0===r?Yw:r)(Xw(l-s,0)),i=0;s0&&void 0!==arguments[0]&&arguments[0],r=this.Reveal.getCurrentSlide(),n=this.Reveal.getIndices(),i=null,a=this.Reveal.getConfig().rtl?"future":"past",o=this.Reveal.getConfig().rtl?"past":"future";if(Array.from(this.element.childNodes).forEach(function(e,r){e.classList.remove("past","present","future"),rn.h?e.classList.add(o):(e.classList.add("present"),i=e),(t||r===n.h)&&fb(e,".slide-background").forEach(function(e,t){e.classList.remove("past","present","future");var a="number"==typeof n.v?n.v:0;ta?e.classList.add("future"):(e.classList.add("present"),r===n.h&&(i=e))})}),this.previousBackground&&this.Reveal.slideContent.stopEmbeddedContent(this.previousBackground,{unloadIframes:!this.Reveal.slideContent.shouldPreload(this.previousBackground)}),i){this.Reveal.slideContent.startEmbeddedContent(i);var s=i.querySelector(".slide-background-content");if(s){var l=s.style.backgroundImage||"";/\.gif/i.test(l)&&(s.style.backgroundImage="",window.getComputedStyle(s).opacity,s.style.backgroundImage=l)}var c=this.previousBackground?this.previousBackground.getAttribute("data-background-hash"):null,u=i.getAttribute("data-background-hash");u&&u===c&&i!==this.previousBackground&&this.element.classList.add("no-transition"),this.previousBackground=i}r&&this.bubbleSlideContrastClassToElement(r,this.Reveal.getRevealElement()),setTimeout(function(){e.element.classList.remove("no-transition")},1)}},{key:"updateParallax",value:function(){var e=this.Reveal.getIndices();if(this.Reveal.getConfig().parallaxBackgroundImage){var t,r,n=this.Reveal.getHorizontalSlides(),i=this.Reveal.getVerticalSlides(),a=this.element.style.backgroundSize.split(" ");1===a.length?t=r=parseInt(a[0],10):(t=parseInt(a[0],10),r=parseInt(a[1],10));var o,s=this.element.offsetWidth,l=n.length;o=("number"==typeof this.Reveal.getConfig().parallaxBackgroundHorizontal?this.Reveal.getConfig().parallaxBackgroundHorizontal:l>1?(t-s)/(l-1):0)*e.h*-1;var c,u,h=this.element.offsetHeight,d=i.length;c="number"==typeof this.Reveal.getConfig().parallaxBackgroundVertical?this.Reveal.getConfig().parallaxBackgroundVertical:(r-h)/(d-1),u=d>0?c*e.v:0,this.element.style.backgroundPosition=o+"px "+-u+"px"}}},{key:"destroy",value:function(){this.element.remove()}}]),e}(),Jw=ka.filter;pi({target:"Array",proto:!0,forced:!Ma("filter")},{filter:function(e){return Jw(this,e,arguments.length>1?arguments[1]:void 0)}});var Zw=ze,Qw=Ke,ex=U,tx=An,rx=TypeError,nx=function(e){return function(t,r,n,i){Zw(r);var a=Qw(t),o=ex(a),s=tx(a),l=e?s-1:0,c=e?-1:1;if(n<2)for(;;){if(l in o){i=o[l],l+=c;break}if(l+=c,e?l<0:s<=l)throw new rx("Reduce of empty array with no initial value")}for(;e?l>=0:s>l;l+=c)l in o&&(i=r(i,o[l],l,a));return i}},ix=[nx(!1),nx(!0)][0];pi({target:"Array",proto:!0,forced:!zs&&fe>79&&fe<83||!Zh("reduce")},{reduce:function(e){var t=arguments.length;return ix(this,e,t,t>1?arguments[1]:void 0)}});var ax=0,ox=function(){function e(t){a(this,e),this.Reveal=t}return s(e,[{key:"run",value:function(e,t){var r=this;this.reset();var n=this.Reveal.getSlides(),i=n.indexOf(t),a=n.indexOf(e);if(e.hasAttribute("data-auto-animate")&&t.hasAttribute("data-auto-animate")&&e.getAttribute("data-auto-animate-id")===t.getAttribute("data-auto-animate-id")&&!(i>a?t:e).hasAttribute("data-auto-animate-restart")){this.autoAnimateStyleSheet=this.autoAnimateStyleSheet||xb();var o=this.getAutoAnimateOptions(t);e.dataset.autoAnimate="pending",t.dataset.autoAnimate="pending",o.slideDirection=i>a?"forward":"backward";var s="none"===e.style.display;s&&(e.style.display=this.Reveal.getConfig().display);var l=this.getAutoAnimatableElements(e,t).map(function(e){return r.autoAnimateElements(e.from,e.to,e.options||{},o,ax++)});if(s&&(e.style.display="none"),"false"!==t.dataset.autoAnimateUnmatched&&!0===this.Reveal.getConfig().autoAnimateUnmatched){var c=.8*o.duration,u=.2*o.duration;this.getUnmatchedAutoAnimateElements(t).forEach(function(e){var t=r.getAutoAnimateOptions(e,o),n="unmatched";t.duration===o.duration&&t.delay===o.delay||(n="unmatched-"+ax++,l.push('[data-auto-animate="running"] [data-auto-animate-target="'.concat(n,'"] { transition: opacity ').concat(t.duration,"s ease ").concat(t.delay,"s; }"))),e.dataset.autoAnimateTarget=n},this),l.push('[data-auto-animate="running"] [data-auto-animate-target="unmatched"] { transition: opacity '.concat(c,"s ease ").concat(u,"s; }"))}this.autoAnimateStyleSheet.innerHTML=l.join(""),requestAnimationFrame(function(){r.autoAnimateStyleSheet&&(getComputedStyle(r.autoAnimateStyleSheet).fontWeight,t.dataset.autoAnimate="running")}),this.Reveal.dispatchEvent({type:"autoanimate",data:{fromSlide:e,toSlide:t,sheet:this.autoAnimateStyleSheet}})}}},{key:"reset",value:function(){fb(this.Reveal.getRevealElement(),'[data-auto-animate]:not([data-auto-animate=""])').forEach(function(e){e.dataset.autoAnimate=""}),fb(this.Reveal.getRevealElement(),"[data-auto-animate-target]").forEach(function(e){delete e.dataset.autoAnimateTarget}),this.autoAnimateStyleSheet&&this.autoAnimateStyleSheet.parentNode&&(this.autoAnimateStyleSheet.parentNode.removeChild(this.autoAnimateStyleSheet),this.autoAnimateStyleSheet=null)}},{key:"autoAnimateElements",value:function(e,t,r,n,i){e.dataset.autoAnimateTarget="",t.dataset.autoAnimateTarget=i;var a=this.getAutoAnimateOptions(t,n);void 0!==r.delay&&(a.delay=r.delay),void 0!==r.duration&&(a.duration=r.duration),void 0!==r.easing&&(a.easing=r.easing);var o=this.getAutoAnimatableProperties("from",e,r),s=this.getAutoAnimatableProperties("to",t,r);if(t.classList.contains("fragment")&&(delete s.styles.opacity, +e.classList.contains("fragment")&&(e.className.match(Ib)||[""])[0]===(t.className.match(Ib)||[""])[0]&&"forward"===n.slideDirection&&t.classList.add("visible","disabled")),!1!==r.translate||!1!==r.scale){var l=this.Reveal.getScale(),c={x:(o.x-s.x)/l,y:(o.y-s.y)/l,scaleX:o.width/s.width,scaleY:o.height/s.height};c.x=Math.round(1e3*c.x)/1e3,c.y=Math.round(1e3*c.y)/1e3,c.scaleX=Math.round(1e3*c.scaleX)/1e3,c.scaleX=Math.round(1e3*c.scaleX)/1e3;var u=!1!==r.translate&&(0!==c.x||0!==c.y),h=!1!==r.scale&&(0!==c.scaleX||0!==c.scaleY);if(u||h){var d=[];u&&d.push("translate(".concat(c.x,"px, ").concat(c.y,"px)")),h&&d.push("scale(".concat(c.scaleX,", ").concat(c.scaleY,")")),o.styles.transform=d.join(" "),o.styles["transform-origin"]="top left",s.styles.transform="none"}}for(var p in s.styles){var m=s.styles[p],f=o.styles[p];m===f?delete s.styles[p]:(!0===m.explicitValue&&(s.styles[p]=m.value),!0===f.explicitValue&&(o.styles[p]=f.value))}var g="",v=Object.keys(s.styles);v.length>0&&(o.styles.transition="none",s.styles.transition="all ".concat(a.duration,"s ").concat(a.easing," ").concat(a.delay,"s"),s.styles["transition-property"]=v.join(", "),s.styles["will-change"]=v.join(", "),g='[data-auto-animate-target="'+i+'"] {'+Object.keys(o.styles).map(function(e){return e+": "+o.styles[e]+" !important;"}).join("")+'}[data-auto-animate="running"] [data-auto-animate-target="'+i+'"] {'+Object.keys(s.styles).map(function(e){return e+": "+s.styles[e]+" !important;"}).join("")+"}");return g}},{key:"getAutoAnimateOptions",value:function(e,t){var r={easing:this.Reveal.getConfig().autoAnimateEasing,duration:this.Reveal.getConfig().autoAnimateDuration,delay:0};if(r=mb(r,t),e.parentNode){var n=wb(e.parentNode,"[data-auto-animate-target]");n&&(r=this.getAutoAnimateOptions(n,r))}return e.dataset.autoAnimateEasing&&(r.easing=e.dataset.autoAnimateEasing),e.dataset.autoAnimateDuration&&(r.duration=parseFloat(e.dataset.autoAnimateDuration)),e.dataset.autoAnimateDelay&&(r.delay=parseFloat(e.dataset.autoAnimateDelay)),r}},{key:"getAutoAnimatableProperties",value:function(e,t,r){var n=this.Reveal.getConfig(),i={styles:[]};if(!1!==r.translate||!1!==r.scale){var a;if("function"==typeof r.measure)a=r.measure(t);else if(n.center)a=t.getBoundingClientRect();else{var o=this.Reveal.getScale();a={x:t.offsetLeft*o,y:t.offsetTop*o,width:t.offsetWidth*o,height:t.offsetHeight*o}}i.x=a.x,i.y=a.y,i.width=a.width,i.height=a.height}var s=getComputedStyle(t);return(r.styles||n.autoAnimateStyles).forEach(function(t){var r;"string"==typeof t&&(t={property:t}),void 0!==t.from&&"from"===e?r={value:t.from,explicitValue:!0}:void 0!==t.to&&"to"===e?r={value:t.to,explicitValue:!0}:("line-height"===t.property&&(r=parseFloat(s["line-height"])/parseFloat(s["font-size"])),isNaN(r)&&(r=s[t.property])),""!==r&&(i.styles[t.property]=r)}),i}},{key:"getAutoAnimatableElements",value:function(e,t){var r=("function"==typeof this.Reveal.getConfig().autoAnimateMatcher?this.Reveal.getConfig().autoAnimateMatcher:this.getAutoAnimatePairs).call(this,e,t),n=[];return r.filter(function(e){if(-1===n.indexOf(e.to))return n.push(e.to),!0})}},{key:"getAutoAnimatePairs",value:function(e,t){var r=this,n=[],i="h1, h2, h3, h4, h5, h6, p, li";return this.findAutoAnimateMatches(n,e,t,"[data-id]",function(e){return e.nodeName+":::"+e.getAttribute("data-id")}),this.findAutoAnimateMatches(n,e,t,i,function(e){return e.nodeName+":::"+e.innerText}),this.findAutoAnimateMatches(n,e,t,"img, video, iframe",function(e){return e.nodeName+":::"+(e.getAttribute("src")||e.getAttribute("data-src"))}),this.findAutoAnimateMatches(n,e,t,"pre",function(e){return e.nodeName+":::"+e.innerText}),n.forEach(function(e){bb(e.from,i)?e.options={scale:!1}:bb(e.from,"pre")&&(e.options={scale:!1,styles:["width","height"]},r.findAutoAnimateMatches(n,e.from,e.to,".hljs .hljs-ln-code",function(e){return e.textContent},{scale:!1,styles:[],measure:r.getLocalBoundingBox.bind(r)}),r.findAutoAnimateMatches(n,e.from,e.to,".hljs .hljs-ln-numbers[data-line-number]",function(e){return e.getAttribute("data-line-number")},{scale:!1,styles:["width"],measure:r.getLocalBoundingBox.bind(r)}))},this),n}},{key:"getLocalBoundingBox",value:function(e){var t=this.Reveal.getScale();return{x:Math.round(e.offsetLeft*t*100)/100,y:Math.round(e.offsetTop*t*100)/100,width:Math.round(e.offsetWidth*t*100)/100,height:Math.round(e.offsetHeight*t*100)/100}}},{key:"findAutoAnimateMatches",value:function(e,t,r,n,i,a){var o={},s={};[].slice.call(t.querySelectorAll(n)).forEach(function(e){var t=i(e);"string"==typeof t&&t.length&&(o[t]=o[t]||[],o[t].push(e))}),[].slice.call(r.querySelectorAll(n)).forEach(function(t){var r,n=i(t);if(s[n]=s[n]||[],s[n].push(t),o[n]){var l=s[n].length-1,c=o[n].length-1;o[n][l]?(r=o[n][l],o[n][l]=null):o[n][c]&&(r=o[n][c],o[n][c]=null)}r&&e.push({from:r,to:t,options:a})})}},{key:"getUnmatchedAutoAnimateElements",value:function(e){var t=this;return[].slice.call(e.children).reduce(function(e,r){var n=r.querySelector("[data-auto-animate-target]");return r.hasAttribute("data-auto-animate-target")||n||e.push(r),r.querySelector("[data-auto-animate-target]")&&(e=e.concat(t.getUnmatchedAutoAnimateElements(r))),e},[])}}]),e}(),sx=$p,lx=Math.floor,cx=function(e,t){var r=e.length,n=lx(r/2);return r<8?ux(e,t):hx(e,cx(sx(e,0,n),t),cx(sx(e,n),t),t)},ux=function(e,t){for(var r,n,i=e.length,a=1;a0;)e[n]=e[--n];n!==a++&&(e[n]=r)}return e},hx=function(e,t,r,n){for(var i=t.length,a=r.length,o=0,s=0;o3)){if(Cx)return!0;if(zx)return zx<603;var e,t,r,n,i="";for(e=65;e<76;e++){switch(t=String.fromCharCode(e),e){case 66:case 69:case 70:case 72:r=3;break;case 68:case 71:r=4;break;default:r=2}for(n=0;n<47;n++)Nx.push({k:t+n,v:r})}for(Nx.sort(function(e,t){return t.v-e.v}),n=0;nAx(r)?1:-1}}(e)),r=kx(i),n=0;n0&&Hx(l)?(c=Fx(l),u=Ux(e,t,l,c,u,a-1)-1):(jx(u+1),e[u]=l),u++),h++;return u},_x=Ux,Wx=ze,Gx=Ke,Yx=An,Xx=ma;pi({target:"Array",proto:!0},{flatMap:function(e){var t,r=Gx(this),n=Yx(r);return Wx(e),(t=Xx(r,0)).length=_x(t,r,r,n,0,1,e,arguments.length>1?arguments[1]:void 0),t}}),Rw("flatMap");var $x=function(){function e(t){a(this,e),this.Reveal=t,this.active=!1,this.activatedCallbacks=[],this.onScroll=this.onScroll.bind(this)}return s(e,[{key:"activate",value:function(){var e=this;if(!this.active){var t=this.Reveal.getState();this.active=!0,this.slideHTMLBeforeActivation=this.Reveal.getSlidesElement().innerHTML;var r,n=fb(this.Reveal.getRevealElement(),Lb);this.viewportElement.classList.add("loading-scroll-mode","reveal-scroll");var i=window.getComputedStyle(this.viewportElement);i&&i.background&&(r=i.background);var a,o=[],s=n[0].parentNode,l=function(t,n,i){var s;if(a&&e.Reveal.shouldAutoAnimateBetween(a,t))(s=document.createElement("div")).className="scroll-page-content scroll-auto-animate-page",s.style.display="none",a.closest(".scroll-page-content").parentNode.appendChild(s);else{var l=document.createElement("div");l.className="scroll-page",o.push(l),r&&(l.style.background=r);var c=document.createElement("div");c.className="scroll-page-sticky",l.appendChild(c),(s=document.createElement("div")).className="scroll-page-content",c.appendChild(s)}s.appendChild(t),t.classList.remove("past","future"),t.setAttribute("data-index-h",n),t.setAttribute("data-index-v",i),t.slideBackgroundElement&&(t.slideBackgroundElement.remove("past","future"),s.insertBefore(t.slideBackgroundElement,t)),a=t};n.forEach(function(t,r){e.Reveal.isVerticalStack(t)?t.querySelectorAll("section").forEach(function(e,t){l(e,r,t)}):l(t,r,0)},this),this.createProgressBar(),fb(this.Reveal.getRevealElement(),".stack").forEach(function(e){return e.remove()}),o.forEach(function(e){return s.appendChild(e)}),this.Reveal.slideContent.layout(this.Reveal.getSlidesElement()),this.Reveal.layout(),this.Reveal.setState(t),this.activatedCallbacks.forEach(function(e){return e()}),this.activatedCallbacks=[],this.restoreScrollPosition(),this.viewportElement.classList.remove("loading-scroll-mode"),this.viewportElement.addEventListener("scroll",this.onScroll,{passive:!0})}}},{key:"deactivate",value:function(){if(this.active){var e=this.Reveal.getState();this.active=!1,this.viewportElement.removeEventListener("scroll",this.onScroll),this.viewportElement.classList.remove("reveal-scroll"),this.removeProgressBar(),this.Reveal.getSlidesElement().innerHTML=this.slideHTMLBeforeActivation,this.Reveal.sync(),this.Reveal.setState(e),this.slideHTMLBeforeActivation=null}}},{key:"toggle",value:function(e){"boolean"==typeof e?e?this.activate():this.deactivate():this.isActive()?this.deactivate():this.activate()}},{key:"isActive",value:function(){return this.active}},{key:"createProgressBar",value:function(){var e=this;this.progressBar=document.createElement("div"),this.progressBar.className="scrollbar",this.progressBarInner=document.createElement("div"),this.progressBarInner.className="scrollbar-inner",this.progressBar.appendChild(this.progressBarInner),this.progressBarPlayhead=document.createElement("div"),this.progressBarPlayhead.className="scrollbar-playhead",this.progressBarInner.appendChild(this.progressBarPlayhead),this.viewportElement.insertBefore(this.progressBar,this.viewportElement.firstChild);var t=function(t){var r=(t.clientY-e.progressBarInner.getBoundingClientRect().top)/e.progressBarHeight;r=Math.max(Math.min(r,1),0),e.viewportElement.scrollTop=r*(e.viewportElement.scrollHeight-e.viewportElement.offsetHeight)},r=function n(){e.draggingProgressBar=!1,e.showProgressBar(),document.removeEventListener("mousemove",t),document.removeEventListener("mouseup",n)};this.progressBarInner.addEventListener("mousedown",function(n){n.preventDefault(),e.draggingProgressBar=!0,document.addEventListener("mousemove",t),document.addEventListener("mouseup",r),t(n)})}},{key:"removeProgressBar",value:function(){this.progressBar&&(this.progressBar.remove(),this.progressBar=null)}},{key:"layout",value:function(){this.isActive()&&(this.syncPages(),this.syncScrollPosition())}},{key:"syncPages",value:function(){var e=this,t=this.Reveal.getConfig(),r=this.Reveal.getComputedSlideSize(window.innerWidth,window.innerHeight),n=this.Reveal.getScale(),i="compact"===t.scrollLayout,a=this.viewportElement.offsetHeight,o=r.height*n,s=i?o:a,l=i?o:a;this.viewportElement.style.setProperty("--page-height",s+"px"),this.viewportElement.style.scrollSnapType="string"==typeof t.scrollSnap?"y ".concat(t.scrollSnap):"",this.slideTriggers=[];var c=Array.from(this.Reveal.getRevealElement().querySelectorAll(".scroll-page"));this.pages=c.map(function(n){var o=e.createPage({pageElement:n,slideElement:n.querySelector("section"),stickyElement:n.querySelector(".scroll-page-sticky"),contentElement:n.querySelector(".scroll-page-content"),backgroundElement:n.querySelector(".slide-background"),autoAnimateElements:n.querySelectorAll(".scroll-auto-animate-page"),autoAnimatePages:[]});o.pageElement.style.setProperty("--slide-height",!0===t.center?"auto":r.height+"px"),e.slideTriggers.push({page:o,activate:function(){return e.activatePage(o)},deactivate:function(){return e.deactivatePage(o)}}),e.createFragmentTriggersForPage(o),o.autoAnimateElements.length>0&&e.createAutoAnimateTriggersForPage(o);var c=Math.max(o.scrollTriggers.length-1,0);c+=o.autoAnimatePages.reduce(function(e,t){return e+Math.max(t.scrollTriggers.length-1,0)},o.autoAnimatePages.length),o.pageElement.querySelectorAll(".scroll-snap-point").forEach(function(e){return e.remove()});for(var u=0;u0?(o.pageHeight=a,o.pageElement.style.setProperty("--page-height",a+"px")):(o.pageHeight=s,o.pageElement.style.removeProperty("--page-height")),o.scrollPadding=l*c,o.totalHeight=o.pageHeight+o.scrollPadding,o.pageElement.style.setProperty("--page-scroll-padding",o.scrollPadding+"px"),c>0?(o.stickyElement.style.position="sticky",o.stickyElement.style.top=Math.max((a-o.pageHeight)/2,0)+"px"):(o.stickyElement.style.position="relative",o.pageElement.style.scrollSnapAlign=o.pageHeight1?(this.progressBar||this.createProgressBar(),this.syncProgressBar()):this.removeProgressBar()}},{key:"setTriggerRanges",value:function(){var e=this;this.totalScrollTriggerCount=this.slideTriggers.reduce(function(e,t){return e+Math.max(t.page.scrollTriggers.length,1)},0);var t=0;this.slideTriggers.forEach(function(r){r.range=[t,t+Math.max(r.page.scrollTriggers.length,1)/e.totalScrollTriggerCount];var n=(r.range[1]-r.range[0])/r.page.scrollTriggers.length;r.page.scrollTriggers.forEach(function(e,r){e.range=[t+r*n,t+(r+1)*n]}),t=r.range[1]})}},{key:"createFragmentTriggersForPage",value:function(e,t){var r=this;t=t||e.slideElement;var n,i=this.Reveal.fragments.sort(t.querySelectorAll(".fragment"),!0);return i.length&&(e.fragments=this.Reveal.fragments.sort(t.querySelectorAll(".fragment:not(.disabled)")),(n=e.scrollTriggers).push.apply(n,[{activate:function(){r.Reveal.fragments.update(-1,e.fragments,t)}}].concat(c(i.map(function(n,i){return{activate:function(){r.Reveal.fragments.update(i,e.fragments,t)}}}))))),e.scrollTriggers.length}},{key:"createAutoAnimateTriggersForPage",value:function(e){var t,r=this;e.autoAnimateElements.length>0&&(t=this.slideTriggers).push.apply(t,c(Array.from(e.autoAnimateElements).map(function(t){var n=r.createPage({slideElement:t.querySelector("section"),contentElement:t,backgroundElement:t.querySelector(".slide-background")});return r.createFragmentTriggersForPage(n,n.slideElement),e.autoAnimatePages.push(n),{page:n,activate:function(){return r.activatePage(n)},deactivate:function(){return r.deactivatePage(n)}}})))}},{key:"createPage",value:function(e){return e.scrollTriggers=[],e.indexh=parseInt(e.slideElement.getAttribute("data-index-h"),10),e.indexv=parseInt(e.slideElement.getAttribute("data-index-v"),10),e}},{key:"syncProgressBar",value:function(){var e=this;this.progressBarInner.querySelectorAll(".scrollbar-slide").forEach(function(e){return e.remove()});var t=this.viewportElement.scrollHeight,r=this.viewportElement.offsetHeight,n=r/t;this.progressBarHeight=this.progressBarInner.offsetHeight,this.playheadHeight=Math.max(n*this.progressBarHeight,8),this.progressBarScrollableHeight=this.progressBarHeight-this.playheadHeight;var i=r/t*this.progressBarHeight,a=Math.min(i/8,4);this.progressBarPlayhead.style.height=this.playheadHeight-a+"px",i>6?this.slideTriggers.forEach(function(t){var r=t.page;r.progressBarSlide=document.createElement("div"),r.progressBarSlide.className="scrollbar-slide",r.progressBarSlide.style.top=t.range[0]*e.progressBarHeight+"px",r.progressBarSlide.style.height=(t.range[1]-t.range[0])*e.progressBarHeight-a+"px",r.progressBarSlide.classList.toggle("has-triggers",r.scrollTriggers.length>0),e.progressBarInner.appendChild(r.progressBarSlide),r.scrollTriggerElements=r.scrollTriggers.map(function(n,i){var o=document.createElement("div");return o.className="scrollbar-trigger",o.style.top=(n.range[0]-t.range[0])*e.progressBarHeight+"px",o.style.height=(n.range[1]-n.range[0])*e.progressBarHeight-a+"px",r.progressBarSlide.appendChild(o),0===i&&(o.style.display="none"),o})}):this.pages.forEach(function(e){return e.progressBarSlide=null})}},{key:"syncScrollPosition",value:function(){var e,t=this,r=this.viewportElement.offsetHeight,n=r/this.viewportElement.scrollHeight,i=this.viewportElement.scrollTop,a=this.viewportElement.scrollHeight-r,o=Math.max(Math.min(i/a,1),0),s=Math.max(Math.min((i+r/2)/this.viewportElement.scrollHeight,1),0);this.slideTriggers.forEach(function(r){var i=r.page;o>=r.range[0]-2*n&&o<=r.range[1]+2*n&&!i.loaded?(i.loaded=!0,t.Reveal.slideContent.load(i.slideElement)):i.loaded&&(i.loaded=!1,t.Reveal.slideContent.unload(i.slideElement)),o>=r.range[0]&&o<=r.range[1]?(t.activateTrigger(r),e=r.page):r.active&&t.deactivateTrigger(r)}),e&&e.scrollTriggers.forEach(function(e){s>=e.range[0]&&s<=e.range[1]?t.activateTrigger(e):e.active&&t.deactivateTrigger(e)}),this.setProgressBarValue(i/(this.viewportElement.scrollHeight-r))}},{key:"setProgressBarValue",value:function(e){this.progressBar&&(this.progressBarPlayhead.style.transform="translateY(".concat(e*this.progressBarScrollableHeight,"px)"),this.getAllPages().filter(function(e){return e.progressBarSlide}).forEach(function(e){e.progressBarSlide.classList.toggle("active",!0===e.active),e.scrollTriggers.forEach(function(t,r){e.scrollTriggerElements[r].classList.toggle("active",!0===e.active&&!0===t.active)})}),this.showProgressBar())}},{key:"showProgressBar",value:function(){var e=this;this.progressBar.classList.add("visible"),clearTimeout(this.hideProgressBarTimeout),"auto"!==this.Reveal.getConfig().scrollProgress||this.draggingProgressBar||(this.hideProgressBarTimeout=setTimeout(function(){e.progressBar&&e.progressBar.classList.remove("visible")},500))}},{key:"scrollToSlide",value:function(e){var t=this;if(this.active){var r=this.getScrollTriggerBySlide(e);r&&(this.viewportElement.scrollTop=r.range[0]*(this.viewportElement.scrollHeight-this.viewportElement.offsetHeight))}else this.activatedCallbacks.push(function(){return t.scrollToSlide(e)})}},{key:"storeScrollPosition",value:function(){var e=this;clearTimeout(this.storeScrollPositionTimeout),this.storeScrollPositionTimeout=setTimeout(function(){sessionStorage.setItem("reveal-scroll-top",e.viewportElement.scrollTop),sessionStorage.setItem("reveal-scroll-origin",location.origin+location.pathname),e.storeScrollPositionTimeout=null},50)}},{key:"restoreScrollPosition",value:function(){var e=sessionStorage.getItem("reveal-scroll-top"),t=sessionStorage.getItem("reveal-scroll-origin");e&&t===location.origin+location.pathname&&(this.viewportElement.scrollTop=parseInt(e,10))}},{key:"activatePage",value:function(e){if(!e.active){e.active=!0;var t=e.slideElement,r=e.backgroundElement,n=e.contentElement,i=e.indexh,a=e.indexv;n.style.display="block",t.classList.add("present"),r&&r.classList.add("present"),this.Reveal.setCurrentScrollPage(t,i,a),this.Reveal.backgrounds.bubbleSlideContrastClassToElement(t,this.viewportElement),Array.from(n.parentNode.querySelectorAll(".scroll-page-content")).forEach(function(e){e!==n&&(e.style.display="none")})}}},{key:"deactivatePage",value:function(e){e.active&&(e.active=!1,e.slideElement.classList.remove("present"),e.backgroundElement.classList.remove("present"))}},{key:"activateTrigger",value:function(e){e.active||(e.active=!0,e.activate())}},{key:"deactivateTrigger",value:function(e){e.active&&(e.active=!1,e.deactivate&&e.deactivate())}},{key:"getSlideByIndices",value:function(e,t){var r=this.getAllPages().find(function(r){return r.indexh===e&&r.indexv===t});return r?r.slideElement:null}},{key:"getScrollTriggerBySlide",value:function(e){return this.slideTriggers.find(function(t){return t.page.slideElement===e})}},{key:"getAllPages",value:function(){return this.pages.flatMap(function(e){return[e].concat(c(e.autoAnimatePages||[]))})}},{key:"onScroll",value:function(){this.syncScrollPosition(),this.storeScrollPosition()}},{key:"viewportElement",get:function(){return this.Reveal.getViewportElement()}}]),e}(),Kx=function(){function e(t){a(this,e),this.Reveal=t}var t,n;return s(e,[{key:"activate",value:(t=r().mark(function o(){var e,t,n,i,a,s,l,c,u,h,d,p,m,f,g;return r().wrap(function(r){for(;;)switch(r.prev=r.next){case 0:return e=this.Reveal.getConfig(),t=fb(this.Reveal.getRevealElement(),Cb),n=e.slideNumber&&/all|print/i.test(e.showSlideNumber),i=this.Reveal.getComputedSlideSize(window.innerWidth,window.innerHeight),a=Math.floor(i.width*(1+e.margin)),s=Math.floor(i.height*(1+e.margin)),l=i.width,c=i.height,r.next=8,new Promise(requestAnimationFrame);case 8:return xb("@page{size:"+a+"px "+s+"px; margin: 0px;}"),xb(".reveal section>img, .reveal section>video, .reveal section>iframe{max-width: "+l+"px; max-height:"+c+"px}"),document.documentElement.classList.add("reveal-print","print-pdf"),document.body.style.width=a+"px",document.body.style.height=s+"px",(u=this.Reveal.getViewportElement())&&(d=window.getComputedStyle(u))&&d.background&&(h=d.background),r.next=17,new Promise(requestAnimationFrame);case 17:return this.Reveal.layoutSlideContents(l,c),r.next=20,new Promise(requestAnimationFrame);case 20:return p=t.map(function(e){return e.scrollHeight}),m=[],f=t[0].parentNode,g=1,t.forEach(function(t,r){if(!1===t.classList.contains("stack")){var i=(a-l)/2,o=(s-c)/2,u=p[r],d=Math.max(Math.ceil(u/s),1);(1===(d=Math.min(d,e.pdfMaxPagesPerSlide))&&e.center||t.classList.contains("center"))&&(o=Math.max((s-u)/2,0));var f=document.createElement("div");if(m.push(f),f.className="pdf-page",f.style.height=(s+e.pdfPageHeightOffset)*d+"px",h&&(f.style.background=h),f.appendChild(t),t.style.left=i+"px",t.style.top=o+"px",t.style.width=l+"px",this.Reveal.slideContent.layout(t),t.slideBackgroundElement&&f.insertBefore(t.slideBackgroundElement,t),e.showNotes){var v=this.Reveal.getSlideNotes(t);if(v){var y="string"==typeof e.showNotes?e.showNotes:"inline",b=document.createElement("div");b.classList.add("speaker-notes"),b.classList.add("speaker-notes-pdf"),b.setAttribute("data-layout",y),b.innerHTML=v,"separate-page"===y?m.push(b):(b.style.left="8px",b.style.bottom="8px",b.style.width=a-16+"px",f.appendChild(b))}}if(n){var w=document.createElement("div");w.classList.add("slide-number"),w.classList.add("slide-number-pdf"),w.innerHTML=g++,f.appendChild(w)}if(e.pdfSeparateFragments){var x,k=this.Reveal.fragments.sort(f.querySelectorAll(".fragment"),!0);k.forEach(function(e,t){x&&x.forEach(function(e){e.classList.remove("current-fragment")}),e.forEach(function(e){e.classList.add("visible","current-fragment")},this);var r=f.cloneNode(!0);if(n){var i=t+1;r.querySelector(".slide-number-pdf").innerHTML+="."+i}m.push(r),x=e},this),k.forEach(function(e){e.forEach(function(e){e.classList.remove("visible","current-fragment")})})}else fb(f,".fragment:not(.fade-out)").forEach(function(e){e.classList.add("visible")})}},this),r.next=27,new Promise(requestAnimationFrame);case 27:m.forEach(function(e){return f.appendChild(e)}),this.Reveal.slideContent.layout(this.Reveal.getSlidesElement()),this.Reveal.dispatchEvent({type:"pdf-ready"});case 30:case"end":return r.stop()}},o,this)}),n=function(){var e=this,r=arguments;return new Promise(function(n,a){function o(e){i(l,n,a,o,s,"next",e)}function s(e){i(l,n,a,o,s,"throw",e)}var l=t.apply(e,r);o(void 0)})},function(){return n.apply(this,arguments)})},{key:"isActive",value:function(){return"print"===this.Reveal.getConfig().view}}]),e}(),Jx=function(){function e(t){a(this,e),this.Reveal=t}return s(e,[{key:"configure",value:function(e,t){!1===e.fragments?this.disable():!1===t.fragments&&this.enable()}},{key:"disable",value:function(){fb(this.Reveal.getSlidesElement(),".fragment").forEach(function(e){e.classList.add("visible"),e.classList.remove("current-fragment")})}},{key:"enable",value:function(){fb(this.Reveal.getSlidesElement(),".fragment").forEach(function(e){e.classList.remove("visible"),e.classList.remove("current-fragment")})}},{key:"availableRoutes",value:function(){var e=this.Reveal.getCurrentSlide();if(e&&this.Reveal.getConfig().fragments){var t=e.querySelectorAll(".fragment:not(.disabled)"),r=e.querySelectorAll(".fragment:not(.disabled):not(.visible)");return{prev:t.length-r.length>0,next:!!r.length}}return{prev:!1,next:!1}}},{key:"sort",value:function(e){var t=arguments.length>1&&void 0!==arguments[1]&&arguments[1];e=Array.from(e);var r=[],n=[],i=[];e.forEach(function(e){if(e.hasAttribute("data-fragment-index")){var t=parseInt(e.getAttribute("data-fragment-index"),10);r[t]||(r[t]=[]),r[t].push(e)}else n.push([e])}),r=r.concat(n);var a=0;return r.forEach(function(e){e.forEach(function(e){i.push(e),e.setAttribute("data-fragment-index",a)}),a++}),!0===t?r:i}},{key:"sortAll",value:function(){var e=this;this.Reveal.getHorizontalSlides().forEach(function(t){var r=fb(t,"section");r.forEach(function(t){e.sort(t.querySelectorAll(".fragment"))},e),0===r.length&&e.sort(t.querySelectorAll(".fragment"))})}},{key:"update",value:function(e,t){var r=this,n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:this.Reveal.getCurrentSlide(),i={shown:[],hidden:[]};if(n&&this.Reveal.getConfig().fragments&&(t=t||this.sort(n.querySelectorAll(".fragment"))).length){var a=0;if("number"!=typeof e){var o=this.sort(n.querySelectorAll(".fragment.visible")).pop();o&&(e=parseInt(o.getAttribute("data-fragment-index")||0,10))}Array.from(t).forEach(function(t,n){if(t.hasAttribute("data-fragment-index")&&(n=parseInt(t.getAttribute("data-fragment-index"),10)),a=Math.max(a,n),n<=e){var o=t.classList.contains("visible");t.classList.add("visible"),t.classList.remove("current-fragment"),n===e&&(r.Reveal.announceStatus(r.Reveal.getStatusText(t)),t.classList.add("current-fragment"),r.Reveal.slideContent.startEmbeddedContent(t)),o||(i.shown.push(t),r.Reveal.dispatchEvent({target:t,type:"visible",bubbles:!1}))}else{var s=t.classList.contains("visible");t.classList.remove("visible"),t.classList.remove("current-fragment"),s&&(r.Reveal.slideContent.stopEmbeddedContent(t),i.hidden.push(t),r.Reveal.dispatchEvent({target:t,type:"hidden",bubbles:!1}))}}),e="number"==typeof e?e:-1,e=Math.max(Math.min(e,a),-1),n.setAttribute("data-fragment",e)}return i}},{key:"sync",value:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:this.Reveal.getCurrentSlide();return this.sort(e.querySelectorAll(".fragment"))}},{key:"goto",value:function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:0,r=this.Reveal.getCurrentSlide();if(r&&this.Reveal.getConfig().fragments){var n=this.sort(r.querySelectorAll(".fragment:not(.disabled)"));if(n.length){if("number"!=typeof e){var i=this.sort(r.querySelectorAll(".fragment:not(.disabled).visible")).pop();e=i?parseInt(i.getAttribute("data-fragment-index")||0,10):-1}e+=t;var a=this.update(e,n);return a.hidden.length&&this.Reveal.dispatchEvent({type:"fragmenthidden",data:{fragment:a.hidden[0],fragments:a.hidden}}),a.shown.length&&this.Reveal.dispatchEvent({type:"fragmentshown",data:{fragment:a.shown[0],fragments:a.shown}}),this.Reveal.controls.update(),this.Reveal.progress.update(),this.Reveal.getConfig().fragmentInURL&&this.Reveal.location.writeURL(),!(!a.shown.length&&!a.hidden.length)}}return!1}},{key:"next",value:function(){return this.goto(null,1)}},{key:"prev",value:function(){return this.goto(null,-1)}}]),e}(),Zx=function(){function e(t){a(this,e),this.Reveal=t,this.active=!1,this.onSlideClicked=this.onSlideClicked.bind(this)}return s(e,[{key:"activate",value:function(){var e=this;if(this.Reveal.getConfig().overview&&!this.Reveal.isScrollView()&&!this.isActive()){this.active=!0,this.Reveal.getRevealElement().classList.add("overview"),this.Reveal.cancelAutoSlide(),this.Reveal.getSlidesElement().appendChild(this.Reveal.getBackgroundsElement()),fb(this.Reveal.getRevealElement(),Cb).forEach(function(t){t.classList.contains("stack")||t.addEventListener("click",e.onSlideClicked,!0)});var t=this.Reveal.getComputedSlideSize();this.overviewSlideWidth=t.width+70,this.overviewSlideHeight=t.height+70,this.Reveal.getConfig().rtl&&(this.overviewSlideWidth=-this.overviewSlideWidth),this.Reveal.updateSlidesVisibility(),this.layout(),this.update(),this.Reveal.layout();var r=this.Reveal.getIndices();this.Reveal.dispatchEvent({type:"overviewshown",data:{indexh:r.h,indexv:r.v,currentSlide:this.Reveal.getCurrentSlide()}})}}},{key:"layout",value:function(){var e=this;this.Reveal.getHorizontalSlides().forEach(function(t,r){t.setAttribute("data-index-h",r),yb(t,"translate3d("+r*e.overviewSlideWidth+"px, 0, 0)"),t.classList.contains("stack")&&fb(t,"section").forEach(function(t,n){t.setAttribute("data-index-h",r),t.setAttribute("data-index-v",n),yb(t,"translate3d(0, "+n*e.overviewSlideHeight+"px, 0)")})}),Array.from(this.Reveal.getBackgroundsElement().childNodes).forEach(function(t,r){yb(t,"translate3d("+r*e.overviewSlideWidth+"px, 0, 0)"),fb(t,".slide-background").forEach(function(t,r){yb(t,"translate3d(0, "+r*e.overviewSlideHeight+"px, 0)")})})}},{key:"update",value:function(){var e=Math.min(window.innerWidth,window.innerHeight),t=Math.max(e/5,150)/e,r=this.Reveal.getIndices();this.Reveal.transformSlides({overview:["scale("+t+")","translateX("+-r.h*this.overviewSlideWidth+"px)","translateY("+-r.v*this.overviewSlideHeight+"px)"].join(" ")})}},{key:"deactivate",value:function(){var e=this;if(this.Reveal.getConfig().overview){this.active=!1,this.Reveal.getRevealElement().classList.remove("overview"),this.Reveal.getRevealElement().classList.add("overview-deactivating"),setTimeout(function(){e.Reveal.getRevealElement().classList.remove("overview-deactivating")},1),this.Reveal.getRevealElement().appendChild(this.Reveal.getBackgroundsElement()),fb(this.Reveal.getRevealElement(),Cb).forEach(function(t){yb(t,""),t.removeEventListener("click",e.onSlideClicked,!0)}),fb(this.Reveal.getBackgroundsElement(),".slide-background").forEach(function(e){yb(e,"")}),this.Reveal.transformSlides({overview:""});var t=this.Reveal.getIndices();this.Reveal.slide(t.h,t.v),this.Reveal.layout(),this.Reveal.cueAutoSlide(),this.Reveal.dispatchEvent({type:"overviewhidden",data:{indexh:t.h,indexv:t.v,currentSlide:this.Reveal.getCurrentSlide()}})}}},{key:"toggle",value:function(e){"boolean"==typeof e?e?this.activate():this.deactivate():this.isActive()?this.deactivate():this.activate()}},{key:"isActive",value:function(){return this.active}},{key:"onSlideClicked",value:function(e){if(this.isActive()){e.preventDefault();for(var t=e.target;t&&!t.nodeName.match(/section/gi);)t=t.parentNode;if(t&&!t.classList.contains("disabled")&&(this.deactivate(),t.nodeName.match(/section/gi))){var r=parseInt(t.getAttribute("data-index-h"),10),n=parseInt(t.getAttribute("data-index-v"),10);this.Reveal.slide(r,n)}}}}]),e}(),Qx=Cn.includes,ek=Rw;pi({target:"Array",proto:!0,forced:v(function(){return!Array(1).includes()})},{includes:function(e){return Qx(this,e,arguments.length>1?arguments[1]:void 0)}}),ek("includes");var tk=function(){function e(t){a(this,e),this.Reveal=t,this.shortcuts={},this.bindings={},this.onDocumentKeyDown=this.onDocumentKeyDown.bind(this)}return s(e,[{key:"configure",value:function(e){"linear"===e.navigationMode?(this.shortcuts["→ , ↓ , SPACE , N , L , J"]="Next slide",this.shortcuts["← , ↑ , P , H , K"]="Previous slide"):(this.shortcuts["N , SPACE"]="Next slide",this.shortcuts["P , Shift SPACE"]="Previous slide",this.shortcuts["← , H"]="Navigate left",this.shortcuts["→ , L"]="Navigate right",this.shortcuts["↑ , K"]="Navigate up",this.shortcuts["↓ , J"]="Navigate down"),this.shortcuts["Alt + ←/↑/→/↓"]="Navigate without fragments",this.shortcuts["Shift + ←/↑/→/↓"]="Jump to first/last slide",this.shortcuts["B , ."]="Pause",this.shortcuts.F="Fullscreen",this.shortcuts.G="Jump to slide",this.shortcuts["ESC, O"]="Slide overview"}},{key:"bind",value:function(){document.addEventListener("keydown",this.onDocumentKeyDown,!1)}},{key:"unbind",value:function(){document.removeEventListener("keydown",this.onDocumentKeyDown,!1)}},{key:"addKeyBinding",value:function(e,t){"object"===n(e)&&e.keyCode?this.bindings[e.keyCode]={callback:t,key:e.key,description:e.description}:this.bindings[e]={callback:t,key:null,description:null}}},{key:"removeKeyBinding",value:function(e){delete this.bindings[e]}},{key:"triggerKey",value:function(e){this.onDocumentKeyDown({keyCode:e})}},{ +key:"registerKeyboardShortcut",value:function(e,t){this.shortcuts[e]=t}},{key:"getShortcuts",value:function(){return this.shortcuts}},{key:"getBindings",value:function(){return this.bindings}},{key:"onDocumentKeyDown",value:function(e){var t=this.Reveal.getConfig();if("function"==typeof t.keyboardCondition&&!1===t.keyboardCondition(e))return!0;if("focused"===t.keyboardCondition&&!this.Reveal.isFocused())return!0;var r=e.keyCode,i=!this.Reveal.isAutoSliding();this.Reveal.onUserInput(e);var a=document.activeElement&&!0===document.activeElement.isContentEditable,o=document.activeElement&&document.activeElement.tagName&&/input|textarea/i.test(document.activeElement.tagName),s=document.activeElement&&document.activeElement.className&&/speaker-notes/i.test(document.activeElement.className),l=!(-1!==[32,37,38,39,40,78,80,191].indexOf(e.keyCode)&&e.shiftKey||e.altKey)&&(e.shiftKey||e.altKey||e.ctrlKey||e.metaKey);if(!(a||o||s||l)){var c,u=[66,86,190,191];if("object"===n(t.keyboard))for(c in t.keyboard)"togglePause"===t.keyboard[c]&&u.push(parseInt(c,10));if(this.Reveal.isPaused()&&-1===u.indexOf(r))return!1;var h,d,p="linear"===t.navigationMode||!this.Reveal.hasHorizontalSlides()||!this.Reveal.hasVerticalSlides(),m=!1;if("object"===n(t.keyboard))for(c in t.keyboard)if(parseInt(c,10)===r){var f=t.keyboard[c];"function"==typeof f?f.apply(null,[e]):"string"==typeof f&&"function"==typeof this.Reveal[f]&&this.Reveal[f].call(),m=!0}if(!1===m)for(c in this.bindings)if(parseInt(c,10)===r){var g=this.bindings[c].callback;"function"==typeof g?g.apply(null,[e]):"string"==typeof g&&"function"==typeof this.Reveal[g]&&this.Reveal[g].call(),m=!0}!1===m&&(m=!0,80===r||33===r?this.Reveal.prev({skipFragments:e.altKey}):78===r||34===r?this.Reveal.next({skipFragments:e.altKey}):72===r||37===r?e.shiftKey?this.Reveal.slide(0):!this.Reveal.overview.isActive()&&p?this.Reveal.prev({skipFragments:e.altKey}):this.Reveal.left({skipFragments:e.altKey}):76===r||39===r?e.shiftKey?this.Reveal.slide(this.Reveal.getHorizontalSlides().length-1):!this.Reveal.overview.isActive()&&p?this.Reveal.next({skipFragments:e.altKey}):this.Reveal.right({skipFragments:e.altKey}):75===r||38===r?e.shiftKey?this.Reveal.slide(void 0,0):!this.Reveal.overview.isActive()&&p?this.Reveal.prev({skipFragments:e.altKey}):this.Reveal.up({skipFragments:e.altKey}):74===r||40===r?e.shiftKey?this.Reveal.slide(void 0,Number.MAX_VALUE):!this.Reveal.overview.isActive()&&p?this.Reveal.next({skipFragments:e.altKey}):this.Reveal.down({skipFragments:e.altKey}):36===r?this.Reveal.slide(0):35===r?this.Reveal.slide(this.Reveal.getHorizontalSlides().length-1):32===r?(this.Reveal.overview.isActive()&&this.Reveal.overview.deactivate(),e.shiftKey?this.Reveal.prev({skipFragments:e.altKey}):this.Reveal.next({skipFragments:e.altKey})):[58,59,66,86,190].includes(r)||191===r&&!e.shiftKey?this.Reveal.togglePause():70===r?(d=(h=(h=t.embedded?this.Reveal.getViewportElement():document.documentElement)||document.documentElement).requestFullscreen||h.webkitRequestFullscreen||h.webkitRequestFullScreen||h.mozRequestFullScreen||h.msRequestFullscreen)&&d.apply(h):65===r?t.autoSlideStoppable&&this.Reveal.toggleAutoSlide(i):71===r?t.jumpToSlide&&this.Reveal.toggleJumpToSlide():191===r&&e.shiftKey?this.Reveal.toggleHelp():m=!1),m?e.preventDefault&&e.preventDefault():27!==r&&79!==r||(!1===this.Reveal.closeOverlay()&&this.Reveal.overview.toggle(),e.preventDefault&&e.preventDefault()),this.Reveal.cueAutoSlide()}}}]),e}(),rk=function(){function e(t){a(this,e),l(this,"MAX_REPLACE_STATE_FREQUENCY",1e3),this.Reveal=t,this.writeURLTimeout=0,this.replaceStateTimestamp=0,this.onWindowHashChange=this.onWindowHashChange.bind(this)}return s(e,[{key:"bind",value:function(){window.addEventListener("hashchange",this.onWindowHashChange,!1)}},{key:"unbind",value:function(){window.removeEventListener("hashchange",this.onWindowHashChange,!1)}},{key:"getIndicesFromHash",value:function(){var e,r,n=arguments.length>0&&void 0!==arguments[0]?arguments[0]:window.location.hash,i=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},a=n.replace(/^#\/?/,""),o=a.split("/");if(/^[0-9]*$/.test(o[0])||!a.length){var s,l=this.Reveal.getConfig(),c=l.hashOneBasedIndex||i.oneBasedIndex?1:0,u=parseInt(o[0],10)-c||0,h=parseInt(o[1],10)-c||0;return l.fragmentInURL&&(s=parseInt(o[2],10),isNaN(s)&&(s=void 0)),{h:u,v:h,f:s}}/\/[-\d]+$/g.test(a)&&(r=parseInt(a.split("/").pop(),10),r=isNaN(r)?void 0:r,a=a.split("/").shift());try{e=document.getElementById(decodeURIComponent(a)).closest(".slides section")}catch(e){}return e?t(t({},this.Reveal.getIndices(e)),{},{f:r}):null}},{key:"readURL",value:function(){var e=this.Reveal.getIndices(),t=this.getIndicesFromHash();t?t.h===e.h&&t.v===e.v&&void 0===t.f||this.Reveal.slide(t.h,t.v,t.f):this.Reveal.slide(e.h||0,e.v||0)}},{key:"writeURL",value:function(e){var t=this.Reveal.getConfig(),r=this.Reveal.getCurrentSlide();if(clearTimeout(this.writeURLTimeout),"number"==typeof e)this.writeURLTimeout=setTimeout(this.writeURL,e);else if(r){var n=this.getHash();t.history?window.location.hash=n:t.hash&&("/"===n?this.debouncedReplaceState(window.location.pathname+window.location.search):this.debouncedReplaceState("#"+n))}}},{key:"replaceState",value:function(e){window.history.replaceState(null,null,e),this.replaceStateTimestamp=Date.now()}},{key:"debouncedReplaceState",value:function(e){var t=this;clearTimeout(this.replaceStateTimeout),Date.now()-this.replaceStateTimestamp>this.MAX_REPLACE_STATE_FREQUENCY?this.replaceState(e):this.replaceStateTimeout=setTimeout(function(){return t.replaceState(e)},this.MAX_REPLACE_STATE_FREQUENCY)}},{key:"getHash",value:function(e){var t="/",r=e||this.Reveal.getCurrentSlide(),n=r?r.getAttribute("id"):null;n&&(n=encodeURIComponent(n));var i=this.Reveal.getIndices(e);if(this.Reveal.getConfig().fragmentInURL||(i.f=void 0),"string"==typeof n&&n.length)t="/"+n,i.f>=0&&(t+="/"+i.f);else{var a=this.Reveal.getConfig().hashOneBasedIndex?1:0;(i.h>0||i.v>0||i.f>=0)&&(t+=i.h+a),(i.v>0||i.f>=0)&&(t+="/"+(i.v+a)),i.f>=0&&(t+="/"+i.f)}return t}},{key:"onWindowHashChange",value:function(){this.readURL()}}]),e}(),nk=function(){function e(t){a(this,e),this.Reveal=t,this.onNavigateLeftClicked=this.onNavigateLeftClicked.bind(this),this.onNavigateRightClicked=this.onNavigateRightClicked.bind(this),this.onNavigateUpClicked=this.onNavigateUpClicked.bind(this),this.onNavigateDownClicked=this.onNavigateDownClicked.bind(this),this.onNavigatePrevClicked=this.onNavigatePrevClicked.bind(this),this.onNavigateNextClicked=this.onNavigateNextClicked.bind(this)}return s(e,[{key:"render",value:function(){var e=this.Reveal.getConfig().rtl,t=this.Reveal.getRevealElement();this.element=document.createElement("aside"),this.element.className="controls",this.element.innerHTML='\n\t\t\t\n\t\t\t\n\t\t\t'),this.Reveal.getRevealElement().appendChild(this.element),this.controlsLeft=fb(t,".navigate-left"),this.controlsRight=fb(t,".navigate-right"),this.controlsUp=fb(t,".navigate-up"),this.controlsDown=fb(t,".navigate-down"),this.controlsPrev=fb(t,".navigate-prev"),this.controlsNext=fb(t,".navigate-next"),this.controlsRightArrow=this.element.querySelector(".navigate-right"),this.controlsLeftArrow=this.element.querySelector(".navigate-left"),this.controlsDownArrow=this.element.querySelector(".navigate-down")}},{key:"configure",value:function(e){this.element.style.display=e.controls?"block":"none",this.element.setAttribute("data-controls-layout",e.controlsLayout),this.element.setAttribute("data-controls-back-arrows",e.controlsBackArrows)}},{key:"bind",value:function(){var e=this,t=["touchstart","click"];Mb&&(t=["touchstart"]),t.forEach(function(t){e.controlsLeft.forEach(function(r){return r.addEventListener(t,e.onNavigateLeftClicked,!1)}),e.controlsRight.forEach(function(r){return r.addEventListener(t,e.onNavigateRightClicked,!1)}),e.controlsUp.forEach(function(r){return r.addEventListener(t,e.onNavigateUpClicked,!1)}),e.controlsDown.forEach(function(r){return r.addEventListener(t,e.onNavigateDownClicked,!1)}),e.controlsPrev.forEach(function(r){return r.addEventListener(t,e.onNavigatePrevClicked,!1)}),e.controlsNext.forEach(function(r){return r.addEventListener(t,e.onNavigateNextClicked,!1)})})}},{key:"unbind",value:function(){var e=this;["touchstart","click"].forEach(function(t){e.controlsLeft.forEach(function(r){return r.removeEventListener(t,e.onNavigateLeftClicked,!1)}),e.controlsRight.forEach(function(r){return r.removeEventListener(t,e.onNavigateRightClicked,!1)}),e.controlsUp.forEach(function(r){return r.removeEventListener(t,e.onNavigateUpClicked,!1)}),e.controlsDown.forEach(function(r){return r.removeEventListener(t,e.onNavigateDownClicked,!1)}),e.controlsPrev.forEach(function(r){return r.removeEventListener(t,e.onNavigatePrevClicked,!1)}),e.controlsNext.forEach(function(r){return r.removeEventListener(t,e.onNavigateNextClicked,!1)})})}},{key:"update",value:function(){var e=this.Reveal.availableRoutes();[].concat(c(this.controlsLeft),c(this.controlsRight),c(this.controlsUp),c(this.controlsDown),c(this.controlsPrev),c(this.controlsNext)).forEach(function(e){e.classList.remove("enabled","fragmented"),e.setAttribute("disabled","disabled")}),e.left&&this.controlsLeft.forEach(function(e){e.classList.add("enabled"),e.removeAttribute("disabled")}),e.right&&this.controlsRight.forEach(function(e){e.classList.add("enabled"),e.removeAttribute("disabled")}),e.up&&this.controlsUp.forEach(function(e){e.classList.add("enabled"),e.removeAttribute("disabled")}),e.down&&this.controlsDown.forEach(function(e){e.classList.add("enabled"),e.removeAttribute("disabled")}),(e.left||e.up)&&this.controlsPrev.forEach(function(e){e.classList.add("enabled"),e.removeAttribute("disabled")}),(e.right||e.down)&&this.controlsNext.forEach(function(e){e.classList.add("enabled"),e.removeAttribute("disabled")});var t=this.Reveal.getCurrentSlide();if(t){var r=this.Reveal.fragments.availableRoutes();r.prev&&this.controlsPrev.forEach(function(e){e.classList.add("fragmented","enabled"),e.removeAttribute("disabled")}),r.next&&this.controlsNext.forEach(function(e){e.classList.add("fragmented","enabled"),e.removeAttribute("disabled")}),this.Reveal.isVerticalSlide(t)?(r.prev&&this.controlsUp.forEach(function(e){e.classList.add("fragmented","enabled"),e.removeAttribute("disabled")}),r.next&&this.controlsDown.forEach(function(e){e.classList.add("fragmented","enabled"),e.removeAttribute("disabled")})):(r.prev&&this.controlsLeft.forEach(function(e){e.classList.add("fragmented","enabled"),e.removeAttribute("disabled")}),r.next&&this.controlsRight.forEach(function(e){e.classList.add("fragmented","enabled"),e.removeAttribute("disabled")}))}if(this.Reveal.getConfig().controlsTutorial){var n=this.Reveal.getIndices();!this.Reveal.hasNavigatedVertically()&&e.down?this.controlsDownArrow.classList.add("highlight"):(this.controlsDownArrow.classList.remove("highlight"),this.Reveal.getConfig().rtl?!this.Reveal.hasNavigatedHorizontally()&&e.left&&0===n.v?this.controlsLeftArrow.classList.add("highlight"):this.controlsLeftArrow.classList.remove("highlight"):!this.Reveal.hasNavigatedHorizontally()&&e.right&&0===n.v?this.controlsRightArrow.classList.add("highlight"):this.controlsRightArrow.classList.remove("highlight"))}}},{key:"destroy",value:function(){this.unbind(),this.element.remove()}},{key:"onNavigateLeftClicked",value:function(e){e.preventDefault(),this.Reveal.onUserInput(),"linear"===this.Reveal.getConfig().navigationMode?this.Reveal.prev():this.Reveal.left()}},{key:"onNavigateRightClicked",value:function(e){e.preventDefault(),this.Reveal.onUserInput(),"linear"===this.Reveal.getConfig().navigationMode?this.Reveal.next():this.Reveal.right()}},{key:"onNavigateUpClicked",value:function(e){e.preventDefault(),this.Reveal.onUserInput(),this.Reveal.up()}},{key:"onNavigateDownClicked",value:function(e){e.preventDefault(),this.Reveal.onUserInput(),this.Reveal.down()}},{key:"onNavigatePrevClicked",value:function(e){e.preventDefault(),this.Reveal.onUserInput(),this.Reveal.prev()}},{key:"onNavigateNextClicked",value:function(e){e.preventDefault(),this.Reveal.onUserInput(),this.Reveal.next()}}]),e}(),ik=function(){function e(t){a(this,e),this.Reveal=t,this.onProgressClicked=this.onProgressClicked.bind(this)}return s(e,[{key:"render",value:function(){this.element=document.createElement("div"),this.element.className="progress",this.Reveal.getRevealElement().appendChild(this.element),this.bar=document.createElement("span"),this.element.appendChild(this.bar)}},{key:"configure",value:function(e){this.element.style.display=e.progress?"block":"none"}},{key:"bind",value:function(){this.Reveal.getConfig().progress&&this.element&&this.element.addEventListener("click",this.onProgressClicked,!1)}},{key:"unbind",value:function(){this.Reveal.getConfig().progress&&this.element&&this.element.removeEventListener("click",this.onProgressClicked,!1)}},{key:"update",value:function(){if(this.Reveal.getConfig().progress&&this.bar){var e=this.Reveal.getProgress();this.Reveal.getTotalSlides()<2&&(e=0),this.bar.style.transform="scaleX("+e+")"}}},{key:"getMaxWidth",value:function(){return this.Reveal.getRevealElement().offsetWidth}},{key:"onProgressClicked",value:function(e){this.Reveal.onUserInput(e),e.preventDefault();var t=this.Reveal.getSlides(),r=t.length,n=Math.floor(e.clientX/this.getMaxWidth()*r);this.Reveal.getConfig().rtl&&(n=r-n);var i=this.Reveal.getIndices(t[n]);this.Reveal.slide(i.h,i.v)}},{key:"destroy",value:function(){this.element.remove()}}]),e}(),ak=function(){function e(t){a(this,e),this.Reveal=t,this.lastMouseWheelStep=0,this.cursorHidden=!1,this.cursorInactiveTimeout=0,this.onDocumentCursorActive=this.onDocumentCursorActive.bind(this),this.onDocumentMouseScroll=this.onDocumentMouseScroll.bind(this)}return s(e,[{key:"configure",value:function(e){e.mouseWheel?document.addEventListener("wheel",this.onDocumentMouseScroll,!1):document.removeEventListener("wheel",this.onDocumentMouseScroll,!1),e.hideInactiveCursor?(document.addEventListener("mousemove",this.onDocumentCursorActive,!1),document.addEventListener("mousedown",this.onDocumentCursorActive,!1)):(this.showCursor(),document.removeEventListener("mousemove",this.onDocumentCursorActive,!1),document.removeEventListener("mousedown",this.onDocumentCursorActive,!1))}},{key:"showCursor",value:function(){this.cursorHidden&&(this.cursorHidden=!1,this.Reveal.getRevealElement().style.cursor="")}},{key:"hideCursor",value:function(){!1===this.cursorHidden&&(this.cursorHidden=!0,this.Reveal.getRevealElement().style.cursor="none")}},{key:"destroy",value:function(){this.showCursor(),document.removeEventListener("wheel",this.onDocumentMouseScroll,!1),document.removeEventListener("mousemove",this.onDocumentCursorActive,!1),document.removeEventListener("mousedown",this.onDocumentCursorActive,!1)}},{key:"onDocumentCursorActive",value:function(){this.showCursor(),clearTimeout(this.cursorInactiveTimeout),this.cursorInactiveTimeout=setTimeout(this.hideCursor.bind(this),this.Reveal.getConfig().hideCursorTime)}},{key:"onDocumentMouseScroll",value:function(e){if(Date.now()-this.lastMouseWheelStep>1e3){this.lastMouseWheelStep=Date.now();var t=e.detail||-e.wheelDelta;t>0?this.Reveal.next():t<0&&this.Reveal.prev()}}}]),e}(),ok=y,sk=v,lk=B,ck=Vd,uk=gi,hk=K,dk=lk(S.f),pk=lk([].push),mk=ok&&sk(function(){var e=Object.create(null);return e[2]=2,!dk(e,2)}),fk=function(e){return function(t){for(var r,n=hk(t),i=uk(n),a=mk&&null===ck(n),o=i.length,s=0,l=[];o>s;)r=i[s++],ok&&!(a?r in n:dk(n,r))||pk(l,e?[r,n[r]]:n[r]);return l}},gk=(fk(!0),fk(!1));pi({target:"Object",stat:!0},{values:function(e){return gk(e)}});var vk=function(e,t){var r=document.createElement("script");r.type="text/javascript",r.async=!1,r.defer=!1,r.src=e,"function"==typeof t&&(r.onload=r.onreadystatechange=function(e){("load"===e.type||/loaded|complete/.test(r.readyState))&&(r.onload=r.onreadystatechange=r.onerror=null,t())},r.onerror=function(e){r.onload=r.onreadystatechange=r.onerror=null,t(new Error("Failed loading script: "+r.src+"\n"+e))});var n=document.querySelector("head");n.insertBefore(r,n.lastChild)},yk=function(){function e(t){a(this,e),this.Reveal=t,this.state="idle",this.registeredPlugins={},this.asyncDependencies=[]}return s(e,[{key:"load",value:function(e,t){var r=this;return this.state="loading",e.forEach(this.registerPlugin.bind(this)),new Promise(function(e){var n=[],i=0;if(t.forEach(function(e){e.condition&&!e.condition()||(e.async?r.asyncDependencies.push(e):n.push(e))}),n.length){i=n.length;var a=function(t){t&&"function"==typeof t.callback&&t.callback(),0==--i&&r.initPlugins().then(e)};n.forEach(function(e){"string"==typeof e.id?(r.registerPlugin(e),a(e)):"string"==typeof e.src?vk(e.src,function(){return a(e)}):(console.warn("Unrecognized plugin format",e),a())})}else r.initPlugins().then(e)})}},{key:"initPlugins",value:function(){var e=this;return new Promise(function(t){var r=Object.values(e.registeredPlugins),n=r.length;if(0===n)e.loadAsync().then(t);else{var i,a=function(){0==--n?e.loadAsync().then(t):i()},o=0;(i=function(){var t=r[o++];if("function"==typeof t.init){var n=t.init(e.Reveal);n&&"function"==typeof n.then?n.then(a):a()}else a()})()}})}},{key:"loadAsync",value:function(){return this.state="loaded",this.asyncDependencies.length&&this.asyncDependencies.forEach(function(e){vk(e.src,e.callback)}),Promise.resolve()}},{key:"registerPlugin",value:function(e){2===arguments.length&&"string"==typeof arguments[0]?(e=arguments[1]).id=arguments[0]:"function"==typeof e&&(e=e());var t=e.id;"string"!=typeof t?console.warn("Unrecognized plugin format; can't find plugin.id",e):void 0===this.registeredPlugins[t]?(this.registeredPlugins[t]=e,"loaded"===this.state&&"function"==typeof e.init&&e.init(this.Reveal)):console.warn('reveal.js: "'+t+'" plugin has already been registered')}},{key:"hasPlugin",value:function(e){return!!this.registeredPlugins[e]}},{key:"getPlugin",value:function(e){return this.registeredPlugins[e]}},{key:"getRegisteredPlugins",value:function(){return this.registeredPlugins}},{key:"destroy",value:function(){Object.values(this.registeredPlugins).forEach(function(e){"function"==typeof e.destroy&&e.destroy()}),this.registeredPlugins={},this.asyncDependencies=[]}}]),e}(),bk=function(){function e(t){a(this,e),this.Reveal=t,this.touchStartX=0,this.touchStartY=0,this.touchStartCount=0,this.touchCaptured=!1,this.onPointerDown=this.onPointerDown.bind(this),this.onPointerMove=this.onPointerMove.bind(this),this.onPointerUp=this.onPointerUp.bind(this),this.onTouchStart=this.onTouchStart.bind(this),this.onTouchMove=this.onTouchMove.bind(this),this.onTouchEnd=this.onTouchEnd.bind(this)}return s(e,[{key:"bind",value:function(){var e=this.Reveal.getRevealElement();"onpointerdown"in window?(e.addEventListener("pointerdown",this.onPointerDown,!1),e.addEventListener("pointermove",this.onPointerMove,!1),e.addEventListener("pointerup",this.onPointerUp,!1)):window.navigator.msPointerEnabled?(e.addEventListener("MSPointerDown",this.onPointerDown,!1),e.addEventListener("MSPointerMove",this.onPointerMove,!1),e.addEventListener("MSPointerUp",this.onPointerUp,!1)):(e.addEventListener("touchstart",this.onTouchStart,!1),e.addEventListener("touchmove",this.onTouchMove,!1),e.addEventListener("touchend",this.onTouchEnd,!1))}},{key:"unbind",value:function(){var e=this.Reveal.getRevealElement();e.removeEventListener("pointerdown",this.onPointerDown,!1),e.removeEventListener("pointermove",this.onPointerMove,!1),e.removeEventListener("pointerup",this.onPointerUp,!1),e.removeEventListener("MSPointerDown",this.onPointerDown,!1),e.removeEventListener("MSPointerMove",this.onPointerMove,!1),e.removeEventListener("MSPointerUp",this.onPointerUp,!1),e.removeEventListener("touchstart",this.onTouchStart,!1),e.removeEventListener("touchmove",this.onTouchMove,!1),e.removeEventListener("touchend",this.onTouchEnd,!1)}},{key:"isSwipePrevented",value:function(e){if(bb(e,"video, audio"))return!0;for(;e&&"function"==typeof e.hasAttribute;){if(e.hasAttribute("data-prevent-swipe"))return!0;e=e.parentNode}return!1}},{key:"onTouchStart",value:function(e){if(this.isSwipePrevented(e.target))return!0;this.touchStartX=e.touches[0].clientX,this.touchStartY=e.touches[0].clientY,this.touchStartCount=e.touches.length}},{key:"onTouchMove",value:function(e){if(this.isSwipePrevented(e.target))return!0;var t=this.Reveal.getConfig();if(this.touchCaptured)Mb&&e.preventDefault();else{this.Reveal.onUserInput(e);var r=e.touches[0].clientX,n=e.touches[0].clientY;if(1===e.touches.length&&2!==this.touchStartCount){var i=this.Reveal.availableRoutes({includeFragments:!0}),a=r-this.touchStartX,o=n-this.touchStartY;a>40&&Math.abs(a)>Math.abs(o)?(this.touchCaptured=!0,"linear"===t.navigationMode?t.rtl?this.Reveal.next():this.Reveal.prev():this.Reveal.left()):a<-40&&Math.abs(a)>Math.abs(o)?(this.touchCaptured=!0,"linear"===t.navigationMode?t.rtl?this.Reveal.prev():this.Reveal.next():this.Reveal.right()):o>40&&i.up?(this.touchCaptured=!0,"linear"===t.navigationMode?this.Reveal.prev():this.Reveal.up()):o<-40&&i.down&&(this.touchCaptured=!0,"linear"===t.navigationMode?this.Reveal.next():this.Reveal.down()),t.embedded?(this.touchCaptured||this.Reveal.isVerticalSlide())&&e.preventDefault():e.preventDefault()}}}},{key:"onTouchEnd",value:function(){this.touchCaptured=!1}},{key:"onPointerDown",value:function(e){e.pointerType!==e.MSPOINTER_TYPE_TOUCH&&"touch"!==e.pointerType||(e.touches=[{clientX:e.clientX,clientY:e.clientY}],this.onTouchStart(e))}},{key:"onPointerMove",value:function(e){e.pointerType!==e.MSPOINTER_TYPE_TOUCH&&"touch"!==e.pointerType||(e.touches=[{clientX:e.clientX,clientY:e.clientY}],this.onTouchMove(e))}},{key:"onPointerUp",value:function(e){e.pointerType!==e.MSPOINTER_TYPE_TOUCH&&"touch"!==e.pointerType||(e.touches=[{clientX:e.clientX,clientY:e.clientY}],this.onTouchEnd(e))}}]),e}(),wk="focus",xk="blur",kk=function(){function e(t){a(this,e),this.Reveal=t,this.onRevealPointerDown=this.onRevealPointerDown.bind(this),this.onDocumentPointerDown=this.onDocumentPointerDown.bind(this)}return s(e,[{key:"configure",value:function(e){e.embedded?this.blur():(this.focus(),this.unbind())}},{key:"bind",value:function(){this.Reveal.getConfig().embedded&&this.Reveal.getRevealElement().addEventListener("pointerdown",this.onRevealPointerDown,!1)}},{key:"unbind",value:function(){this.Reveal.getRevealElement().removeEventListener("pointerdown",this.onRevealPointerDown,!1),document.removeEventListener("pointerdown",this.onDocumentPointerDown,!1)}},{key:"focus",value:function(){this.state!==wk&&(this.Reveal.getRevealElement().classList.add("focused"),document.addEventListener("pointerdown",this.onDocumentPointerDown,!1)),this.state=wk}},{key:"blur",value:function(){this.state!==xk&&(this.Reveal.getRevealElement().classList.remove("focused"),document.removeEventListener("pointerdown",this.onDocumentPointerDown,!1)),this.state=xk}},{key:"isFocused",value:function(){return this.state===wk}},{key:"destroy",value:function(){this.Reveal.getRevealElement().classList.remove("focused")}},{key:"onRevealPointerDown",value:function(){this.focus()}},{key:"onDocumentPointerDown",value:function(e){var t=wb(e.target,".reveal");t&&t===this.Reveal.getRevealElement()||this.blur()}}]),e}(),Sk=function(){function e(t){a(this,e),this.Reveal=t}return s(e,[{key:"render",value:function(){this.element=document.createElement("div"),this.element.className="speaker-notes",this.element.setAttribute("data-prevent-swipe",""),this.element.setAttribute("tabindex","0"),this.Reveal.getRevealElement().appendChild(this.element)}},{key:"configure",value:function(e){e.showNotes&&this.element.setAttribute("data-layout","string"==typeof e.showNotes?e.showNotes:"inline")}},{key:"update",value:function(){this.Reveal.getConfig().showNotes&&this.element&&this.Reveal.getCurrentSlide()&&!this.Reveal.isScrollView()&&!this.Reveal.isPrintView()&&(this.element.innerHTML=this.getSlideNotes()||'No notes on this slide.')}},{key:"updateVisibility",value:function(){this.Reveal.getConfig().showNotes&&this.hasNotes()&&!this.Reveal.isScrollView()&&!this.Reveal.isPrintView()?this.Reveal.getRevealElement().classList.add("show-notes"):this.Reveal.getRevealElement().classList.remove("show-notes")}},{key:"hasNotes",value:function(){return this.Reveal.getSlidesElement().querySelectorAll("[data-notes], aside.notes").length>0}},{key:"isSpeakerNotesWindow",value:function(){return!!window.location.search.match(/receiver/gi)}},{key:"getSlideNotes",value:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:this.Reveal.getCurrentSlide();if(e.hasAttribute("data-notes"))return e.getAttribute("data-notes");var t=e.querySelectorAll("aside.notes");return t?Array.from(t).map(function(e){return e.innerHTML}).join("\n"):null}},{key:"destroy",value:function(){this.element.remove()}}]),e}(),Ak=Ke,Ek=bn,Mk=An,Tk=Rw;pi({target:"Array",proto:!0},{fill:function(e){for(var t=Ak(this),r=Mk(t),n=arguments.length,i=Ek(n>1?arguments[1]:void 0,r),a=n>2?arguments[2]:void 0,o=void 0===a?r:Ek(a,r);o>i;)t[i++]=e;return t}}),Tk("fill");var Rk=function(){function e(t,r){a(this,e),this.diameter=100,this.diameter2=this.diameter/2,this.thickness=6,this.playing=!1,this.progress=0,this.progressOffset=1,this.container=t,this.progressCheck=r,this.canvas=document.createElement("canvas"),this.canvas.className="playback",this.canvas.width=this.diameter,this.canvas.height=this.diameter,this.canvas.style.width=this.diameter2+"px",this.canvas.style.height=this.diameter2+"px",this.context=this.canvas.getContext("2d"),this.container.appendChild(this.canvas),this.render()}return s(e,[{key:"setPlaying",value:function(e){var t=this.playing;this.playing=e,!t&&this.playing?this.animate():this.render()}},{key:"animate",value:function(){var e=this.progress;this.progress=this.progressCheck(),e>.8&&this.progress<.2&&(this.progressOffset=this.progress),this.render(),this.playing&&requestAnimationFrame(this.animate.bind(this))}},{key:"render",value:function(){var e=this.playing?this.progress:0,t=this.diameter2-this.thickness,r=this.diameter2,n=this.diameter2,i=28;this.progressOffset+=.1*(1-this.progressOffset);var a=-Math.PI/2+e*(2*Math.PI),o=-Math.PI/2+this.progressOffset*(2*Math.PI);this.context.save(),this.context.clearRect(0,0,this.diameter,this.diameter),this.context.beginPath(),this.context.arc(r,n,t+4,0,2*Math.PI,!1),this.context.fillStyle="rgba( 0, 0, 0, 0.4 )",this.context.fill(),this.context.beginPath(),this.context.arc(r,n,t,0,2*Math.PI,!1),this.context.lineWidth=this.thickness,this.context.strokeStyle="rgba( 255, 255, 255, 0.2 )",this.context.stroke(),this.playing&&(this.context.beginPath(),this.context.arc(r,n,t,o,a,!1),this.context.lineWidth=this.thickness,this.context.strokeStyle="#fff",this.context.stroke()),this.context.translate(r-14,n-14),this.playing?(this.context.fillStyle="#fff",this.context.fillRect(0,0,10,i),this.context.fillRect(18,0,10,i)):(this.context.beginPath(),this.context.translate(4,0),this.context.moveTo(0,0),this.context.lineTo(24,14),this.context.lineTo(0,i),this.context.fillStyle="#fff",this.context.fill()),this.context.restore()}},{key:"on",value:function(e,t){this.canvas.addEventListener(e,t,!1)}},{key:"off",value:function(e,t){this.canvas.removeEventListener(e,t,!1)}},{key:"destroy",value:function(){this.playing=!1,this.canvas.parentNode&&this.container.removeChild(this.canvas)}}]),e}(),Ck={width:960,height:700,margin:.04,minScale:.2,maxScale:2,controls:!0,controlsTutorial:!0,controlsLayout:"bottom-right",controlsBackArrows:"faded",progress:!0,slideNumber:!1,showSlideNumber:"all",hashOneBasedIndex:!1,hash:!1,respondToHashChanges:!0,jumpToSlide:!0,history:!1,keyboard:!0,keyboardCondition:null,disableLayout:!1,overview:!0,center:!0,touch:!0,loop:!1,rtl:!1,navigationMode:"default",shuffle:!1,fragments:!0,fragmentInURL:!0,embedded:!1,help:!0,pause:!0,showNotes:!1,showHiddenSlides:!1,autoPlayMedia:null,preloadIframes:null,autoAnimate:!0,autoAnimateMatcher:null,autoAnimateEasing:"ease",autoAnimateDuration:1,autoAnimateUnmatched:!0,autoAnimateStyles:["opacity","color","background-color","padding","font-size","line-height","letter-spacing","border-width","border-color","border-radius","outline","outline-offset"],autoSlide:0,autoSlideStoppable:!0,autoSlideMethod:null,defaultTiming:null,mouseWheel:!1,previewLinks:!1,postMessage:!0,postMessageEvents:!1,focusBodyOnPageVisibilityChange:!0,transition:"slide",transitionSpeed:"default",backgroundTransition:"fade",parallaxBackgroundImage:"",parallaxBackgroundSize:"",parallaxBackgroundRepeat:"",parallaxBackgroundPosition:"",parallaxBackgroundHorizontal:null,parallaxBackgroundVertical:null,view:null,scrollLayout:"full",scrollSnap:"mandatory",scrollProgress:"auto",scrollActivationWidth:435,pdfMaxPagesPerSlide:Number.POSITIVE_INFINITY,pdfSeparateFragments:!0,pdfPageHeightOffset:-1,viewDistance:3,mobileViewDistance:2,display:"block",hideInactiveCursor:!0,hideCursorTime:5e3,sortFragmentsOnSync:!0,dependencies:[],plugins:[]},Lk="5.0.1",zk=d,Nk=[];return zk.initialize=function(e){return Object.assign(zk,new d(document.querySelector(".reveal"),e)),Nk.map(function(e){return e(zk)}),zk.initialize()},["configure","on","off","addEventListener","removeEventListener","registerPlugin"].forEach(function(e){zk[e]=function(){for(var t=arguments.length,r=new Array(t),n=0;ne.from);return SL.deck.AutoAnimate.findMatchingElements(r,e,t,'.sl-block[data-block-type="text"], .sl-block[data-block-type="snippet"], .sl-block[data-block-type="table"]',function(e){return e.getAttribute("data-block-type")+":::"+e.innerText},null,n),SL.deck.AutoAnimate.findMatchingElements(r,e,t,'.sl-block[data-block-type="image"], .sl-block[data-block-type="video"]',function(e){var t=e.querySelector("img[src], video[src]");return t?t.getAttribute("src"):null},null,n),SL.deck.AutoAnimate.findMatchingElements(r,e,t,'.sl-block[data-block-type="iframe"]',function(e){var t=e.querySelector("iframe[src], iframe[data-src]");return t?t.getAttribute("src")||t.getAttribute("data-src"):null},null,n),SL.deck.AutoAnimate.findMatchingElements(r,e,t,'.sl-block[data-block-type="code"]',function(e){return e.querySelector(":not(.editing-ui) pre code").textContent},null,n),SL.deck.AutoAnimate.findMatchingElements(r,e,t,'.sl-block[data-block-type="math"]',function(e){return e.querySelector(".math-input").textContent},null,n),SL.deck.AutoAnimate.expandBlockPairs(r)},findMatchingElements:function(e,t,r,n,i,a,o){var s={},l={};[].slice.call(t.querySelectorAll(n)).forEach(function(e){var t=i(e);"string"==typeof t&&t.length&&(s[t]=s[t]||[],s[t].push(e))}),[].slice.call(r.querySelectorAll(n)).forEach(function(t){var r,n=i(t);if(l[n]=l[n]||[],l[n].push(t),s[n]){var c=l[n].length-1,u=s[n].length-1;s[n][c]?(r=s[n][c],s[n][c]=null):s[n][u]&&(r=s[n][u],s[n][u]=null)}!r||o&&-1!==o.indexOf(r)||e.push({from:r,to:t,options:a||{styles:[]}})})},expandBlockPairs:function(e){return e.forEach(function(t){var r=t.from,n=t.to,i=r.querySelector(".sl-block-content"),a=n.querySelector(".sl-block-content");i&&a&&SL.deck.AutoAnimate.expandBlockPair(e,t,r,n,i,a)}),e},expandBlockPair:function(e,t,r,n,i,a){var o=r.querySelector(".sl-block-style"),s=n.querySelector(".sl-block-style");o&&o.closest(".sl-block")!==r&&(o=null),s&&s.closest(".sl-block")!==n&&(s=null);var l=r.getAttribute("data-block-type"),c={},u={};return n.dataset.autoAnimateDelay&&(t.options.delay=parseFloat(n.dataset.autoAnimateDelay)),n.dataset.autoAnimateDuration&&(t.options.duration=parseFloat(n.dataset.autoAnimateDuration)),n.dataset.autoAnimateEasing&&(t.options.easing=n.dataset.autoAnimateEasing),c["z-index"]={property:"z-index",from:a.style.zIndex,to:a.style.zIndex},/text|snippet|table/i.test(l)?c.width={property:"width"}:/code|math/i.test(l)&&(c.width={property:"width"},c.height={property:"height"}),o&&s?(c.opacity={property:"opacity",from:o.style.opacity||"1",to:s.style.opacity||"1"},(o.style.transform||s.style.transform)&&(c.width={property:"width"},c.height={property:"height"},e.push({from:o,to:s,options:{translate:!1,scale:!1,styles:[{property:"transform"}]}}))):o?(c.opacity={property:"opacity",from:o.style.opacity||"1",to:"1"},o.style.transform&&(c.width={property:"width"},c.height={property:"height"},u.transform={property:"transform",from:o.style.transform,to:"none"})):s&&(c.opacity={property:"opacity",from:"1",to:s.style.opacity||"1"},s.style.transform&&(c.width={property:"width"},c.height={property:"height"},e.push({from:document.createElement("div"),to:s,options:{translate:!1,scale:!1,styles:[{property:"transform",from:"none"}]}}))),e.push({from:i,to:a,options:{translate:!1,scale:!1,styles:SL.deck.AutoAnimate.ANIMATABLE_BLOCK_CONTENT_STYLES.concat(Object.keys(u).map(function(e){return u[e]}))}}),/text/i.test(l)&&this.expandTextBlock(e,t,r,n),/code/i.test(l)&&this.expandCodeBlock(e,t,r,n),/shape/i.test(l)&&this.expandShapeBlock(e,t,r,n),/line/i.test(l)&&this.expandLineBlock(e,t,r,n),t.options.styles=t.options.styles.concat(Object.keys(c).map(function(e){return c[e]})),(c.width||c.height)&&(t.options.scale=!1),e},expandTextBlock:function(e,t,r,n){SL.deck.AutoAnimate.findMatchingElements(e,r,n,"li>p",function(e){return e.innerText.trim()},{scale:!1,translate:!1,measure:SL.deck.AutoAnimate.getLocalBlockMeasurements}),SL.deck.AutoAnimate.findMatchingElements(e,r,n,"ul li, ol li",function(e){return Array.prototype.map.call(e.childNodes,function(e){return/li|ul|ol/i.test(e.nodeName)?"":e.textContent.trim()}).join("")},{scale:!1,measure:SL.deck.AutoAnimate.getLocalBlockMeasurements}),SL.deck.AutoAnimate.findMatchingElements(e,r,n,'span[style*="font-size"]',function(e){return e.textContent.trim()},{scale:!1,translate:!1,styles:[{property:"font-size"}]})},expandCodeBlock:function(e,t,r,n){var i=n.querySelector("code.current-fragment");i&&(n=i),SL.deck.AutoAnimate.findMatchingElements(e,r,n,".hljs-ln-code",function(e){return e.textContent},{scale:!1,styles:[],measure:SL.deck.AutoAnimate.getLocalBlockMeasurements}),SL.deck.AutoAnimate.findMatchingElements(e,r,n,".hljs-ln-line[data-line-number]",function(e){return e.getAttribute("data-line-number")},{scale:!1,styles:["width"],measure:SL.deck.AutoAnimate.getLocalBlockMeasurements})},expandShapeBlock:function(e,t,r,n){var i=r.querySelector(".shape-element"),a=n.querySelector(".shape-element"),o=[{property:"fill"},{property:"stroke"}];/rect/i.test(a.nodeName)&&o.push({property:"rx"},{property:"ry"}),i&&a&&e.push({from:i,to:a,options:{translate:!1,scale:!1,styles:o}})},expandLineBlock:function(e,t,r,n){var i=r.querySelector(".line-element"),a=n.querySelector(".line-element");i&&a&&e.push({from:i,to:a,options:{translate:!1,scale:!1,styles:[{property:"stroke"},{property:"stroke-width"}]}})},getLocalBlockMeasurements:function(e){var t=Reveal.getScale();return{x:Math.round(e.offsetLeft*t*100)/100,y:Math.round(e.offsetTop*t*100)/100,width:Math.round(e.offsetWidth*t*100)/100,height:Math.round(e.offsetHeight*t*100)/100}},onAutoAnimate:function(e){let t=[];Array.prototype.forEach.call(e.toSlide.querySelectorAll('.sl-block[data-auto-animate-target^="unmatched"]'),function(e){const r=e.querySelector(".sl-block-content");if(r){const n=r.style.zIndex,i=e.getAttribute("data-block-id"),a=e.getAttribute("data-auto-animate-target");t.push(`.reveal [data-auto-animate-target="${a}"][data-block-id="${i}"] { z-index: ${n}; }`)}}),t.length&&(e.sheet.innerHTML+=t.join(""))}},SL("deck").Controller={MODE_VIEWING:"viewing",MODE_EDITING:"editing",MODE_PRINTING:"printing",init:function(e){this.options=e||{},this.options.mode="string"==typeof this.options.mode?this.options.mode:SL.deck.Controller.MODE_VIEWING,this.mode=null,SL.deck.Media.init(this.options),this.options.mode===SL.deck.Controller.MODE_VIEWING&&SL.deck.util.formatIframes(),Reveal.isReady()?this.setup():Reveal.addEventListener("ready",this.setup.bind(this))},setup:function(){SL.deck.Animation.init(),SL.deck.AutoAnimate.init(),this.setMode(this.options.mode)},setMode:function(e){this.mode=e,this.mode===SL.deck.Controller.MODE_EDITING||this.mode===SL.deck.Controller.MODE_PRINTING?SL.deck.Animation.disableAnimations():SL.deck.Animation.enableAnimations()}},SL("deck").Media={init:function(e){this.options=e,this.supportsCDN()&&(this.switchToCDN(".reveal img[src], .reveal video[src]","src"),this.switchToCDN(".reveal img[data-src], .reveal video[data-src]","data-src"),this.switchToCDN(".reveal video[poster]","poster"),this.switchToCDN(".reveal [data-background-video]","data-background-video"),this.switchToCDN(".reveal [data-background-image]","data-background-image"))},supportsCDN:function(){return SL.config&&this.options.mode===SL.deck.Controller.MODE_VIEWING&&!document.documentElement.classList.contains("sl-editor")},switchToCDN:function(e,t){document.querySelectorAll(e).forEach(function(e){var r=e.getAttribute(t);0===r.lastIndexOf(SL.config.S3_HOST,0)&&e.setAttribute(t,r.replace(SL.config.S3_HOST,SL.config.CDN_HOST))},this)}},SL("deck").util={extend:function(e){return Array.prototype.forEach.call(arguments,function(t){for(var r in t)e[r]=t[r]},e),e},renderMath:function(e){SL.deck.util.renderMathBlocks(e),SL.deck.util.renderInlineMath(e)},renderMathBlocks:function(e){e||(e=document.querySelector(".reveal .slides")),window.katex&&"function"==typeof window.katex.render&&[].slice.call(e.querySelectorAll('.sl-block[data-block-type="math"]')).forEach(function(e){var t=e.querySelector(".math-input"),r=e.querySelector(".math-output");if(t&&!r&&((r=document.createElement("div")).className="math-output",t.parentNode.insertBefore(r,t)),t&&r)try{katex.render(t.innerText,r,{displayMode:null!==e.querySelector("[data-math-display=true]"),throwOnError:!0})}catch(n){console.warn(n.message)}})},renderInlineMath:function(e){e||(e=document.querySelector(".reveal .slides")),"function"==typeof window.renderMathInElement&&SL.deck.util.containsInlineMath(e)&&renderMathInElement(e,{delimiters:[{left:"$$",right:"$$",display:!0},{left:"\\[",right:"\\]",display:!0},{left:"\\(",right:"\\)",display:!1}]})},containsInlineMath:function(e){return!!e&&/\$\$.+\$\$|\\\[.+\\\]|\\\(.+\\\)/g.test(e.innerHTML)},injectCodeCopyButtons:function(){var e=[].slice.call(document.querySelectorAll('.sl-block[data-block-type="code"] .sl-block-content:not(.has-copy-button)'));e.length&&(this.copyButton=document.createElement("button"),this.copyButton.className="copy-code-to-clipboard",this.copyButton.textContent="Copy",this.copyButton.addEventListener("click",function(){this.copyButton.hasAttribute("data-code-to-copy")&&(this.copyButton.textContent="Copied!",this.copyButton.classList.add("bounce"),SL.deck.util.copyToClipboard(this.copyButton.getAttribute("data-code-to-copy")),setTimeout(function(){this.copyButton.textContent="Copy",this.copyButton.classList.remove("bounce")}.bind(this),1500))}.bind(this)),e.forEach(function(e){var t,r=e.querySelector("pre code");r&&(t=r.hasAttribute("data-plaintext")?r.getAttribute("data-plaintext"):r.textContent),t&&e.addEventListener("mouseenter",function(e){this.copyButton.setAttribute("data-code-to-copy",t),e.currentTarget.classList.add("has-copy-button"),e.currentTarget.appendChild(this.copyButton)}.bind(this))},this))},hasNotes:function(){if(SLConfig.deck&&SLConfig.deck.notes)for(var e in SLConfig.deck.notes)return!0;return document.querySelectorAll(".reveal .slides section[data-notes]").length>0},injectNotes:function(){SLConfig.deck&&SLConfig.deck.notes&&[].forEach.call(document.querySelectorAll(".reveal .slides section"),function(e){var t=SLConfig.deck.notes[e.getAttribute("data-id")];t&&"string"==typeof t&&e.setAttribute("data-notes",t)})},injectTranslationRules:function(){[].slice.call(document.querySelectorAll(".sl-block .katex")).forEach(function(e){e.classList.add("notranslate")})},formatIframes:function(){[].slice.call(document.querySelectorAll(".sl-block iframe[data-src]")).forEach(this.formatIframe.bind(this))},formatIframe:function(e){e.setAttribute("allowfullscreen",""),e.setAttribute("allow","fullscreen; accelerometer; geolocation; gyroscope; camera; encrypted-media; microphone; midi");var t=e.getAttribute("src")||e.getAttribute("data-src");"string"!=typeof t||/\.pdf$/i.test(t)?e.removeAttribute("sandbox"):e.setAttribute("sandbox","allow-forms allow-scripts allow-popups allow-same-origin allow-pointer-lock allow-presentation")},copyToClipboard:function(e){var t=document.createElement("textarea");t.value=e,document.body.appendChild(t),t.select();var r=document.execCommand("copy");return document.body.removeChild(t),r}}; + + + !function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e=e||self).RevealHighlight=t()}(this,function(){"use strict";function e(e,t,a){return e(a={path:t,exports:{},require:function(e,t){return function(){throw new Error("Dynamic requires are not currently supported by @rollup/plugin-commonjs")}(null==t&&a.path)}},a.exports),a.exports}function t(e,t){return RegExp(e,t)}function a(e){return(a="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e})(e)}function n(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}function r(e,t){for(var a=0;ae.length)&&(t=e.length);for(var a=0,n=new Array(t);a/g,">")}function g(e){var t,a={},n=Array.prototype.slice.call(arguments,1);for(t in e)a[t]=e[t];return n.forEach(function(e){for(t in e)a[t]=e[t]}),a}function S(e){return e.nodeName.toLowerCase()}function T(e){return e&&e.source||e}function b(e){function t(t,a){return new RegExp(T(t),"m"+(e.case_insensitive?"i":"")+(a?"g":""))}function a(e){var t=e.input[e.index-1],a=e.input[e.index+e[0].length];if("."===t||"."===a)return{ignoreMatch:!0}}var r=function(){function e(){n(this,e),this.matchIndexes={},this.regexes=[],this.matchAt=1,this.position=0}return i(e,[{key:"addRule",value:function(e,t){t.position=this.position++,this.matchIndexes[this.matchAt]=t,this.regexes.push([t,e]),this.matchAt+=function(e){return new RegExp(e.toString()+"|").exec("").length-1}(e)+1}},{key:"compile",value:function(){0===this.regexes.length&&(this.exec=function(){return null});var e=this.regexes.map(function(e){return e[1]});this.matcherRe=t(function(e,t){for(var a=/\[(?:[^\\\]]|\\.)*\]|\(\??|\\([1-9][0-9]*)|\\./,n=0,r="",i=0;i0&&(r+=t),r+="(";s.length>0;){var l=a.exec(s);if(null==l){r+=s;break}r+=s.substring(0,l.index),s=s.substring(l.index+l[0].length),"\\"==l[0][0]&&l[1]?r+="\\"+String(Number(l[1])+o):(r+=l[0],"("==l[0]&&n++)}r+=")"}return r}(e,"|"),!0),this.lastIndex=0}},{key:"exec",value:function(e){this.matcherRe.lastIndex=this.lastIndex;var t=this.matcherRe.exec(e);if(!t)return null;var a=t.findIndex(function(e,t){return t>0&&null!=e}),n=this.matchIndexes[a];return Object.assign(t,n)}}]),e}(),o=function(){function e(){n(this,e),this.rules=[],this.multiRegexes=[],this.count=0,this.lastIndex=0,this.regexIndex=0}return i(e,[{key:"getMatcher",value:function(e){if(this.multiRegexes[e])return this.multiRegexes[e];var t=new r;return this.rules.slice(e).forEach(function(e){var a=c(e,2),n=a[0],r=a[1];return t.addRule(n,r)}),t.compile(),this.multiRegexes[e]=t,t}},{key:"considerAll",value:function(){this.regexIndex=0}},{key:"addRule",value:function(e,t){this.rules.push([e,t]),"begin"===t.type&&this.count++}},{key:"exec",value:function(e){var t=this.getMatcher(this.regexIndex);t.lastIndex=this.lastIndex;var a=t.exec(e);return a&&(this.regexIndex+=a.position+1,this.regexIndex===this.count&&(this.regexIndex=0)),a}}]),e}();if(e.contains&&e.contains.includes("self"))throw new Error("ERR: contains `self` is not supported at the top-level of a language. See documentation.");!function s(n,r){var i;n.compiled||(n.compiled=!0,n.__onBegin=null,n.keywords=n.keywords||n.beginKeywords,n.keywords&&(n.keywords=function(e,t){function a(e,a){t&&(a=a.toLowerCase()),a.split(" ").forEach(function(t){var a=t.split("|");n[a[0]]=[e,f(a[0],a[1])]})}var n={};return"string"==typeof e?a("keyword",e):Object.keys(e).forEach(function(t){a(t,e[t])}),n}(n.keywords,e.case_insensitive)),n.lexemesRe=t(n.lexemes||/\w+/,!0),r&&(n.beginKeywords&&(n.begin="\\b("+n.beginKeywords.split(" ").join("|")+")(?=\\b|\\s)",n.__onBegin=a),n.begin||(n.begin=/\B|\b/),n.beginRe=t(n.begin),n.endSameAsBegin&&(n.end=n.begin),n.end||n.endsWithParent||(n.end=/\B|\b/),n.end&&(n.endRe=t(n.end)),n.terminator_end=T(n.end)||"",n.endsWithParent&&r.terminator_end&&(n.terminator_end+=(n.end?"|":"")+r.terminator_end)),n.illegal&&(n.illegalRe=t(n.illegal)),null==n.relevance&&(n.relevance=1),n.contains||(n.contains=[]),n.contains=(i=[]).concat.apply(i,d(n.contains.map(function(e){return function(e){return e.variants&&!e.cached_variants&&(e.cached_variants=e.variants.map(function(t){return g(e,{variants:null},t)})),e.cached_variants?e.cached_variants:function t(e){return!!e&&(e.endsWithParent||t(e.starts))}(e)?g(e,{starts:e.starts?g(e.starts):null}):Object.isFrozen(e)?g(e):e}("self"===e?n:e)}))),n.contains.forEach(function(e){s(e,n)}),n.starts&&s(n.starts,r),n.matcher=function(e){var t=new o;return e.contains.forEach(function(e){return t.addRule(e.begin,{rule:e,type:"begin"})}),e.terminator_end&&t.addRule(e.terminator_end,{type:"end"}),e.illegal&&t.addRule(e.illegal,{type:"illegal"}),t}(n))}(e)}function f(e,t){return t?Number(t):(a=e,Ur.includes(a.toLowerCase())?0:1);var a}var C="undefined"!=typeof globalThis?globalThis:"undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:{},R=function(e){return e&&e.Math==Math&&e},N=R("object"==typeof globalThis&&globalThis)||R("object"==typeof window&&window)||R("object"==typeof self&&self)||R("object"==typeof C&&C)||Function("return this")(),O=function(e){try{return!!e()}catch(e){return!0}},v=!O(function(){return 7!=Object.defineProperty({},1,{get:function(){return 7}})[1]}),I={}.propertyIsEnumerable,h=Object.getOwnPropertyDescriptor,A={f:h&&!I.call({1:2},1)?function(e){var t=h(this,e);return!!t&&t.enumerable}:I},y=function(e,t){return{enumerable:!(1&e),configurable:!(2&e),writable:!(4&e),value:t}},D={}.toString,M=function(e){return D.call(e).slice(8,-1)},L="".split,x=O(function(){return!Object("z").propertyIsEnumerable(0)})?function(e){return"String"==M(e)?L.call(e,""):Object(e)}:Object,w=function(e){if(null==e)throw TypeError("Can't call method on "+e);return e},P=function(e){return x(w(e))},k=function(e){return"object"==typeof e?null!==e:"function"==typeof e},U=function(e,t){if(!k(e))return e;var a,n;if(t&&"function"==typeof(a=e.toString)&&!k(n=a.call(e)))return n;if("function"==typeof(a=e.valueOf)&&!k(n=a.call(e)))return n;if(!t&&"function"==typeof(a=e.toString)&&!k(n=a.call(e)))return n;throw TypeError("Can't convert object to primitive value")},F={}.hasOwnProperty,B=function(e,t){return F.call(e,t)},G=N.document,Y=k(G)&&k(G.createElement),H=function(e){return Y?G.createElement(e):{}},V=!v&&!O(function(){return 7!=Object.defineProperty(H("div"),"a",{get:function(){return 7}}).a}),q=Object.getOwnPropertyDescriptor,z={f:v?q:function(e,t){if(e=P(e),t=U(t,!0),V)try{return q(e,t)}catch(e){}if(B(e,t))return y(!A.f.call(e,t),e[t])}},W=function(e){if(!k(e))throw TypeError(String(e)+" is not an object");return e},Q=Object.defineProperty,$={f:v?Q:function(e,t,a){if(W(e),t=U(t,!0),W(a),V)try{return Q(e,t,a)}catch(e){}if("get"in a||"set"in a)throw TypeError("Accessors not supported");return"value"in a&&(e[t]=a.value),e}},K=v?function(e,t,a){return $.f(e,t,y(1,a))}:function(e,t,a){return e[t]=a,e},j=function(e,t){try{K(N,e,t)}catch(R){N[e]=t}return t},X=N["__core-js_shared__"]||j("__core-js_shared__",{}),Z=Function.toString;"function"!=typeof X.inspectSource&&(X.inspectSource=function(e){return Z.call(e)});var J,ee,te,ae=X.inspectSource,ne=N.WeakMap,re="function"==typeof ne&&/native code/.test(ae(ne)),ie=e(function(e){(e.exports=function(e,t){return X[e]||(X[e]=void 0!==t?t:{})})("versions",[]).push({version:"3.6.5",mode:"global",copyright:"\xa9 2020 Denis Pushkarev (zloirock.ru)"})}),oe=0,se=Math.random(),le=function(e){return"Symbol("+String(void 0===e?"":e)+")_"+(++oe+se).toString(36)},_e=ie("keys"),ce=function(e){return _e[e]||(_e[e]=le(e))},de={},ue=N.WeakMap;if(re){var me=new ue,pe=me.get,Ee=me.has,ge=me.set;J=function(e,t){return ge.call(me,e,t),t},ee=function(e){return pe.call(me,e)||{}},te=function(e){return Ee.call(me,e)}}else{var Se=ce("state");de[Se]=!0,J=function(e,t){return K(e,Se,t),t},ee=function(e){return B(e,Se)?e[Se]:{}},te=function(e){return B(e,Se)}}var Te={set:J,get:ee,has:te,enforce:function(e){return te(e)?ee(e):J(e,{})},getterFor:function(e){return function(t){var a;if(!k(t)||(a=ee(t)).type!==e)throw TypeError("Incompatible receiver, "+e+" required");return a}}},be=e(function(e){var t=Te.get,a=Te.enforce,n=String(String).split("String");(e.exports=function(e,t,r,i){var o=!!i&&!!i.unsafe,s=!!i&&!!i.enumerable,l=!!i&&!!i.noTargetGet;"function"==typeof r&&("string"!=typeof t||B(r,"name")||K(r,"name",t),a(r).source=n.join("string"==typeof t?t:"")),e!==N?(o?!l&&e[t]&&(s=!0):delete e[t],s?e[t]=r:K(e,t,r)):s?e[t]=r:j(t,r)})(Function.prototype,"toString",function(){return"function"==typeof this&&t(this).source||ae(this)})}),fe=N,Ce=function(e){return"function"==typeof e?e:void 0},Re=function(e,t){return arguments.length<2?Ce(fe[e])||Ce(N[e]):fe[e]&&fe[e][t]||N[e]&&N[e][t]},Ne=Math.ceil,Oe=Math.floor,ve=function(e){return isNaN(e=+e)?0:(e>0?Oe:Ne)(e)},Ie=Math.min,he=function(e){return e>0?Ie(ve(e),9007199254740991):0},Ae=Math.max,ye=Math.min,De=function(e,t){var a=ve(e);return a<0?Ae(a+t,0):ye(a,t)},Me=function(e){return function(t,a,n){var r,i=P(t),o=he(i.length),s=De(n,o);if(e&&a!=a){for(;o>s;)if((r=i[s++])!=r)return!0}else for(;o>s;s++)if((e||s in i)&&i[s]===a)return e||s||0;return!e&&-1}},Le={includes:Me(!0),indexOf:Me(!1)},xe=Le.indexOf,we=function(e,t){var a,n=P(e),r=0,i=[];for(a in n)!B(de,a)&&B(n,a)&&i.push(a);for(;t.length>r;)B(n,a=t[r++])&&(~xe(i,a)||i.push(a));return i},Pe=["constructor","hasOwnProperty","isPrototypeOf","propertyIsEnumerable","toLocaleString","toString","valueOf"],ke=Pe.concat("length","prototype"),Ue={f:Object.getOwnPropertyNames||function(e){return we(e,ke)}},Fe={f:Object.getOwnPropertySymbols},Be=Re("Reflect","ownKeys")||function(e){var t=Ue.f(W(e)),a=Fe.f;return a?t.concat(a(e)):t},Ge=function(e,t){for(var a=Be(t),n=$.f,r=z.f,i=0;iS;S++)if((o||S in p)&&(u=E(d=p[S],S,m),e))if(t)b[S]=u;else if(u)switch(e){case 3:return!0;case 5:return d;case 6:return S;case 2:st.call(b,d)}else if(r)return!1;return i?-1:n||r?r:b}},_t={forEach:lt(0),map:lt(1),filter:lt(2),some:lt(3),every:lt(4),find:lt(5),findIndex:lt(6)},ct=function(e,t){var a=[][e];return!!a&&O(function(){a.call(null,t||function(){throw 1},1)})},dt=Object.defineProperty,ut={},mt=function(e){throw e},pt=function(e,t){if(B(ut,e))return ut[e];t||(t={});var a=[][e],n=!!B(t,"ACCESSORS")&&t.ACCESSORS,r=B(t,0)?t[0]:mt,i=B(t,1)?t[1]:void 0;return ut[e]=!!a&&!O(function(){if(n&&!v)return!0;var e={length:-1};n?dt(e,1,{enumerable:!0,get:mt}):e[1]=1,a.call(e,r,i)})},Et=_t.forEach,gt=ct("forEach"),St=pt("forEach"),Tt=gt&&St?[].forEach:function(e){return Et(this,e,arguments.length>1?arguments[1]:void 0)};Ke({target:"Array",proto:!0,forced:[].forEach!=Tt},{forEach:Tt});var bt=[].join,ft=x!=Object,Ct=ct("join",",");Ke({target:"Array",proto:!0,forced:ft||!Ct},{join:function(e){return bt.call(P(this),void 0===e?",":e)}});var Rt,Nt,Ot=Re("navigator","userAgent")||"",vt=N.process,It=vt&&vt.versions,ht=It&&It.v8;ht?Nt=(Rt=ht.split("."))[0]+Rt[1]:Ot&&(!(Rt=Ot.match(/Edge\/(\d+)/))||Rt[1]>=74)&&(Rt=Ot.match(/Chrome\/(\d+)/))&&(Nt=Rt[1]);var At=Nt&&+Nt,yt=rt("species"),Dt=function(e){return At>=51||!O(function(){var t=[];return(t.constructor={})[yt]=function(){return{foo:1}},1!==t[e](Boolean).foo})},Mt=_t.map,Lt=Dt("map"),xt=pt("map");Ke({target:"Array",proto:!0,forced:!Lt||!xt},{map:function(e){return Mt(this,e,arguments.length>1?arguments[1]:void 0)}});var wt=function(e){return function(t,a,n,r){je(a);var i=Xe(t),o=x(i),s=he(i.length),l=e?s-1:0,_=e?-1:1;if(n<2)for(;;){if(l in o){r=o[l],l+=_;break}if(l+=_,e?l<0:s<=l)throw TypeError("Reduce of empty array with no initial value")}for(;e?l>=0:s>l;l+=_)l in o&&(r=a(r,o[l],l,i));return r}},Pt=[wt(!1),wt(!0)][0],kt=ct("reduce"),Ut=pt("reduce",{1:0});Ke({target:"Array",proto:!0,forced:!kt||!Ut},{reduce:function(e){return Pt(this,e,arguments.length,arguments.length>1?arguments[1]:void 0)}});var Ft=function(e,t,a){var n=U(t);n in e?$.f(e,n,y(0,a)):e[n]=a},Bt=Dt("slice"),Gt=pt("slice",{ACCESSORS:!0,0:0,1:2}),Yt=rt("species"),Ht=[].slice,Vt=Math.max;Ke({target:"Array",proto:!0,forced:!Bt||!Gt},{slice:function(e,t){var a,n,r,i=P(this),o=he(i.length),s=De(e,o),l=De(void 0===t?o:t,o);if(Ze(i)&&("function"!=typeof(a=i.constructor)||a!==Array&&!Ze(a.prototype)?k(a)&&null===(a=a[Yt])&&(a=void 0):a=void 0,a===Array||void 0===a))return Ht.call(i,s,l);for(n=new(void 0===a?Array:a)(Vt(l-s,0)),r=0;s9007199254740991)throw TypeError("Maximum allowed length exceeded");for(r=ot(l,n),i=0;i_-n+a;i--)delete l[i-1]}else if(a>n)for(i=_-n;i>c;i--)s=i+a-1,(o=i+n-1)in l?l[s]=l[o]:delete l[s];for(i=0;ii;)$.f(e,a=n[i++],t[a]);return e},Jt=Re("document","documentElement"),ea=ce("IE_PROTO"),ta=function(){},aa=function(e){return""+e+""},na=function(){try{$t=document.domain&&new ActiveXObject("htmlfile")}catch(e){}var e,t;na=$t?function(e){e.write(aa("")),e.close();var t=e.parentWindow.Object;return e=null,t}($t):((t=H("iframe")).style.display="none",Jt.appendChild(t),t.src=String("javascript:"),(e=t.contentWindow.document).open(),e.write(aa("document.F=Object")),e.close(),e.F);for(var a=Pe.length;a--;)delete na.prototype[Pe[a]];return na()};de[ea]=!0;var ra=Object.create||function(e,t){var a;return null!==e?(ta.prototype=W(e),a=new ta,ta.prototype=null,a[ea]=e):a=na(),void 0===t?a:Zt(a,t)},ia="\t\n\x0B\f\r \xa0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\ufeff",oa="["+ia+"]",sa=RegExp("^"+oa+oa+"*"),la=RegExp(oa+oa+"*$"),_a=function(e){return function(t){var a=String(w(t));return 1&e&&(a=a.replace(sa,"")),2&e&&(a=a.replace(la,"")),a}},ca={start:_a(1),end:_a(2),trim:_a(3)},da=Ue.f,ua=z.f,ma=$.f,pa=ca.trim,Ea=N.Number,ga=Ea.prototype,Sa="Number"==M(ra(ga)),Ta=function(e){var t,a,n,r,i,o,s,l,_=U(e,!1);if("string"==typeof _&&_.length>2)if(43===(t=(_=pa(_)).charCodeAt(0))||45===t){if(88===(a=_.charCodeAt(2))||120===a)return NaN}else if(48===t){switch(_.charCodeAt(1)){case 66:case 98:n=2,r=49;break;case 79:case 111:n=8,r=55;break;default:return+_}for(o=(i=_.slice(2)).length,s=0;sr)return NaN;return parseInt(i,n)}return+_};if(Qe("Number",!Ea(" 0o1")||!Ea("0b1")||Ea("+0x1"))){for(var ba,fa=function(e){var t=arguments.length<1?0:e,a=this;return a instanceof fa&&(Sa?O(function(){ga.valueOf.call(a)}):"Number"!=M(a))?jt(new Ea(Ta(t)),a,fa):Ta(t)},Ca=v?da(Ea):"MAX_VALUE,MIN_VALUE,NaN,NEGATIVE_INFINITY,POSITIVE_INFINITY,EPSILON,isFinite,isInteger,isNaN,isSafeInteger,MAX_SAFE_INTEGER,MIN_SAFE_INTEGER,parseFloat,parseInt,isInteger".split(","),Ra=0;Ca.length>Ra;Ra++)B(Ea,ba=Ca[Ra])&&!B(fa,ba)&&ma(fa,ba,ua(Ea,ba));fa.prototype=ga,ga.constructor=fa,be(N,"Number",fa)}var Na=function(){var e=W(this),t="";return e.global&&(t+="g"),e.ignoreCase&&(t+="i"),e.multiline&&(t+="m"),e.dotAll&&(t+="s"),e.unicode&&(t+="u"),e.sticky&&(t+="y"),t},Oa={UNSUPPORTED_Y:O(function(){var e=t("a","y");return e.lastIndex=2,null!=e.exec("abcd")}),BROKEN_CARET:O(function(){var e=t("^r","gy");return e.lastIndex=2,null!=e.exec("str")})},va=RegExp.prototype.exec,Ia=String.prototype.replace,ha=va,Aa=function(){var e=/a/,t=/b*/g;return va.call(e,"a"),va.call(t,"a"),0!==e.lastIndex||0!==t.lastIndex}(),ya=Oa.UNSUPPORTED_Y||Oa.BROKEN_CARET,Da=void 0!==/()??/.exec("")[1];(Aa||Da||ya)&&(ha=function(e){var t,a,n,r,i=this,o=ya&&i.sticky,s=Na.call(i),l=i.source,_=0,c=e;return o&&(-1===(s=s.replace("y","")).indexOf("g")&&(s+="g"),c=String(e).slice(i.lastIndex),i.lastIndex>0&&(!i.multiline||i.multiline&&"\n"!==e[i.lastIndex-1])&&(l="(?: "+l+")",c=" "+c,_++),a=new RegExp("^(?:"+l+")",s)),Da&&(a=new RegExp("^"+l+"$(?!\\s)",s)),Aa&&(t=i.lastIndex),n=va.call(o?a:i,c),o?n?(n.input=n.input.slice(_),n[0]=n[0].slice(_),n.index=i.lastIndex,i.lastIndex+=n[0].length):i.lastIndex=0:Aa&&n&&(i.lastIndex=i.global?n.index+n[0].length:t),Da&&n&&n.length>1&&Ia.call(n[0],a,function(){for(r=1;r")}),wa="$0"==="a".replace(/./,"$0"),Pa=rt("replace"),ka=!!/./[Pa]&&""===/./[Pa]("a","$0"),Ua=!O(function(){var e=/(?:)/,t=e.exec;e.exec=function(){return t.apply(this,arguments)};var a="ab".split(e);return 2!==a.length||"a"!==a[0]||"b"!==a[1]}),Fa=function(e,t,a,n){var r=rt(e),i=!O(function(){var t={};return t[r]=function(){return 7},7!=""[e](t)}),o=i&&!O(function(){var t=!1,a=/a/;return"split"===e&&((a={}).constructor={},a.constructor[La]=function(){return a},a.flags="",a[r]=/./[r]),a.exec=function(){return t=!0,null},a[r](""),!t});if(!i||!o||"replace"===e&&(!xa||!wa||ka)||"split"===e&&!Ua){var s=/./[r],l=a(r,""[e],function(e,t,a,n,r){return t.exec===Ma?i&&!r?{done:!0,value:s.call(t,a,n)}:{done:!0,value:e.call(a,t,n)}:{done:!1}},{REPLACE_KEEPS_$0:wa,REGEXP_REPLACE_SUBSTITUTES_UNDEFINED_CAPTURE:ka}),_=l[0],c=l[1];be(String.prototype,e,_),be(RegExp.prototype,r,2==t?function(e,t){return c.call(e,this,t)}:function(e){return c.call(e,this)})}n&&K(RegExp.prototype[r],"sham",!0)},Ba=function(e){return function(t,a){var n,r,i=String(w(t)),o=ve(a),s=i.length;return o<0||o>=s?e?"":void 0:(n=i.charCodeAt(o))<55296||n>56319||o+1===s||(r=i.charCodeAt(o+1))<56320||r>57343?e?i.charAt(o):n:e?i.slice(o,o+2):r-56320+(n-55296<<10)+65536}},Ga=(Ba(!1),Ba(!0)),Ya=function(e,t,a){return t+(a?Ga(e,t).length:1)},Ha=function(e,t){var a=e.exec;if("function"==typeof a){var n=a.call(e,t);if("object"!=typeof n)throw TypeError("RegExp exec method returned something other than an Object or null");return n}if("RegExp"!==M(e))throw TypeError("RegExp#exec called on incompatible receiver");return Ma.call(e,t)};Fa("match",1,function(e,t,a){return[function(t){var a=w(this),n=null==t?void 0:t[e];return void 0!==n?n.call(t,a):new RegExp(t)[e](String(a))},function(e){var n=a(t,e,this);if(n.done)return n.value;var r=W(e),i=String(this);if(!r.global)return Ha(r,i);var o=r.unicode;r.lastIndex=0;for(var s,l=[],_=0;null!==(s=Ha(r,i));){var c=String(s[0]);l[_]=c,""===c&&(r.lastIndex=Ya(i,he(r.lastIndex),o)),_++}return 0===_?null:l}]});var Va=Math.max,qa=Math.min,za=Math.floor,Wa=/\$([$&'`]|\d\d?|<[^>]*>)/g,Qa=/\$([$&'`]|\d\d?)/g;Fa("replace",2,function(e,t,a,n){function r(e,a,n,r,i,o){var s=n+e.length,l=r.length,_=Qa;return void 0!==i&&(i=Xe(i),_=Wa),t.call(o,_,function(t,o){var _;switch(o.charAt(0)){case"$":return"$";case"&":return e;case"`":return a.slice(0,n);case"'":return a.slice(s);case"<":_=i[o.slice(1,-1)];break;default:var c=+o;if(0===c)return t;if(c>l){var d=za(c/10);return 0===d?t:d<=l?void 0===r[d-1]?o.charAt(1):r[d-1]+o.charAt(1):t}_=r[c-1]}return void 0===_?"":_})}var i=n.REGEXP_REPLACE_SUBSTITUTES_UNDEFINED_CAPTURE,o=n.REPLACE_KEEPS_$0,s=i?"$":"$0";return[function(a,n){var r=w(this),i=null==a?void 0:a[e];return void 0!==i?i.call(a,r,n):t.call(String(r),a,n)},function(e,n){if(!i&&o||"string"==typeof n&&-1===n.indexOf(s)){var l=a(t,e,this,n);if(l.done)return l.value}var _=W(e),c=String(this),d="function"==typeof n;d||(n=String(n));var u=_.global;if(u){var m=_.unicode;_.lastIndex=0}for(var p=[];;){var E=Ha(_,c);if(null===E)break;if(p.push(E),!u)break;""===String(E[0])&&(_.lastIndex=Ya(c,he(_.lastIndex),m))}for(var g,S="",T=0,b=0;b=T&&(S+=c.slice(T,C)+I,T=C+f.length)}return S+c.slice(T)}]});var $a=rt("match"),Ka=function(e){var t;return k(e)&&(void 0!==(t=e[$a])?!!t:"RegExp"==M(e))},ja=rt("species"),Xa=[].push,Za=Math.min,Ja=!O(function(){return!RegExp(4294967295,"y")});Fa("split",2,function(e,t,a){var n;return n="c"=="abbc".split(/(b)*/)[1]||4!="test".split(/(?:)/,-1).length||2!="ab".split(/(?:ab)*/).length||4!=".".split(/(.?)(.?)/).length||".".split(/()()/).length>1||"".split(/.?/).length?function(e,a){var n=String(w(this)),r=void 0===a?4294967295:a>>>0;if(0===r)return[];if(void 0===e)return[n];if(!Ka(e))return t.call(n,e,r);for(var i,o,s,l=[],_=(e.ignoreCase?"i":"")+(e.multiline?"m":"")+(e.unicode?"u":"")+(e.sticky?"y":""),c=0,d=new RegExp(e.source,_+"g");(i=Ma.call(d,n))&&!((o=d.lastIndex)>c&&(l.push(n.slice(c,i.index)),i.length>1&&i.index=r));)d.lastIndex===i.index&&d.lastIndex++;return c===n.length?!s&&d.test("")||l.push(""):l.push(n.slice(c)),l.length>r?l.slice(0,r):l}:"0".split(void 0,0).length?function(e,a){return void 0===e&&0===a?[]:t.call(this,e,a)}:t,[function(t,a){var r=w(this),i=null==t?void 0:t[e];return void 0!==i?i.call(t,r,a):n.call(String(r),t,a)},function(e,r){var i=a(n,e,this,r,n!==t);if(i.done)return i.value;var o=W(e),s=String(this),l=function(e,t){var a,n=W(o).constructor;return void 0===n||null==(a=W(n)[ja])?t:je(a)}(0,RegExp),_=o.unicode,c=(o.ignoreCase?"i":"")+(o.multiline?"m":"")+(o.unicode?"u":"")+(Ja?"y":"g"),d=new l(Ja?o:"^(?:"+o.source+")",c),u=void 0===r?4294967295:r>>>0;if(0===u)return[];if(0===s.length)return null===Ha(d,s)?[s]:[];for(var m=0,p=0,E=[];p=51||!O(function(){var e=[];return e[on]=!1,e.concat()[0]!==e}),ln=Dt("concat"),_n=function(e){if(!k(e))return!1;var t=e[on];return void 0!==t?!!t:Ze(e)};Ke({target:"Array",proto:!0,forced:!sn||!ln},{concat:function(){var e,t,a,n,r,i=Xe(this),o=ot(i,0),s=0;for(e=-1,a=arguments.length;e9007199254740991)throw TypeError("Maximum allowed index exceeded");for(t=0;t=9007199254740991)throw TypeError("Maximum allowed index exceeded");Ft(o,s++,r)}return o.length=s,o}});var cn=_t.every,dn=ct("every"),un=pt("every");Ke({target:"Array",proto:!0,forced:!dn||!un},{every:function(e){return cn(this,e,arguments.length>1?arguments[1]:void 0)}});var mn=_t.filter,pn=Dt("filter"),En=pt("filter");Ke({target:"Array",proto:!0,forced:!pn||!En},{filter:function(e){return mn(this,e,arguments.length>1?arguments[1]:void 0)}});var gn=rt("unscopables"),Sn=Array.prototype;null==Sn[gn]&&$.f(Sn,gn,{configurable:!0,value:ra(null)});var Tn=function(e){Sn[gn][e]=!0},bn=_t.find,fn=!0,Cn=pt("find");"find"in[]&&Array(1).find(function(){fn=!1}),Ke({target:"Array",proto:!0,forced:fn||!Cn},{find:function(e){return bn(this,e,arguments.length>1?arguments[1]:void 0)}}),Tn("find");var Rn=_t.findIndex,Nn=!0,On=pt("findIndex");"findIndex"in[]&&Array(1).findIndex(function(){Nn=!1}),Ke({target:"Array",proto:!0,forced:Nn||!On},{findIndex:function(e){return Rn(this,e,arguments.length>1?arguments[1]:void 0)}}),Tn("findIndex");var vn=Le.includes;Ke({target:"Array",proto:!0,forced:!pt("indexOf",{ACCESSORS:!0,1:0})},{includes:function(e){return vn(this,e,arguments.length>1?arguments[1]:void 0)}}),Tn("includes");var In=$.f,hn=Function.prototype,An=hn.toString,yn=/^\s*function ([^ (]*)/;v&&!("name"in hn)&&In(hn,"name",{configurable:!0,get:function(){try{return An.call(this).match(yn)[1]}catch(C){return""}}});var Dn=Object.assign,Mn=Object.defineProperty,Ln=!Dn||O(function(){if(v&&1!==Dn({b:1},Dn(Mn({},"a",{enumerable:!0,get:function(){Mn(this,"b",{value:3,enumerable:!1})}}),{b:2})).b)return!0;var e={},t={},a=Symbol();return e[a]=7,"abcdefghijklmnopqrst".split("").forEach(function(e){t[e]=e}),7!=Dn({},e)[a]||"abcdefghijklmnopqrst"!=Xt(Dn({},t)).join("")})?function(e){for(var t=Xe(e),a=arguments.length,n=1,r=Fe.f,i=A.f;a>n;)for(var o,s=x(arguments[n++]),l=r?Xt(s).concat(r(s)):Xt(s),_=l.length,c=0;_>c;)o=l[c++],v&&!i.call(s,o)||(t[o]=s[o]);return t}:Dn;Ke({target:"Object",stat:!0,forced:Object.assign!==Ln},{assign:Ln});var xn=!O(function(){return Object.isExtensible(Object.preventExtensions({}))}),wn=e(function(e){var t=$.f,a=le("meta"),n=0,r=Object.isExtensible||function(){return!0},i=function(e){t(e,a,{value:{objectID:"O"+ ++n,weakData:{}}})},o=e.exports={REQUIRED:!1,fastKey:function(e,t){if(!k(e))return"symbol"==typeof e?e:("string"==typeof e?"S":"P")+e;if(!B(e,a)){if(!r(e))return"F";if(!t)return"E";i(e)}return e[a].objectID},getWeakData:function(e,t){if(!B(e,a)){if(!r(e))return!0;if(!t)return!1;i(e)}return e[a].weakData},onFreeze:function(e){return xn&&o.REQUIRED&&r(e)&&!B(e,a)&&i(e),e}};de[a]=!0}),Pn=(wn.REQUIRED,wn.fastKey,wn.getWeakData,wn.onFreeze,wn.onFreeze),kn=Object.freeze;Ke({target:"Object",stat:!0,forced:O(function(){kn(1)}),sham:!xn},{freeze:function(e){return kn&&k(e)?kn(Pn(e)):e}});var Un=Ue.f,Fn={}.toString,Bn="object"==typeof window&&window&&Object.getOwnPropertyNames?Object.getOwnPropertyNames(window):[],Gn=function(e){return Bn&&"[object Window]"==Fn.call(e)?function(e){try{return Un(e)}catch(e){return Bn.slice()}}(e):Un(P(e))};Ke({target:"Object",stat:!0,forced:O(function(){return!Object.getOwnPropertyNames(1)})},{getOwnPropertyNames:Gn});var Yn=Object.isFrozen;Ke({target:"Object",stat:!0,forced:O(function(){Yn(1)})},{isFrozen:function(e){return!k(e)||!!Yn&&Yn(e)}}),Ke({target:"Object",stat:!0,forced:O(function(){Xt(1)})},{keys:function(e){return Xt(Xe(e))}});var Hn={};Hn[rt("toStringTag")]="z";var Vn="[object z]"===String(Hn),qn=rt("toStringTag"),zn="Arguments"==M(function(){return arguments}()),Wn=Vn?M:function(e){var t,a,n;return void 0===e?"Undefined":null===e?"Null":"string"==typeof(a=function(e,t){try{return e[t]}catch(e){}}(t=Object(e),qn))?a:zn?M(t):"Object"==(n=M(t))&&"function"==typeof t.callee?"Arguments":n},Qn=Vn?{}.toString:function(){return"[object "+Wn(this)+"]"};Vn||be(Object.prototype,"toString",Qn,{unsafe:!0});var $n=rt("species"),Kn=$.f,jn=Ue.f,Xn=Te.set,Zn=rt("match"),Jn=N.RegExp,er=Jn.prototype,tr=/a/g,ar=/a/g,nr=new Jn(tr)!==tr,rr=Oa.UNSUPPORTED_Y;if(v&&Qe("RegExp",!nr||rr||O(function(){return ar[Zn]=!1,Jn(tr)!=tr||Jn(ar)==ar||"/a/i"!=Jn(tr,"i")}))){for(var ir=function(e,t){var a,n=this instanceof ir,r=Ka(e),i=void 0===t;if(!n&&r&&e.constructor===ir&&i)return e +;nr?r&&!i&&(e=e.source):e instanceof ir&&(i&&(t=Na.call(e)),e=e.source),rr&&(a=!!t&&t.indexOf("y")>-1)&&(t=t.replace(/y/g,""));var o=jt(nr?new Jn(e,t):Jn(e,t),n?this:er,ir);return rr&&a&&Xn(o,{sticky:a}),o},or=function(e){e in ir||Kn(ir,e,{configurable:!0,get:function(){return Jn[e]},set:function(t){Jn[e]=t}})},sr=jn(Jn),lr=0;sr.length>lr;)or(sr[lr++]);er.constructor=ir,ir.prototype=er,be(N,"RegExp",ir)}!function(){var e=Re("RegExp"),t=$.f;v&&e&&!e[$n]&&t(e,$n,{configurable:!0,get:function(){return this}})}();var _r=RegExp.prototype,cr=_r.toString,dr=O(function(){return"/a/b"!=cr.call({source:"a",flags:"b"})}),ur="toString"!=cr.name;(dr||ur)&&be(RegExp.prototype,"toString",function(){var e=W(this),t=String(e.source),a=e.flags;return"/"+t+"/"+String(void 0===a&&e instanceof RegExp&&!("flags"in _r)?Na.call(e):a)},{unsafe:!0});var mr=function(e){if(Ka(e))throw TypeError("The method doesn't accept regular expressions");return e},pr=rt("match");Ke({target:"String",proto:!0,forced:!function(e){var t=/./;try{"/./"[e](t)}catch(R){try{return t[pr]=!1,"/./"[e](t)}catch(e){}}return!1}("includes")},{includes:function(e){return!!~String(w(this)).indexOf(mr(e),arguments.length>1?arguments[1]:void 0)}});var Er=Object.freeze({__proto__:null,escapeHTML:E,inherit:g,nodeStream:function(e){var t=[];return function a(e,n){for(var r=e.firstChild;r;r=r.nextSibling)3===r.nodeType?n+=r.nodeValue.length:1===r.nodeType&&(t.push({event:"start",offset:n,node:r}),n=a(r,n),S(r).match(/br|hr|img|input/)||t.push({event:"stop",offset:n,node:r}));return n}(e,0),t},mergeStreams:function(e,t,a){function n(){return e.length&&t.length?e[0].offset!==t[0].offset?e[0].offset"}function i(e){l+=""}function o(e){("start"===e.event?r:i)(e.node)}for(var s=0,l="",_=[];e.length||t.length;){var c=n();if(l+=E(a.substring(s,c[0].offset)),s=c[0].offset,c===e){_.reverse().forEach(i);do{o(c.splice(0,1)[0]),c=n()}while(c===e&&c.length&&c[0].offset===s);_.reverse().forEach(r)}else"start"===c[0].event?_.push(c[0].node):_.pop(),o(c.splice(0,1)[0])}return l+E(a.substr(s))}}),gr=function(e){return!!e.kind},Sr=function(){function e(t,a){n(this,e),this.buffer="",this.classPrefix=a.classPrefix,t.walk(this)}return i(e,[{key:"addText",value:function(e){this.buffer+=E(e)}},{key:"openNode",value:function(e){if(gr(e)){var t=e.kind;e.sublanguage||(t="".concat(this.classPrefix).concat(t)),this.span(t)}}},{key:"closeNode",value:function(e){gr(e)&&(this.buffer+="")}},{key:"span",value:function(e){this.buffer+='')}},{key:"value",value:function(){return this.buffer}}]),e}(),Tr=function(e){function t(e){var r;return n(this,t),(r=a.call(this)).options=e,r}!function(e,t){if("function"!=typeof t&&null!==t)throw new TypeError("Super expression must either be null or a function");e.prototype=Object.create(t&&t.prototype,{constructor:{value:e,writable:!0,configurable:!0}}),t&&s(e,t)}(t,e);var a=_(t);return i(t,[{key:"addKeyword",value:function(e,t){""!==e&&(this.openNode(t),this.addText(e),this.closeNode())}},{key:"addText",value:function(e){""!==e&&this.add(e)}},{key:"addSublanguage",value:function(e,t){var a=e.root;a.kind=t,a.sublanguage=!0,this.add(a)}},{key:"toHTML",value:function(){return new Sr(this,this.options).value()}},{key:"finalize",value:function(){}}]),t}(function(){function e(){n(this,e),this.rootNode={children:[]},this.stack=[this.rootNode]}return i(e,[{key:"add",value:function(e){this.top.children.push(e)}},{key:"openNode",value:function(e){var t={kind:e,children:[]};this.add(t),this.stack.push(t)}},{key:"closeNode",value:function(){if(this.stack.length>1)return this.stack.pop()}},{key:"closeAllNodes",value:function(){for(;this.closeNode(););}},{key:"toJSON",value:function(){return JSON.stringify(this.rootNode,null,4)}},{key:"walk",value:function(e){return this.constructor._walk(e,this.rootNode)}},{key:"top",get:function(){return this.stack[this.stack.length-1]}},{key:"root",get:function(){return this.rootNode}}],[{key:"_walk",value:function(e,t){var a=this;return"string"==typeof t?e.addText(t):t.children&&(e.openNode(t),t.children.forEach(function(t){return a._walk(e,t)}),e.closeNode(t)),e}},{key:"_collapse",value:function(t){t.children&&(t.children.every(function(e){return"string"==typeof e})?(t.text=t.children.join(""),delete t.children):t.children.forEach(function(t){"string"!=typeof t&&e._collapse(t)}))}}]),e}()),br="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",fr={begin:"\\\\[\\s\\S]",relevance:0},Cr={className:"string",begin:"'",end:"'",illegal:"\\n",contains:[fr]},Rr={className:"string",begin:'"',end:'"',illegal:"\\n",contains:[fr]},Nr={begin:/\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|they|like|more)\b/},Or=function(e,t,a){var n=g({className:"comment",begin:e,end:t,contains:[]},a||{});return n.contains.push(Nr),n.contains.push({className:"doctag",begin:"(?:TODO|FIXME|NOTE|BUG|XXX):",relevance:0}),n},vr=Or("//","$"),Ir=Or("/\\*","\\*/"),hr=Or("#","$"),Ar={className:"number",begin:"\\b\\d+(\\.\\d+)?",relevance:0},yr={className:"number",begin:br,relevance:0},Dr={className:"number",begin:"\\b(0b[01]+)",relevance:0},Mr={className:"number",begin:"\\b\\d+(\\.\\d+)?(%|em|ex|ch|rem|vw|vh|vmin|vmax|cm|mm|in|pt|pc|px|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?",relevance:0},Lr={begin:/(?=\/[^\/\n]*\/)/,contains:[{className:"regexp",begin:/\//,end:/\/[gimuy]*/,illegal:/\n/,contains:[fr,{begin:/\[/,end:/\]/,relevance:0,contains:[fr]}]}]},xr={className:"title",begin:"[a-zA-Z]\\w*",relevance:0},wr={className:"title",begin:"[a-zA-Z_]\\w*",relevance:0},Pr={begin:"\\.\\s*[a-zA-Z_]\\w*",relevance:0},kr=Object.freeze({__proto__:null,IDENT_RE:"[a-zA-Z]\\w*",UNDERSCORE_IDENT_RE:"[a-zA-Z_]\\w*",NUMBER_RE:"\\b\\d+(\\.\\d+)?",C_NUMBER_RE:br,BINARY_NUMBER_RE:"\\b(0b[01]+)",RE_STARTERS_RE:"!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~",BACKSLASH_ESCAPE:fr,APOS_STRING_MODE:Cr,QUOTE_STRING_MODE:Rr,PHRASAL_WORDS_MODE:Nr,COMMENT:Or,C_LINE_COMMENT_MODE:vr,C_BLOCK_COMMENT_MODE:Ir,HASH_COMMENT_MODE:hr,NUMBER_MODE:Ar,C_NUMBER_MODE:yr,BINARY_NUMBER_MODE:Dr,CSS_NUMBER_MODE:Mr,REGEXP_MODE:Lr,TITLE_MODE:xr,UNDERSCORE_TITLE_MODE:wr,METHOD_GUARD:Pr}),Ur="of and for in not or if then".split(" "),Fr=E,Br=g,Gr=Er.nodeStream,Yr=Er.mergeStreams,Hr=function(e){function t(e){return C.noHighlightRe.test(e)}function n(e,t,a,n){var i={code:t,language:e};d("before:highlight",i);var o=i.result?i.result:r(i.language,i.code,a,n);return o.code=i.code,d("after:highlight",o),o}function r(e,t,a,n){function o(e,t){var a=g.case_insensitive?t[0].toLowerCase():t[0];return e.keywords.hasOwnProperty(a)&&e.keywords[a]}function s(){null!=R.subLanguage?function(){if(""!==h){var e="string"==typeof R.subLanguage;if(!e||m[R.subLanguage]){var t=e?r(R.subLanguage,h,!0,N[R.subLanguage]):i(h,R.subLanguage.length?R.subLanguage:void 0);R.relevance>0&&(A+=t.relevance),e&&(N[R.subLanguage]=t.top),O.addSublanguage(t.emitter,t.language)}else O.addText(h)}}():function(){var e,t,a,n;if(R.keywords){for(t=0,R.lexemesRe.lastIndex=0,a=R.lexemesRe.exec(h),n="";a;){n+=h.substring(t,a.index);var r=null;(e=o(R,a))?(O.addText(n),n="",A+=e[1],r=e[0],O.addKeyword(a[0],r)):n+=a[0],t=R.lexemesRe.lastIndex,a=R.lexemesRe.exec(h)}n+=h.substr(t),O.addText(n)}else O.addText(h)}(),h=""}function l(e){e.className&&O.openNode(e.className),R=Object.create(e,{parent:{value:R}})}function c(e){var t=e[0],a=e.rule;return a.__onBegin&&(a.__onBegin(e)||{}).ignoreMatch?function(e){return 0===R.matcher.regexIndex?(h+=e[0],1):(M=!0,0)}(t):(a&&a.endSameAsBegin&&(a.endRe=new RegExp(t.replace(/[-\/\\^$*+?.()|[\]{}]/g,"\\$&"),"m")),a.skip?h+=t:(a.excludeBegin&&(h+=t),s(),a.returnBegin||a.excludeBegin||(h=t)),l(a),a.returnBegin?0:t.length)}function d(e){var t=e[0],a=p.substr(e.index),n=function i(e,t){if(function(e,t){var a=e&&e.exec(t);return a&&0===a.index}(e.endRe,t)){for(;e.endsParent&&e.parent;)e=e.parent;return e}if(e.endsWithParent)return i(e.parent,t)}(R,a);if(n){var r=R;r.skip?h+=t:(r.returnEnd||r.excludeEnd||(h+=t),s(),r.excludeEnd&&(h=t));do{R.className&&O.closeNode(),R.skip||R.subLanguage||(A+=R.relevance),R=R.parent}while(R!==n.parent);return n.starts&&(n.endSameAsBegin&&(n.starts.endRe=n.endRe),l(n.starts)),r.returnEnd?0:t.length}}function u(t,n){var r,i=n&&n[0];if(h+=t,null==i)return s(),0;if("begin"==E.type&&"end"==n.type&&E.index==n.index&&""===i){if(h+=p.slice(n.index,n.index+1),!S)throw(r=new Error("0 width match regex")).languageName=e,r.badRule=E.rule,r;return 1}if(E=n,"begin"===n.type)return c(n);if("illegal"===n.type&&!a)throw(r=new Error('Illegal lexeme "'+i+'" for mode "'+(R.className||"")+'"')).mode=R,r;if("end"===n.type){var o=d(n);if(null!=o)return o}if("illegal"===n.type&&""===i)return 1;if(D>1e5&&D>3*n.index)throw new Error("potential infinite loop, way more iterations than matches");return h+=i,i.length}var p=t,E={},g=_(e);if(!g)throw console.error(f.replace("{}",e)),new Error('Unknown language: "'+e+'"');b(g);var T,R=n||g,N={},O=new C.__emitter(C);!function(){for(var e=[],t=R;t!==g;t=t.parent)t.className&&e.unshift(t.className);e.forEach(function(e){return O.openNode(e)})}();var v,I,h="",A=0,y=0,D=0,M=!1;try{for(R.matcher.considerAll();D++,M?M=!1:(R.matcher.lastIndex=y,R.matcher.considerAll()),v=R.matcher.exec(p);)I=u(p.substring(y,v.index),v),y=v.index+I;return u(p.substr(y)),O.closeAllNodes(),O.finalize(),T=O.toHTML(),{relevance:A,value:T,language:e,illegal:!1,emitter:O,top:R}}catch(t){if(t.message&&t.message.includes("Illegal"))return{illegal:!0,illegalBy:{msg:t.message,context:p.slice(y-100,y+100),mode:t.mode},sofar:T,relevance:0,value:Fr(p),emitter:O};if(S)return{relevance:0,value:Fr(p),emitter:O,language:e,top:R,errorRaised:t};throw t}}function i(e,t){t=t||C.languages||Object.keys(m);var a=function(e){var t={relevance:0,emitter:new C.__emitter(C),value:Fr(e),illegal:!1,top:R};return t.emitter.addText(e),t}(e),n=a;return t.filter(_).filter(c).forEach(function(t){var i=r(t,e,!1);i.language=t,i.relevance>n.relevance&&(n=i),i.relevance>a.relevance&&(n=a,a=i)}),n.language&&(a.second_best=n),a}function o(e){return C.tabReplace||C.useBR?e.replace(T,function(e,t){return C.useBR&&"\n"===e?"
":C.tabReplace?t.replace(/\t/g,C.tabReplace):""}):e}function s(e){var a,r,s,l,c,u=function(e){var a,n=e.className+" ";if(n+=e.parentNode?e.parentNode.className:"",a=C.languageDetectRe.exec(n)){var r=_(a[1]);return r||(console.warn(f.replace("{}",a[1])),console.warn("Falling back to no-highlight mode for this block.",e)),r?a[1]:"no-highlight"}return n.split(/\s+/).find(function(e){return t(e)||_(e)})}(e);t(u)||(d("before:highlightBlock",{block:e,language:u}),C.useBR?(a=document.createElement("div")).innerHTML=e.innerHTML.replace(/\n/g,"").replace(//g,"\n"):a=e,c=a.textContent,s=u?n(u,c,!0):i(c),(r=Gr(a)).length&&((l=document.createElement("div")).innerHTML=s.value,s.value=Yr(r,Gr(l),c)),s.value=o(s.value),d("after:highlightBlock",{block:e,result:s}),e.innerHTML=s.value,e.className=function(e,t,a){var n=t?E[t]:a,r=[e.trim()];return e.match(/\bhljs\b/)||r.push("hljs"),e.includes(n)||r.push(n),r.join(" ").trim()}(e.className,u,s.language),e.result={language:s.language,re:s.relevance},s.second_best&&(e.second_best={language:s.second_best.language,re:s.second_best.relevance}))}function l(){if(!l.called){l.called=!0;var e=document.querySelectorAll("pre code");u.forEach.call(e,s)}}function _(e){return e=(e||"").toLowerCase(),m[e]||m[E[e]]}function c(e){var t=_(e);return t&&!t.disableAutodetect}function d(e,t){var a=e;g.forEach(function(e){e[a]&&e[a](t)})}var u=[],m={},E={},g=[],S=!0,T=/((^(<[^>]+>|\t|)+|(?:\n)))/gm,f="Could not find the language '{}', did you forget to load/include a language module?",C={noHighlightRe:/^(no-?highlight)$/i,languageDetectRe:/\blang(?:uage)?-([\w-]+)\b/i,classPrefix:"hljs-",tabReplace:null,useBR:!1,languages:void 0,__emitter:Tr},R={disableAutodetect:!0,name:"Plain text"};for(var N in Object.assign(e,{highlight:n,highlightAuto:i,fixMarkup:o,highlightBlock:s,configure:function(e){C=Br(C,e)},initHighlighting:l,initHighlightingOnLoad:function(){window.addEventListener("DOMContentLoaded",l,!1)},registerLanguage:function(t,a){var n;try{n=a(e)}catch(e){if(console.error("Language definition for '{}' could not be registered.".replace("{}",t)),!S)throw e;console.error(e),n=R}n.name||(n.name=t),m[t]=n,n.rawDefinition=a.bind(null,e),n.aliases&&n.aliases.forEach(function(e){E[e]=t})},listLanguages:function(){return Object.keys(m)},getLanguage:_,requireLanguage:function(e){var t=_(e);if(t)return t;throw new Error("The '{}' language is required, but not loaded.".replace("{}",e))},autoDetection:c,inherit:Br,addPlugin:function(e){g.push(e)}}),e.debugMode=function(){S=!1},e.safeMode=function(){S=!0},e.versionString="10.0.3",kr)"object"===a(kr[N])&&p(kr[N]);return Object.assign(e,kr),e}({}),Vr=function(e){var t="[A-Za-z\u0410-\u042f\u0430-\u044f\u0451\u0401_][A-Za-z\u0410-\u042f\u0430-\u044f\u0451\u0401_0-9]+",a="\u0434\u0430\u043b\u0435\u0435 \u0432\u043e\u0437\u0432\u0440\u0430\u0442 \u0432\u044b\u0437\u0432\u0430\u0442\u044c\u0438\u0441\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u0434\u043b\u044f \u0435\u0441\u043b\u0438 \u0438 \u0438\u0437 \u0438\u043b\u0438 \u0438\u043d\u0430\u0447\u0435 \u0438\u043d\u0430\u0447\u0435\u0435\u0441\u043b\u0438 \u0438\u0441\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u043a\u0430\u0436\u0434\u043e\u0433\u043e \u043a\u043e\u043d\u0435\u0446\u0435\u0441\u043b\u0438 \u043a\u043e\u043d\u0435\u0446\u043f\u043e\u043f\u044b\u0442\u043a\u0438 \u043a\u043e\u043d\u0435\u0446\u0446\u0438\u043a\u043b\u0430 \u043d\u0435 \u043d\u043e\u0432\u044b\u0439 \u043f\u0435\u0440\u0435\u0439\u0442\u0438 \u043f\u0435\u0440\u0435\u043c \u043f\u043e \u043f\u043e\u043a\u0430 \u043f\u043e\u043f\u044b\u0442\u043a\u0430 \u043f\u0440\u0435\u0440\u0432\u0430\u0442\u044c \u043f\u0440\u043e\u0434\u043e\u043b\u0436\u0438\u0442\u044c \u0442\u043e\u0433\u0434\u0430 \u0446\u0438\u043a\u043b \u044d\u043a\u0441\u043f\u043e\u0440\u0442 ",n="null \u0438\u0441\u0442\u0438\u043d\u0430 \u043b\u043e\u0436\u044c \u043d\u0435\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u043e",r=e.inherit(e.NUMBER_MODE),i={className:"string",begin:'"|\\|',end:'"|$',contains:[{begin:'""'}]},o={begin:"'",end:"'",excludeBegin:!0,excludeEnd:!0,contains:[{className:"number",begin:"\\d{4}([\\.\\\\/:-]?\\d{2}){0,5}"}]},s=e.inherit(e.C_LINE_COMMENT_MODE);return{name:"1C:Enterprise",case_insensitive:!0,lexemes:t,keywords:{keyword:a, +built_in:"\u0440\u0430\u0437\u0434\u0435\u043b\u0438\u0442\u0435\u043b\u044c\u0441\u0442\u0440\u0430\u043d\u0438\u0446 \u0440\u0430\u0437\u0434\u0435\u043b\u0438\u0442\u0435\u043b\u044c\u0441\u0442\u0440\u043e\u043a \u0441\u0438\u043c\u0432\u043e\u043b\u0442\u0430\u0431\u0443\u043b\u044f\u0446\u0438\u0438 ansitooem oemtoansi \u0432\u0432\u0435\u0441\u0442\u0438\u0432\u0438\u0434\u0441\u0443\u0431\u043a\u043e\u043d\u0442\u043e \u0432\u0432\u0435\u0441\u0442\u0438\u043f\u0435\u0440\u0435\u0447\u0438\u0441\u043b\u0435\u043d\u0438\u0435 \u0432\u0432\u0435\u0441\u0442\u0438\u043f\u0435\u0440\u0438\u043e\u0434 \u0432\u0432\u0435\u0441\u0442\u0438\u043f\u043b\u0430\u043d\u0441\u0447\u0435\u0442\u043e\u0432 \u0432\u044b\u0431\u0440\u0430\u043d\u043d\u044b\u0439\u043f\u043b\u0430\u043d\u0441\u0447\u0435\u0442\u043e\u0432 \u0434\u0430\u0442\u0430\u0433\u043e\u0434 \u0434\u0430\u0442\u0430\u043c\u0435\u0441\u044f\u0446 \u0434\u0430\u0442\u0430\u0447\u0438\u0441\u043b\u043e \u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043e\u043a\u0441\u0438\u0441\u0442\u0435\u043c\u044b \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0432\u0441\u0442\u0440\u043e\u043a\u0443 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0438\u0437\u0441\u0442\u0440\u043e\u043a\u0438 \u043a\u0430\u0442\u0430\u043b\u043e\u0433\u0438\u0431 \u043a\u0430\u0442\u0430\u043b\u043e\u0433\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u043a\u043e\u0434\u0441\u0438\u043c\u0432 \u043a\u043e\u043d\u0433\u043e\u0434\u0430 \u043a\u043e\u043d\u0435\u0446\u043f\u0435\u0440\u0438\u043e\u0434\u0430\u0431\u0438 \u043a\u043e\u043d\u0435\u0446\u0440\u0430\u0441\u0441\u0447\u0438\u0442\u0430\u043d\u043d\u043e\u0433\u043e\u043f\u0435\u0440\u0438\u043e\u0434\u0430\u0431\u0438 \u043a\u043e\u043d\u0435\u0446\u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u043e\u0433\u043e\u0438\u043d\u0442\u0435\u0440\u0432\u0430\u043b\u0430 \u043a\u043e\u043d\u043a\u0432\u0430\u0440\u0442\u0430\u043b\u0430 \u043a\u043e\u043d\u043c\u0435\u0441\u044f\u0446\u0430 \u043a\u043e\u043d\u043d\u0435\u0434\u0435\u043b\u0438 \u043b\u043e\u0433 \u043b\u043e\u043310 \u043c\u0430\u043a\u0441\u0438\u043c\u0430\u043b\u044c\u043d\u043e\u0435\u043a\u043e\u043b\u0438\u0447\u0435\u0441\u0442\u0432\u043e\u0441\u0443\u0431\u043a\u043e\u043d\u0442\u043e \u043d\u0430\u0437\u0432\u0430\u043d\u0438\u0435\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430 \u043d\u0430\u0437\u0432\u0430\u043d\u0438\u0435\u043d\u0430\u0431\u043e\u0440\u0430\u043f\u0440\u0430\u0432 \u043d\u0430\u0437\u043d\u0430\u0447\u0438\u0442\u044c\u0432\u0438\u0434 \u043d\u0430\u0437\u043d\u0430\u0447\u0438\u0442\u044c\u0441\u0447\u0435\u0442 \u043d\u0430\u0439\u0442\u0438\u0441\u0441\u044b\u043b\u043a\u0438 \u043d\u0430\u0447\u0430\u043b\u043e\u043f\u0435\u0440\u0438\u043e\u0434\u0430\u0431\u0438 \u043d\u0430\u0447\u0430\u043b\u043e\u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u043e\u0433\u043e\u0438\u043d\u0442\u0435\u0440\u0432\u0430\u043b\u0430 \u043d\u0430\u0447\u0433\u043e\u0434\u0430 \u043d\u0430\u0447\u043a\u0432\u0430\u0440\u0442\u0430\u043b\u0430 \u043d\u0430\u0447\u043c\u0435\u0441\u044f\u0446\u0430 \u043d\u0430\u0447\u043d\u0435\u0434\u0435\u043b\u0438 \u043d\u043e\u043c\u0435\u0440\u0434\u043d\u044f\u0433\u043e\u0434\u0430 \u043d\u043e\u043c\u0435\u0440\u0434\u043d\u044f\u043d\u0435\u0434\u0435\u043b\u0438 \u043d\u043e\u043c\u0435\u0440\u043d\u0435\u0434\u0435\u043b\u0438\u0433\u043e\u0434\u0430 \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0430\u043e\u0436\u0438\u0434\u0430\u043d\u0438\u044f \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0439\u0436\u0443\u0440\u043d\u0430\u043b\u0440\u0430\u0441\u0447\u0435\u0442\u043e\u0432 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0439\u043f\u043b\u0430\u043d\u0441\u0447\u0435\u0442\u043e\u0432 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0439\u044f\u0437\u044b\u043a \u043e\u0447\u0438\u0441\u0442\u0438\u0442\u044c\u043e\u043a\u043d\u043e\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0439 \u043f\u0435\u0440\u0438\u043e\u0434\u0441\u0442\u0440 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0432\u0440\u0435\u043c\u044f\u0442\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0434\u0430\u0442\u0443\u0442\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0442\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f\u043e\u0442\u0431\u043e\u0440\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043f\u043e\u0437\u0438\u0446\u0438\u044e\u0442\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043f\u0443\u0441\u0442\u043e\u0435\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0442\u0430 \u043f\u0440\u0435\u0444\u0438\u043a\u0441\u0430\u0432\u0442\u043e\u043d\u0443\u043c\u0435\u0440\u0430\u0446\u0438\u0438 \u043f\u0440\u043e\u043f\u0438\u0441\u044c \u043f\u0443\u0441\u0442\u043e\u0435\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0440\u0430\u0437\u043c \u0440\u0430\u0437\u043e\u0431\u0440\u0430\u0442\u044c\u043f\u043e\u0437\u0438\u0446\u0438\u044e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0440\u0430\u0441\u0441\u0447\u0438\u0442\u0430\u0442\u044c\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u044b\u043d\u0430 \u0440\u0430\u0441\u0441\u0447\u0438\u0442\u0430\u0442\u044c\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u044b\u043f\u043e \u0441\u0438\u043c\u0432 \u0441\u043e\u0437\u0434\u0430\u0442\u044c\u043e\u0431\u044a\u0435\u043a\u0442 \u0441\u0442\u0430\u0442\u0443\u0441\u0432\u043e\u0437\u0432\u0440\u0430\u0442\u0430 \u0441\u0442\u0440\u043a\u043e\u043b\u0438\u0447\u0435\u0441\u0442\u0432\u043e\u0441\u0442\u0440\u043e\u043a \u0441\u0444\u043e\u0440\u043c\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u043f\u043e\u0437\u0438\u0446\u0438\u044e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0441\u0447\u0435\u0442\u043f\u043e\u043a\u043e\u0434\u0443 \u0442\u0435\u043a\u0443\u0449\u0435\u0435\u0432\u0440\u0435\u043c\u044f \u0442\u0438\u043f\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f \u0442\u0438\u043f\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f\u0441\u0442\u0440 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0442\u0430\u043d\u0430 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0442\u0430\u043f\u043e \u0444\u0438\u043a\u0441\u0448\u0430\u0431\u043b\u043e\u043d \u0448\u0430\u0431\u043b\u043e\u043d acos asin atan base64\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 base64\u0441\u0442\u0440\u043e\u043a\u0430 cos exp log log10 pow sin sqrt tan xml\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 xml\u0441\u0442\u0440\u043e\u043a\u0430 xml\u0442\u0438\u043f xml\u0442\u0438\u043f\u0437\u043d\u0447 \u0430\u043a\u0442\u0438\u0432\u043d\u043e\u0435\u043e\u043a\u043d\u043e \u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u044b\u0439\u0440\u0435\u0436\u0438\u043c \u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u044b\u0439\u0440\u0435\u0436\u0438\u043c\u0440\u0430\u0437\u0434\u0435\u043b\u0435\u043d\u0438\u044f\u0434\u0430\u043d\u043d\u044b\u0445 \u0431\u0443\u043b\u0435\u0432\u043e \u0432\u0432\u0435\u0441\u0442\u0438\u0434\u0430\u0442\u0443 \u0432\u0432\u0435\u0441\u0442\u0438\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0432\u0432\u0435\u0441\u0442\u0438\u0441\u0442\u0440\u043e\u043a\u0443 \u0432\u0432\u0435\u0441\u0442\u0438\u0447\u0438\u0441\u043b\u043e \u0432\u043e\u0437\u043c\u043e\u0436\u043d\u043e\u0441\u0442\u044c\u0447\u0442\u0435\u043d\u0438\u044fxml \u0432\u043e\u043f\u0440\u043e\u0441 \u0432\u043e\u0441\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0432\u0440\u0435\u0433 \u0432\u044b\u0433\u0440\u0443\u0437\u0438\u0442\u044c\u0436\u0443\u0440\u043d\u0430\u043b\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0443\u043e\u043f\u043e\u0432\u0435\u0449\u0435\u043d\u0438\u044f \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c\u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0443\u043f\u0440\u0430\u0432\u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u0432\u044b\u0447\u0438\u0441\u043b\u0438\u0442\u044c \u0433\u043e\u0434 \u0434\u0430\u043d\u043d\u044b\u0435\u0444\u043e\u0440\u043c\u044b\u0432\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0434\u0430\u0442\u0430 \u0434\u0435\u043d\u044c \u0434\u0435\u043d\u044c\u0433\u043e\u0434\u0430 \u0434\u0435\u043d\u044c\u043d\u0435\u0434\u0435\u043b\u0438 \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c\u043c\u0435\u0441\u044f\u0446 \u0437\u0430\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0434\u0430\u043d\u043d\u044b\u0435\u0434\u043b\u044f\u0440\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u0437\u0430\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0440\u0430\u0431\u043e\u0442\u0443\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u0437\u0430\u0432\u0435\u0440\u0448\u0438\u0442\u044c\u0440\u0430\u0431\u043e\u0442\u0443\u0441\u0438\u0441\u0442\u0435\u043c\u044b \u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044c\u0432\u043d\u0435\u0448\u043d\u044e\u044e\u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0443 \u0437\u0430\u043a\u0440\u044b\u0442\u044c\u0441\u043f\u0440\u0430\u0432\u043a\u0443 \u0437\u0430\u043f\u0438\u0441\u0430\u0442\u044cjson \u0437\u0430\u043f\u0438\u0441\u0430\u0442\u044cxml \u0437\u0430\u043f\u0438\u0441\u0430\u0442\u044c\u0434\u0430\u0442\u0443json \u0437\u0430\u043f\u0438\u0441\u044c\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u0437\u0430\u043f\u043e\u043b\u043d\u0438\u0442\u044c\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f\u0441\u0432\u043e\u0439\u0441\u0442\u0432 \u0437\u0430\u043f\u0440\u043e\u0441\u0438\u0442\u044c\u0440\u0430\u0437\u0440\u0435\u0448\u0435\u043d\u0438\u0435\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u0437\u0430\u043f\u0443\u0441\u0442\u0438\u0442\u044c\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u0437\u0430\u043f\u0443\u0441\u0442\u0438\u0442\u044c\u0441\u0438\u0441\u0442\u0435\u043c\u0443 \u0437\u0430\u0444\u0438\u043a\u0441\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0442\u0440\u0430\u043d\u0437\u0430\u043a\u0446\u0438\u044e \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0432\u0434\u0430\u043d\u043d\u044b\u0435\u0444\u043e\u0440\u043c\u044b \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0432\u0441\u0442\u0440\u043e\u043a\u0443\u0432\u043d\u0443\u0442\u0440 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0432\u0444\u0430\u0439\u043b \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0437\u0430\u043f\u043e\u043b\u043d\u0435\u043d\u043e \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0438\u0437\u0441\u0442\u0440\u043e\u043a\u0438\u0432\u043d\u0443\u0442\u0440 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0438\u0437\u0444\u0430\u0439\u043b\u0430 \u0438\u0437xml\u0442\u0438\u043f\u0430 \u0438\u043c\u043f\u043e\u0440\u0442\u043c\u043e\u0434\u0435\u043b\u0438xdto \u0438\u043c\u044f\u043a\u043e\u043c\u043f\u044c\u044e\u0442\u0435\u0440\u0430 \u0438\u043c\u044f\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u0438\u043d\u0438\u0446\u0438\u0430\u043b\u0438\u0437\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u043f\u0440\u0435\u0434\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u043d\u044b\u0435\u0434\u0430\u043d\u043d\u044b\u0435 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f\u043e\u0431\u043e\u0448\u0438\u0431\u043a\u0435 \u043a\u0430\u0442\u0430\u043b\u043e\u0433\u0431\u0438\u0431\u043b\u0438\u043e\u0442\u0435\u043a\u0438\u043c\u043e\u0431\u0438\u043b\u044c\u043d\u043e\u0433\u043e\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u043a\u0430\u0442\u0430\u043b\u043e\u0433\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u044b\u0445\u0444\u0430\u0439\u043b\u043e\u0432 \u043a\u0430\u0442\u0430\u043b\u043e\u0433\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u043e\u0432 \u043a\u0430\u0442\u0430\u043b\u043e\u0433\u043f\u0440\u043e\u0433\u0440\u0430\u043c\u043c\u044b \u043a\u043e\u0434\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0441\u0442\u0440\u043e\u043a\u0443 \u043a\u043e\u0434\u043b\u043e\u043a\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u0438\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u043a\u043e\u0434\u0441\u0438\u043c\u0432\u043e\u043b\u0430 \u043a\u043e\u043c\u0430\u043d\u0434\u0430\u0441\u0438\u0441\u0442\u0435\u043c\u044b \u043a\u043e\u043d\u0435\u0446\u0433\u043e\u0434\u0430 \u043a\u043e\u043d\u0435\u0446\u0434\u043d\u044f \u043a\u043e\u043d\u0435\u0446\u043a\u0432\u0430\u0440\u0442\u0430\u043b\u0430 \u043a\u043e\u043d\u0435\u0446\u043c\u0435\u0441\u044f\u0446\u0430 \u043a\u043e\u043d\u0435\u0446\u043c\u0438\u043d\u0443\u0442\u044b \u043a\u043e\u043d\u0435\u0446\u043d\u0435\u0434\u0435\u043b\u0438 \u043a\u043e\u043d\u0435\u0446\u0447\u0430\u0441\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f\u0431\u0430\u0437\u044b\u0434\u0430\u043d\u043d\u044b\u0445\u0438\u0437\u043c\u0435\u043d\u0435\u043d\u0430\u0434\u0438\u043d\u0430\u043c\u0438\u0447\u0435\u0441\u043a\u0438 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f\u0438\u0437\u043c\u0435\u043d\u0435\u043d\u0430 \u043a\u043e\u043f\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0434\u0430\u043d\u043d\u044b\u0435\u0444\u043e\u0440\u043c\u044b \u043a\u043e\u043f\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0444\u0430\u0439\u043b \u043a\u0440\u0430\u0442\u043a\u043e\u0435\u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043e\u0448\u0438\u0431\u043a\u0438 \u043b\u0435\u0432 \u043c\u0430\u043a\u0441 \u043c\u0435\u0441\u0442\u043d\u043e\u0435\u0432\u0440\u0435\u043c\u044f \u043c\u0435\u0441\u044f\u0446 \u043c\u0438\u043d \u043c\u0438\u043d\u0443\u0442\u0430 \u043c\u043e\u043d\u043e\u043f\u043e\u043b\u044c\u043d\u044b\u0439\u0440\u0435\u0436\u0438\u043c \u043d\u0430\u0439\u0442\u0438 \u043d\u0430\u0439\u0442\u0438\u043d\u0435\u0434\u043e\u043f\u0443\u0441\u0442\u0438\u043c\u044b\u0435\u0441\u0438\u043c\u0432\u043e\u043b\u044bxml \u043d\u0430\u0439\u0442\u0438\u043e\u043a\u043d\u043e\u043f\u043e\u043d\u0430\u0432\u0438\u0433\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0441\u0441\u044b\u043b\u043a\u0435 \u043d\u0430\u0439\u0442\u0438\u043f\u043e\u043c\u0435\u0447\u0435\u043d\u043d\u044b\u0435\u043d\u0430\u0443\u0434\u0430\u043b\u0435\u043d\u0438\u0435 \u043d\u0430\u0439\u0442\u0438\u043f\u043e\u0441\u0441\u044b\u043b\u043a\u0430\u043c \u043d\u0430\u0439\u0442\u0438\u0444\u0430\u0439\u043b\u044b \u043d\u0430\u0447\u0430\u043b\u043e\u0433\u043e\u0434\u0430 \u043d\u0430\u0447\u0430\u043b\u043e\u0434\u043d\u044f \u043d\u0430\u0447\u0430\u043b\u043e\u043a\u0432\u0430\u0440\u0442\u0430\u043b\u0430 \u043d\u0430\u0447\u0430\u043b\u043e\u043c\u0435\u0441\u044f\u0446\u0430 \u043d\u0430\u0447\u0430\u043b\u043e\u043c\u0438\u043d\u0443\u0442\u044b \u043d\u0430\u0447\u0430\u043b\u043e\u043d\u0435\u0434\u0435\u043b\u0438 \u043d\u0430\u0447\u0430\u043b\u043e\u0447\u0430\u0441\u0430 \u043d\u0430\u0447\u0430\u0442\u044c\u0437\u0430\u043f\u0440\u043e\u0441\u0440\u0430\u0437\u0440\u0435\u0448\u0435\u043d\u0438\u044f\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u043d\u0430\u0447\u0430\u0442\u044c\u0437\u0430\u043f\u0443\u0441\u043a\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u043d\u0430\u0447\u0430\u0442\u044c\u043a\u043e\u043f\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435\u0444\u0430\u0439\u043b\u0430 \u043d\u0430\u0447\u0430\u0442\u044c\u043f\u0435\u0440\u0435\u043c\u0435\u0449\u0435\u043d\u0438\u0435\u0444\u0430\u0439\u043b\u0430 \u043d\u0430\u0447\u0430\u0442\u044c\u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435\u0432\u043d\u0435\u0448\u043d\u0435\u0439\u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u044b \u043d\u0430\u0447\u0430\u0442\u044c\u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u044f\u0440\u0430\u0431\u043e\u0442\u044b\u0441\u043a\u0440\u0438\u043f\u0442\u043e\u0433\u0440\u0430\u0444\u0438\u0435\u0439 \u043d\u0430\u0447\u0430\u0442\u044c\u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u044f\u0440\u0430\u0431\u043e\u0442\u044b\u0441\u0444\u0430\u0439\u043b\u0430\u043c\u0438 \u043d\u0430\u0447\u0430\u0442\u044c\u043f\u043e\u0438\u0441\u043a\u0444\u0430\u0439\u043b\u043e\u0432 \u043d\u0430\u0447\u0430\u0442\u044c\u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u0435\u043a\u0430\u0442\u0430\u043b\u043e\u0433\u0430\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u044b\u0445\u0444\u0430\u0439\u043b\u043e\u0432 \u043d\u0430\u0447\u0430\u0442\u044c\u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u0435\u043a\u0430\u0442\u0430\u043b\u043e\u0433\u0430\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u043e\u0432 \u043d\u0430\u0447\u0430\u0442\u044c\u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u0435\u0440\u0430\u0431\u043e\u0447\u0435\u0433\u043e\u043a\u0430\u0442\u0430\u043b\u043e\u0433\u0430\u0434\u0430\u043d\u043d\u044b\u0445\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u043d\u0430\u0447\u0430\u0442\u044c\u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u0435\u0444\u0430\u0439\u043b\u043e\u0432 \u043d\u0430\u0447\u0430\u0442\u044c\u043f\u043e\u043c\u0435\u0449\u0435\u043d\u0438\u0435\u0444\u0430\u0439\u043b\u0430 \u043d\u0430\u0447\u0430\u0442\u044c\u043f\u043e\u043c\u0435\u0449\u0435\u043d\u0438\u0435\u0444\u0430\u0439\u043b\u043e\u0432 \u043d\u0430\u0447\u0430\u0442\u044c\u0441\u043e\u0437\u0434\u0430\u043d\u0438\u0435\u0434\u0432\u043e\u0438\u0447\u043d\u044b\u0445\u0434\u0430\u043d\u043d\u044b\u0445\u0438\u0437\u0444\u0430\u0439\u043b\u0430 \u043d\u0430\u0447\u0430\u0442\u044c\u0441\u043e\u0437\u0434\u0430\u043d\u0438\u0435\u043a\u0430\u0442\u0430\u043b\u043e\u0433\u0430 \u043d\u0430\u0447\u0430\u0442\u044c\u0442\u0440\u0430\u043d\u0437\u0430\u043a\u0446\u0438\u044e \u043d\u0430\u0447\u0430\u0442\u044c\u0443\u0434\u0430\u043b\u0435\u043d\u0438\u0435\u0444\u0430\u0439\u043b\u043e\u0432 \u043d\u0430\u0447\u0430\u0442\u044c\u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0443\u0432\u043d\u0435\u0448\u043d\u0435\u0439\u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u044b \u043d\u0430\u0447\u0430\u0442\u044c\u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0443\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u044f\u0440\u0430\u0431\u043e\u0442\u044b\u0441\u043a\u0440\u0438\u043f\u0442\u043e\u0433\u0440\u0430\u0444\u0438\u0435\u0439 \u043d\u0430\u0447\u0430\u0442\u044c\u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0443\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u044f\u0440\u0430\u0431\u043e\u0442\u044b\u0441\u0444\u0430\u0439\u043b\u0430\u043c\u0438 \u043d\u0435\u0434\u0435\u043b\u044f\u0433\u043e\u0434\u0430 \u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e\u0441\u0442\u044c\u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u0438\u044f\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f \u043d\u043e\u043c\u0435\u0440\u0441\u0435\u0430\u043d\u0441\u0430\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u043d\u043e\u043c\u0435\u0440\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u043d\u0440\u0435\u0433 \u043d\u0441\u0442\u0440 \u043e\u0431\u043d\u043e\u0432\u0438\u0442\u044c\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441 \u043e\u0431\u043d\u043e\u0432\u0438\u0442\u044c\u043d\u0443\u043c\u0435\u0440\u0430\u0446\u0438\u044e\u043e\u0431\u044a\u0435\u043a\u0442\u043e\u0432 \u043e\u0431\u043d\u043e\u0432\u0438\u0442\u044c\u043f\u043e\u0432\u0442\u043e\u0440\u043d\u043e\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u043c\u044b\u0435\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0430\u043f\u0440\u0435\u0440\u044b\u0432\u0430\u043d\u0438\u044f\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u043e\u0431\u044a\u0435\u0434\u0438\u043d\u0438\u0442\u044c\u0444\u0430\u0439\u043b\u044b \u043e\u043a\u0440 \u043e\u043f\u0438\u0441\u0430\u043d\u0438\u0435\u043e\u0448\u0438\u0431\u043a\u0438 \u043e\u043f\u043e\u0432\u0435\u0441\u0442\u0438\u0442\u044c \u043e\u043f\u043e\u0432\u0435\u0441\u0442\u0438\u0442\u044c\u043e\u0431\u0438\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u0438 \u043e\u0442\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u0437\u0430\u043f\u0440\u043e\u0441\u0430\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a\u043a\u043b\u0438\u0435\u043d\u0442\u0430\u043b\u0438\u0446\u0435\u043d\u0437\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u043e\u0442\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u043e\u0436\u0438\u0434\u0430\u043d\u0438\u044f \u043e\u0442\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u043e\u043f\u043e\u0432\u0435\u0449\u0435\u043d\u0438\u044f \u043e\u0442\u043a\u0440\u044b\u0442\u044c\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043e\u0442\u043a\u0440\u044b\u0442\u044c\u0438\u043d\u0434\u0435\u043a\u0441\u0441\u043f\u0440\u0430\u0432\u043a\u0438 \u043e\u0442\u043a\u0440\u044b\u0442\u044c\u0441\u043e\u0434\u0435\u0440\u0436\u0430\u043d\u0438\u0435\u0441\u043f\u0440\u0430\u0432\u043a\u0438 \u043e\u0442\u043a\u0440\u044b\u0442\u044c\u0441\u043f\u0440\u0430\u0432\u043a\u0443 \u043e\u0442\u043a\u0440\u044b\u0442\u044c\u0444\u043e\u0440\u043c\u0443 \u043e\u0442\u043a\u0440\u044b\u0442\u044c\u0444\u043e\u0440\u043c\u0443\u043c\u043e\u0434\u0430\u043b\u044c\u043d\u043e \u043e\u0442\u043c\u0435\u043d\u0438\u0442\u044c\u0442\u0440\u0430\u043d\u0437\u0430\u043a\u0446\u0438\u044e \u043e\u0447\u0438\u0441\u0442\u0438\u0442\u044c\u0436\u0443\u0440\u043d\u0430\u043b\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u043e\u0447\u0438\u0441\u0442\u0438\u0442\u044c\u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u043e\u0447\u0438\u0441\u0442\u0438\u0442\u044c\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b\u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u043f\u0435\u0440\u0435\u0439\u0442\u0438\u043f\u043e\u043d\u0430\u0432\u0438\u0433\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0441\u0441\u044b\u043b\u043a\u0435 \u043f\u0435\u0440\u0435\u043c\u0435\u0441\u0442\u0438\u0442\u044c\u0444\u0430\u0439\u043b \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0432\u043d\u0435\u0448\u043d\u044e\u044e\u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0443 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u0437\u0430\u043f\u0440\u043e\u0441\u0430\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a\u043a\u043b\u0438\u0435\u043d\u0442\u0430\u043b\u0438\u0446\u0435\u043d\u0437\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u043e\u0436\u0438\u0434\u0430\u043d\u0438\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u043e\u043f\u043e\u0432\u0435\u0449\u0435\u043d\u0438\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0435\u0440\u0430\u0431\u043e\u0442\u044b\u0441\u043a\u0440\u0438\u043f\u0442\u043e\u0433\u0440\u0430\u0444\u0438\u0435\u0439 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0435\u0440\u0430\u0431\u043e\u0442\u044b\u0441\u0444\u0430\u0439\u043b\u0430\u043c\u0438 \u043f\u043e\u0434\u0440\u043e\u0431\u043d\u043e\u0435\u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043e\u0448\u0438\u0431\u043a\u0438 \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c\u0432\u0432\u043e\u0434\u0434\u0430\u0442\u044b \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c\u0432\u0432\u043e\u0434\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c\u0432\u0432\u043e\u0434\u0441\u0442\u0440\u043e\u043a\u0438 \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c\u0432\u0432\u043e\u0434\u0447\u0438\u0441\u043b\u0430 \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c\u0432\u043e\u043f\u0440\u043e\u0441 \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e\u043e\u0431\u043e\u0448\u0438\u0431\u043a\u0435 \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c\u043d\u0430\u043a\u0430\u0440\u0442\u0435 \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c\u043e\u043f\u043e\u0432\u0435\u0449\u0435\u043d\u0438\u0435\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c\u043f\u0440\u0435\u0434\u0443\u043f\u0440\u0435\u0436\u0434\u0435\u043d\u0438\u0435 \u043f\u043e\u043b\u043d\u043e\u0435\u0438\u043c\u044f\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044ccom\u043e\u0431\u044a\u0435\u043a\u0442 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044cxml\u0442\u0438\u043f \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0430\u0434\u0440\u0435\u0441\u043f\u043e\u043c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u044e \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u043a\u0443\u0441\u0435\u0430\u043d\u0441\u043e\u0432 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0432\u0440\u0435\u043c\u044f\u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u0438\u044f\u0441\u043f\u044f\u0449\u0435\u0433\u043e\u0441\u0435\u0430\u043d\u0441\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0432\u0440\u0435\u043c\u044f\u0437\u0430\u0441\u044b\u043f\u0430\u043d\u0438\u044f\u043f\u0430\u0441\u0441\u0438\u0432\u043d\u043e\u0433\u043e\u0441\u0435\u0430\u043d\u0441\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0432\u0440\u0435\u043c\u044f\u043e\u0436\u0438\u0434\u0430\u043d\u0438\u044f\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0434\u0430\u043d\u043d\u044b\u0435\u0432\u044b\u0431\u043e\u0440\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0434\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0439\u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u043a\u043b\u0438\u0435\u043d\u0442\u0430\u043b\u0438\u0446\u0435\u043d\u0437\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0434\u043e\u043f\u0443\u0441\u0442\u0438\u043c\u044b\u0435\u043a\u043e\u0434\u044b\u043b\u043e\u043a\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u0438 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0434\u043e\u043f\u0443\u0441\u0442\u0438\u043c\u044b\u0435\u0447\u0430\u0441\u043e\u0432\u044b\u0435\u043f\u043e\u044f\u0441\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043e\u043a\u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u043e\u0433\u043e\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043e\u043a\u0441\u0438\u0441\u0442\u0435\u043c\u044b \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f\u043e\u0442\u0431\u043e\u0440\u0430\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0438\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0438\u0437\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u043e\u0433\u043e\u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0438\u043c\u044f\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u043e\u0433\u043e\u0444\u0430\u0439\u043b\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0438\u043c\u044f\u043a\u043b\u0438\u0435\u043d\u0442\u0430\u043b\u0438\u0446\u0435\u043d\u0437\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e\u044d\u043a\u0440\u0430\u043d\u043e\u0432\u043a\u043b\u0438\u0435\u043d\u0442\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0441\u043e\u0431\u044b\u0442\u0438\u044f\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043a\u0440\u0430\u0442\u043a\u0438\u0439\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043e\u043a\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043c\u0430\u043a\u0435\u0442\u043e\u0444\u043e\u0440\u043c\u043b\u0435\u043d\u0438\u044f \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043c\u0430\u0441\u043a\u0443\u0432\u0441\u0435\u0444\u0430\u0439\u043b\u044b \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043c\u0430\u0441\u043a\u0443\u0432\u0441\u0435\u0444\u0430\u0439\u043b\u044b\u043a\u043b\u0438\u0435\u043d\u0442\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043c\u0430\u0441\u043a\u0443\u0432\u0441\u0435\u0444\u0430\u0439\u043b\u044b\u0441\u0435\u0440\u0432\u0435\u0440\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043f\u043e\u0430\u0434\u0440\u0435\u0441\u0443 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043c\u0438\u043d\u0438\u043c\u0430\u043b\u044c\u043d\u0443\u044e\u0434\u043b\u0438\u043d\u0443\u043f\u0430\u0440\u043e\u043b\u0435\u0439\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0435\u0439 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043d\u0430\u0432\u0438\u0433\u0430\u0446\u0438\u043e\u043d\u043d\u0443\u044e\u0441\u0441\u044b\u043b\u043a\u0443 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043d\u0430\u0432\u0438\u0433\u0430\u0446\u0438\u043e\u043d\u043d\u0443\u044e\u0441\u0441\u044b\u043b\u043a\u0443\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438\u0431\u0430\u0437\u044b\u0434\u0430\u043d\u043d\u044b\u0445 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u0440\u0435\u0434\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u043d\u044b\u0445\u0434\u0430\u043d\u043d\u044b\u0445\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043e\u0431\u0449\u0438\u0439\u043c\u0430\u043a\u0435\u0442 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043e\u0431\u0449\u0443\u044e\u0444\u043e\u0440\u043c\u0443 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043e\u043a\u043d\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043e\u043f\u0435\u0440\u0430\u0442\u0438\u0432\u043d\u0443\u044e\u043e\u0442\u043c\u0435\u0442\u043a\u0443\u0432\u0440\u0435\u043c\u0435\u043d\u0438 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043e\u0442\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435\u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u043e\u0433\u043e\u0440\u0435\u0436\u0438\u043c\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b\u0444\u0443\u043d\u043a\u0446\u0438\u043e\u043d\u0430\u043b\u044c\u043d\u044b\u0445\u043e\u043f\u0446\u0438\u0439\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043f\u043e\u043b\u043d\u043e\u0435\u0438\u043c\u044f\u043f\u0440\u0435\u0434\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u043d\u043e\u0433\u043e\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u044f\u043d\u0430\u0432\u0438\u0433\u0430\u0446\u0438\u043e\u043d\u043d\u044b\u0445\u0441\u0441\u044b\u043b\u043e\u043a \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0443\u0441\u043b\u043e\u0436\u043d\u043e\u0441\u0442\u0438\u043f\u0430\u0440\u043e\u043b\u0435\u0439\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0435\u0439 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0440\u0430\u0437\u0434\u0435\u043b\u0438\u0442\u0435\u043b\u044c\u043f\u0443\u0442\u0438 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0440\u0430\u0437\u0434\u0435\u043b\u0438\u0442\u0435\u043b\u044c\u043f\u0443\u0442\u0438\u043a\u043b\u0438\u0435\u043d\u0442\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0440\u0430\u0437\u0434\u0435\u043b\u0438\u0442\u0435\u043b\u044c\u043f\u0443\u0442\u0438\u0441\u0435\u0440\u0432\u0435\u0440\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0441\u0435\u0430\u043d\u0441\u044b\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0441\u043a\u043e\u0440\u043e\u0441\u0442\u044c\u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u043e\u0433\u043e\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044e \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0441\u043e\u043e\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0438\u0435\u043e\u0431\u044a\u0435\u043a\u0442\u0430\u0438\u0444\u043e\u0440\u043c\u044b \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0441\u043e\u0441\u0442\u0430\u0432\u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u043e\u0433\u043e\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430odata \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0441\u0442\u0440\u0443\u043a\u0442\u0443\u0440\u0443\u0445\u0440\u0430\u043d\u0435\u043d\u0438\u044f\u0431\u0430\u0437\u044b\u0434\u0430\u043d\u043d\u044b\u0445 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0442\u0435\u043a\u0443\u0449\u0438\u0439\u0441\u0435\u0430\u043d\u0441\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0444\u0430\u0439\u043b \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0444\u0430\u0439\u043b\u044b \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0444\u043e\u0440\u043c\u0443 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0444\u0443\u043d\u043a\u0446\u0438\u043e\u043d\u0430\u043b\u044c\u043d\u0443\u044e\u043e\u043f\u0446\u0438\u044e \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0444\u0443\u043d\u043a\u0446\u0438\u043e\u043d\u0430\u043b\u044c\u043d\u0443\u044e\u043e\u043f\u0446\u0438\u044e\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0447\u0430\u0441\u043e\u0432\u043e\u0439\u043f\u043e\u044f\u0441\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0438\u043e\u0441 \u043f\u043e\u043c\u0435\u0441\u0442\u0438\u0442\u044c\u0432\u043e\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u043e\u0435\u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0435 \u043f\u043e\u043c\u0435\u0441\u0442\u0438\u0442\u044c\u0444\u0430\u0439\u043b \u043f\u043e\u043c\u0435\u0441\u0442\u0438\u0442\u044c\u0444\u0430\u0439\u043b\u044b \u043f\u0440\u0430\u0432 \u043f\u0440\u0430\u0432\u043e\u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u043f\u0440\u0435\u0434\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u043d\u043e\u0435\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043a\u043e\u0434\u0430\u043b\u043e\u043a\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u0438 \u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u0435\u0440\u0438\u043e\u0434\u0430 \u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u0440\u0430\u0432\u0430 \u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u0441\u043e\u0431\u044b\u0442\u0438\u044f\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u0447\u0430\u0441\u043e\u0432\u043e\u0433\u043e\u043f\u043e\u044f\u0441\u0430 \u043f\u0440\u0435\u0434\u0443\u043f\u0440\u0435\u0436\u0434\u0435\u043d\u0438\u0435 \u043f\u0440\u0435\u043a\u0440\u0430\u0442\u0438\u0442\u044c\u0440\u0430\u0431\u043e\u0442\u0443\u0441\u0438\u0441\u0442\u0435\u043c\u044b \u043f\u0440\u0438\u0432\u0438\u043b\u0435\u0433\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u044b\u0439\u0440\u0435\u0436\u0438\u043c \u043f\u0440\u043e\u0434\u043e\u043b\u0436\u0438\u0442\u044c\u0432\u044b\u0437\u043e\u0432 \u043f\u0440\u043e\u0447\u0438\u0442\u0430\u0442\u044cjson \u043f\u0440\u043e\u0447\u0438\u0442\u0430\u0442\u044cxml \u043f\u0440\u043e\u0447\u0438\u0442\u0430\u0442\u044c\u0434\u0430\u0442\u0443json \u043f\u0443\u0441\u0442\u0430\u044f\u0441\u0442\u0440\u043e\u043a\u0430 \u0440\u0430\u0431\u043e\u0447\u0438\u0439\u043a\u0430\u0442\u0430\u043b\u043e\u0433\u0434\u0430\u043d\u043d\u044b\u0445\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u0440\u0430\u0437\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0434\u0430\u043d\u043d\u044b\u0435\u0434\u043b\u044f\u0440\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u0440\u0430\u0437\u0434\u0435\u043b\u0438\u0442\u044c\u0444\u0430\u0439\u043b \u0440\u0430\u0437\u043e\u0440\u0432\u0430\u0442\u044c\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0435\u0441\u0432\u043d\u0435\u0448\u043d\u0438\u043c\u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u043e\u043c\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0430\u0441\u043a\u043e\u0434\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0441\u0442\u0440\u043e\u043a\u0443 \u0440\u043e\u043b\u044c\u0434\u043e\u0441\u0442\u0443\u043f\u043d\u0430 \u0441\u0435\u043a\u0443\u043d\u0434\u0430 \u0441\u0438\u0433\u043d\u0430\u043b \u0441\u0438\u043c\u0432\u043e\u043b \u0441\u043a\u043e\u043f\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0436\u0443\u0440\u043d\u0430\u043b\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u0441\u043c\u0435\u0449\u0435\u043d\u0438\u0435\u043b\u0435\u0442\u043d\u0435\u0433\u043e\u0432\u0440\u0435\u043c\u0435\u043d\u0438 \u0441\u043c\u0435\u0449\u0435\u043d\u0438\u0435\u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u043e\u0433\u043e\u0432\u0440\u0435\u043c\u0435\u043d\u0438 \u0441\u043e\u0435\u0434\u0438\u043d\u0438\u0442\u044c\u0431\u0443\u0444\u0435\u0440\u044b\u0434\u0432\u043e\u0438\u0447\u043d\u044b\u0445\u0434\u0430\u043d\u043d\u044b\u0445 \u0441\u043e\u0437\u0434\u0430\u0442\u044c\u043a\u0430\u0442\u0430\u043b\u043e\u0433 \u0441\u043e\u0437\u0434\u0430\u0442\u044c\u0444\u0430\u0431\u0440\u0438\u043a\u0443xdto \u0441\u043e\u043a\u0440\u043b \u0441\u043e\u043a\u0440\u043b\u043f \u0441\u043e\u043a\u0440\u043f \u0441\u043e\u043e\u0431\u0449\u0438\u0442\u044c \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0435 \u0441\u043e\u0445\u0440\u0430\u043d\u0438\u0442\u044c\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0441\u043e\u0445\u0440\u0430\u043d\u0438\u0442\u044c\u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u0441\u0440\u0435\u0434 \u0441\u0442\u0440\u0434\u043b\u0438\u043d\u0430 \u0441\u0442\u0440\u0437\u0430\u043a\u0430\u043d\u0447\u0438\u0432\u0430\u0435\u0442\u0441\u044f\u043d\u0430 \u0441\u0442\u0440\u0437\u0430\u043c\u0435\u043d\u0438\u0442\u044c \u0441\u0442\u0440\u043d\u0430\u0439\u0442\u0438 \u0441\u0442\u0440\u043d\u0430\u0447\u0438\u043d\u0430\u0435\u0442\u0441\u044f\u0441 \u0441\u0442\u0440\u043e\u043a\u0430 \u0441\u0442\u0440\u043e\u043a\u0430\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u0441\u0442\u0440\u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0441\u0442\u0440\u043e\u043a\u0443 \u0441\u0442\u0440\u0440\u0430\u0437\u0434\u0435\u043b\u0438\u0442\u044c \u0441\u0442\u0440\u0441\u043e\u0435\u0434\u0438\u043d\u0438\u0442\u044c \u0441\u0442\u0440\u0441\u0440\u0430\u0432\u043d\u0438\u0442\u044c \u0441\u0442\u0440\u0447\u0438\u0441\u043b\u043e\u0432\u0445\u043e\u0436\u0434\u0435\u043d\u0438\u0439 \u0441\u0442\u0440\u0447\u0438\u0441\u043b\u043e\u0441\u0442\u0440\u043e\u043a \u0441\u0442\u0440\u0448\u0430\u0431\u043b\u043e\u043d \u0442\u0435\u043a\u0443\u0449\u0430\u044f\u0434\u0430\u0442\u0430 \u0442\u0435\u043a\u0443\u0449\u0430\u044f\u0434\u0430\u0442\u0430\u0441\u0435\u0430\u043d\u0441\u0430 \u0442\u0435\u043a\u0443\u0449\u0430\u044f\u0443\u043d\u0438\u0432\u0435\u0440\u0441\u0430\u043b\u044c\u043d\u0430\u044f\u0434\u0430\u0442\u0430 \u0442\u0435\u043a\u0443\u0449\u0430\u044f\u0443\u043d\u0438\u0432\u0435\u0440\u0441\u0430\u043b\u044c\u043d\u0430\u044f\u0434\u0430\u0442\u0430\u0432\u043c\u0438\u043b\u043b\u0438\u0441\u0435\u043a\u0443\u043d\u0434\u0430\u0445 \u0442\u0435\u043a\u0443\u0449\u0438\u0439\u0432\u0430\u0440\u0438\u0430\u043d\u0442\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430\u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u043e\u0433\u043e\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0442\u0435\u043a\u0443\u0449\u0438\u0439\u0432\u0430\u0440\u0438\u0430\u043d\u0442\u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0433\u043e\u0448\u0440\u0438\u0444\u0442\u0430\u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u043e\u0433\u043e\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0442\u0435\u043a\u0443\u0449\u0438\u0439\u043a\u043e\u0434\u043b\u043e\u043a\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u0438 \u0442\u0435\u043a\u0443\u0449\u0438\u0439\u0440\u0435\u0436\u0438\u043c\u0437\u0430\u043f\u0443\u0441\u043a\u0430 \u0442\u0435\u043a\u0443\u0449\u0438\u0439\u044f\u0437\u044b\u043a \u0442\u0435\u043a\u0443\u0449\u0438\u0439\u044f\u0437\u044b\u043a\u0441\u0438\u0441\u0442\u0435\u043c\u044b \u0442\u0438\u043f \u0442\u0438\u043f\u0437\u043d\u0447 \u0442\u0440\u0430\u043d\u0437\u0430\u043a\u0446\u0438\u044f\u0430\u043a\u0442\u0438\u0432\u043d\u0430 \u0442\u0440\u0435\u0433 \u0443\u0434\u0430\u043b\u0438\u0442\u044c\u0434\u0430\u043d\u043d\u044b\u0435\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u0443\u0434\u0430\u043b\u0438\u0442\u044c\u0438\u0437\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u043e\u0433\u043e\u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0430 \u0443\u0434\u0430\u043b\u0438\u0442\u044c\u043e\u0431\u044a\u0435\u043a\u0442\u044b \u0443\u0434\u0430\u043b\u0438\u0442\u044c\u0444\u0430\u0439\u043b\u044b \u0443\u043d\u0438\u0432\u0435\u0440\u0441\u0430\u043b\u044c\u043d\u043e\u0435\u0432\u0440\u0435\u043c\u044f \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u044b\u0439\u0440\u0435\u0436\u0438\u043c \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u044b\u0439\u0440\u0435\u0436\u0438\u043c\u0440\u0430\u0437\u0434\u0435\u043b\u0435\u043d\u0438\u044f\u0434\u0430\u043d\u043d\u044b\u0445 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u043a\u0443\u0441\u0435\u0430\u043d\u0441\u043e\u0432 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0432\u043d\u0435\u0448\u043d\u044e\u044e\u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0443 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0432\u0440\u0435\u043c\u044f\u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u0438\u044f\u0441\u043f\u044f\u0449\u0435\u0433\u043e\u0441\u0435\u0430\u043d\u0441\u0430 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0432\u0440\u0435\u043c\u044f\u0437\u0430\u0441\u044b\u043f\u0430\u043d\u0438\u044f\u043f\u0430\u0441\u0441\u0438\u0432\u043d\u043e\u0433\u043e\u0441\u0435\u0430\u043d\u0441\u0430 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0432\u0440\u0435\u043c\u044f\u043e\u0436\u0438\u0434\u0430\u043d\u0438\u044f\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043e\u043a\u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u043e\u0433\u043e\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043e\u043a\u0441\u0438\u0441\u0442\u0435\u043c\u044b \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0441\u043e\u0431\u044b\u0442\u0438\u044f\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u043a\u0440\u0430\u0442\u043a\u0438\u0439\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043e\u043a\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u043c\u0438\u043d\u0438\u043c\u0430\u043b\u044c\u043d\u0443\u044e\u0434\u043b\u0438\u043d\u0443\u043f\u0430\u0440\u043e\u043b\u0435\u0439\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0435\u0439 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u043c\u043e\u043d\u043e\u043f\u043e\u043b\u044c\u043d\u044b\u0439\u0440\u0435\u0436\u0438\u043c \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438\u043a\u043b\u0438\u0435\u043d\u0442\u0430\u043b\u0438\u0446\u0435\u043d\u0437\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u0440\u0435\u0434\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u043d\u044b\u0445\u0434\u0430\u043d\u043d\u044b\u0445\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u043e\u0442\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435\u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u043e\u0433\u043e\u0440\u0435\u0436\u0438\u043c\u0430 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b\u0444\u0443\u043d\u043a\u0446\u0438\u043e\u043d\u0430\u043b\u044c\u043d\u044b\u0445\u043e\u043f\u0446\u0438\u0439\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u043f\u0440\u0438\u0432\u0438\u043b\u0435\u0433\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u044b\u0439\u0440\u0435\u0436\u0438\u043c \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0443\u0441\u043b\u043e\u0436\u043d\u043e\u0441\u0442\u0438\u043f\u0430\u0440\u043e\u043b\u0435\u0439\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0435\u0439 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0435\u0440\u0430\u0431\u043e\u0442\u044b\u0441\u043a\u0440\u0438\u043f\u0442\u043e\u0433\u0440\u0430\u0444\u0438\u0435\u0439 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0435\u0440\u0430\u0431\u043e\u0442\u044b\u0441\u0444\u0430\u0439\u043b\u0430\u043c\u0438 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0435\u0441\u0432\u043d\u0435\u0448\u043d\u0438\u043c\u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u043e\u043c\u0434\u0430\u043d\u043d\u044b\u0445 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0441\u043e\u043e\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0438\u0435\u043e\u0431\u044a\u0435\u043a\u0442\u0430\u0438\u0444\u043e\u0440\u043c\u044b \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0441\u043e\u0441\u0442\u0430\u0432\u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u043e\u0433\u043e\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430odata \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0447\u0430\u0441\u043e\u0432\u043e\u0439\u043f\u043e\u044f\u0441\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0447\u0430\u0441\u043e\u0432\u043e\u0439\u043f\u043e\u044f\u0441\u0441\u0435\u0430\u043d\u0441\u0430 \u0444\u043e\u0440\u043c\u0430\u0442 \u0446\u0435\u043b \u0447\u0430\u0441 \u0447\u0430\u0441\u043e\u0432\u043e\u0439\u043f\u043e\u044f\u0441 \u0447\u0430\u0441\u043e\u0432\u043e\u0439\u043f\u043e\u044f\u0441\u0441\u0435\u0430\u043d\u0441\u0430 \u0447\u0438\u0441\u043b\u043e \u0447\u0438\u0441\u043b\u043e\u043f\u0440\u043e\u043f\u0438\u0441\u044c\u044e \u044d\u0442\u043e\u0430\u0434\u0440\u0435\u0441\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u043e\u0433\u043e\u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0430 ws\u0441\u0441\u044b\u043b\u043a\u0438 \u0431\u0438\u0431\u043b\u0438\u043e\u0442\u0435\u043a\u0430\u043a\u0430\u0440\u0442\u0438\u043d\u043e\u043a \u0431\u0438\u0431\u043b\u0438\u043e\u0442\u0435\u043a\u0430\u043c\u0430\u043a\u0435\u0442\u043e\u0432\u043e\u0444\u043e\u0440\u043c\u043b\u0435\u043d\u0438\u044f\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0431\u0438\u0431\u043b\u0438\u043e\u0442\u0435\u043a\u0430\u0441\u0442\u0438\u043b\u0435\u0439 \u0431\u0438\u0437\u043d\u0435\u0441\u043f\u0440\u043e\u0446\u0435\u0441\u0441\u044b \u0432\u043d\u0435\u0448\u043d\u0438\u0435\u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0432\u043d\u0435\u0448\u043d\u0438\u0435\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0438 \u0432\u043d\u0435\u0448\u043d\u0438\u0435\u043e\u0442\u0447\u0435\u0442\u044b \u0432\u0441\u0442\u0440\u043e\u0435\u043d\u043d\u044b\u0435\u043f\u043e\u043a\u0443\u043f\u043a\u0438 \u0433\u043b\u0430\u0432\u043d\u044b\u0439\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441 \u0433\u043b\u0430\u0432\u043d\u044b\u0439\u0441\u0442\u0438\u043b\u044c \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u044b \u0434\u043e\u0441\u0442\u0430\u0432\u043b\u044f\u0435\u043c\u044b\u0435\u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u044f \u0436\u0443\u0440\u043d\u0430\u043b\u044b\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u043e\u0432 \u0437\u0430\u0434\u0430\u0447\u0438 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f\u043e\u0431\u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0438 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0440\u0430\u0431\u043e\u0447\u0435\u0439\u0434\u0430\u0442\u044b \u0438\u0441\u0442\u043e\u0440\u0438\u044f\u0440\u0430\u0431\u043e\u0442\u044b\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u043a\u043e\u043d\u0441\u0442\u0430\u043d\u0442\u044b \u043a\u0440\u0438\u0442\u0435\u0440\u0438\u0438\u043e\u0442\u0431\u043e\u0440\u0430 \u043c\u0435\u0442\u0430\u0434\u0430\u043d\u043d\u044b\u0435 \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0438 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0440\u0435\u043a\u043b\u0430\u043c\u044b \u043e\u0442\u043f\u0440\u0430\u0432\u043a\u0430\u0434\u043e\u0441\u0442\u0430\u0432\u043b\u044f\u0435\u043c\u044b\u0445\u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u0439 \u043e\u0442\u0447\u0435\u0442\u044b \u043f\u0430\u043d\u0435\u043b\u044c\u0437\u0430\u0434\u0430\u0447\u043e\u0441 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0437\u0430\u043f\u0443\u0441\u043a\u0430 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b\u0441\u0435\u0430\u043d\u0441\u0430 \u043f\u0435\u0440\u0435\u0447\u0438\u0441\u043b\u0435\u043d\u0438\u044f \u043f\u043b\u0430\u043d\u044b\u0432\u0438\u0434\u043e\u0432\u0440\u0430\u0441\u0447\u0435\u0442\u0430 \u043f\u043b\u0430\u043d\u044b\u0432\u0438\u0434\u043e\u0432\u0445\u0430\u0440\u0430\u043a\u0442\u0435\u0440\u0438\u0441\u0442\u0438\u043a \u043f\u043b\u0430\u043d\u044b\u043e\u0431\u043c\u0435\u043d\u0430 \u043f\u043b\u0430\u043d\u044b\u0441\u0447\u0435\u0442\u043e\u0432 \u043f\u043e\u043b\u043d\u043e\u0442\u0435\u043a\u0441\u0442\u043e\u0432\u044b\u0439\u043f\u043e\u0438\u0441\u043a \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0438\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u043f\u043e\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043d\u043e\u0441\u0442\u0438 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0430\u0432\u0441\u0442\u0440\u043e\u0435\u043d\u043d\u044b\u0445\u043f\u043e\u043a\u0443\u043f\u043e\u043a \u0440\u0430\u0431\u043e\u0447\u0430\u044f\u0434\u0430\u0442\u0430 \u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u044f\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u044b\u0431\u0443\u0445\u0433\u0430\u043b\u0442\u0435\u0440\u0438\u0438 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u044b\u043d\u0430\u043a\u043e\u043f\u043b\u0435\u043d\u0438\u044f \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u044b\u0440\u0430\u0441\u0447\u0435\u0442\u0430 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u044b\u0441\u0432\u0435\u0434\u0435\u043d\u0438\u0439 \u0440\u0435\u0433\u043b\u0430\u043c\u0435\u043d\u0442\u043d\u044b\u0435\u0437\u0430\u0434\u0430\u043d\u0438\u044f \u0441\u0435\u0440\u0438\u0430\u043b\u0438\u0437\u0430\u0442\u043e\u0440xdto \u0441\u043f\u0440\u0430\u0432\u043e\u0447\u043d\u0438\u043a\u0438 \u0441\u0440\u0435\u0434\u0441\u0442\u0432\u0430\u0433\u0435\u043e\u043f\u043e\u0437\u0438\u0446\u0438\u043e\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u0441\u0440\u0435\u0434\u0441\u0442\u0432\u0430\u043a\u0440\u0438\u043f\u0442\u043e\u0433\u0440\u0430\u0444\u0438\u0438 \u0441\u0440\u0435\u0434\u0441\u0442\u0432\u0430\u043c\u0443\u043b\u044c\u0442\u0438\u043c\u0435\u0434\u0438\u0430 \u0441\u0440\u0435\u0434\u0441\u0442\u0432\u0430\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u0440\u0435\u043a\u043b\u0430\u043c\u044b \u0441\u0440\u0435\u0434\u0441\u0442\u0432\u0430\u043f\u043e\u0447\u0442\u044b \u0441\u0440\u0435\u0434\u0441\u0442\u0432\u0430\u0442\u0435\u043b\u0435\u0444\u043e\u043d\u0438\u0438 \u0444\u0430\u0431\u0440\u0438\u043a\u0430xdto \u0444\u0430\u0439\u043b\u043e\u0432\u044b\u0435\u043f\u043e\u0442\u043e\u043a\u0438 \u0444\u043e\u043d\u043e\u0432\u044b\u0435\u0437\u0430\u0434\u0430\u043d\u0438\u044f \u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0430\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a \u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0435\u0432\u0430\u0440\u0438\u0430\u043d\u0442\u043e\u0432\u043e\u0442\u0447\u0435\u0442\u043e\u0432 \u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0435\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a\u0434\u0430\u043d\u043d\u044b\u0445\u0444\u043e\u0440\u043c \u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0435\u043e\u0431\u0449\u0438\u0445\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a \u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0435\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u0441\u043a\u0438\u0445\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a\u0434\u0438\u043d\u0430\u043c\u0438\u0447\u0435\u0441\u043a\u0438\u0445\u0441\u043f\u0438\u0441\u043a\u043e\u0432 \u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0435\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u0441\u043a\u0438\u0445\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a\u043e\u0442\u0447\u0435\u0442\u043e\u0432 \u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0435\u0441\u0438\u0441\u0442\u0435\u043c\u043d\u044b\u0445\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a ", +"class":"web\u0446\u0432\u0435\u0442\u0430 windows\u0446\u0432\u0435\u0442\u0430 windows\u0448\u0440\u0438\u0444\u0442\u044b \u0431\u0438\u0431\u043b\u0438\u043e\u0442\u0435\u043a\u0430\u043a\u0430\u0440\u0442\u0438\u043d\u043e\u043a \u0440\u0430\u043c\u043a\u0438\u0441\u0442\u0438\u043b\u044f \u0441\u0438\u043c\u0432\u043e\u043b\u044b \u0446\u0432\u0435\u0442\u0430\u0441\u0442\u0438\u043b\u044f \u0448\u0440\u0438\u0444\u0442\u044b\u0441\u0442\u0438\u043b\u044f \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u043e\u0435\u0441\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u0438\u0435\u0434\u0430\u043d\u043d\u044b\u0445\u0444\u043e\u0440\u043c\u044b\u0432\u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430\u0445 \u0430\u0432\u0442\u043e\u043d\u0443\u043c\u0435\u0440\u0430\u0446\u0438\u044f\u0432\u0444\u043e\u0440\u043c\u0435 \u0430\u0432\u0442\u043e\u0440\u0430\u0437\u0434\u0432\u0438\u0436\u0435\u043d\u0438\u0435\u0441\u0435\u0440\u0438\u0439 \u0430\u043d\u0438\u043c\u0430\u0446\u0438\u044f\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u0432\u044b\u0440\u0430\u0432\u043d\u0438\u0432\u0430\u043d\u0438\u044f\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u043e\u0432\u0438\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043a\u043e\u0432 \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u044f\u0432\u044b\u0441\u043e\u0442\u043e\u0439\u0442\u0430\u0431\u043b\u0438\u0446\u044b \u0432\u0435\u0440\u0442\u0438\u043a\u0430\u043b\u044c\u043d\u0430\u044f\u043f\u0440\u043e\u043a\u0440\u0443\u0442\u043a\u0430\u0444\u043e\u0440\u043c\u044b \u0432\u0435\u0440\u0442\u0438\u043a\u0430\u043b\u044c\u043d\u043e\u0435\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u0432\u0435\u0440\u0442\u0438\u043a\u0430\u043b\u044c\u043d\u043e\u0435\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430 \u0432\u0438\u0434\u0433\u0440\u0443\u043f\u043f\u044b\u0444\u043e\u0440\u043c\u044b \u0432\u0438\u0434\u0434\u0435\u043a\u043e\u0440\u0430\u0446\u0438\u0438\u0444\u043e\u0440\u043c\u044b \u0432\u0438\u0434\u0434\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u0444\u043e\u0440\u043c\u044b \u0432\u0438\u0434\u0438\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u044f\u0434\u0430\u043d\u043d\u044b\u0445 \u0432\u0438\u0434\u043a\u043d\u043e\u043f\u043a\u0438\u0444\u043e\u0440\u043c\u044b \u0432\u0438\u0434\u043f\u0435\u0440\u0435\u043a\u043b\u044e\u0447\u0430\u0442\u0435\u043b\u044f \u0432\u0438\u0434\u043f\u043e\u0434\u043f\u0438\u0441\u0435\u0439\u043a\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u0435 \u0432\u0438\u0434\u043f\u043e\u043b\u044f\u0444\u043e\u0440\u043c\u044b \u0432\u0438\u0434\u0444\u043b\u0430\u0436\u043a\u0430 \u0432\u043b\u0438\u044f\u043d\u0438\u0435\u0440\u0430\u0437\u043c\u0435\u0440\u0430\u043d\u0430\u043f\u0443\u0437\u044b\u0440\u0435\u043a\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0433\u043e\u0440\u0438\u0437\u043e\u043d\u0442\u0430\u043b\u044c\u043d\u043e\u0435\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u0433\u043e\u0440\u0438\u0437\u043e\u043d\u0442\u0430\u043b\u044c\u043d\u043e\u0435\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430 \u0433\u0440\u0443\u043f\u043f\u0438\u0440\u043e\u0432\u043a\u0430\u043a\u043e\u043b\u043e\u043d\u043e\u043a \u0433\u0440\u0443\u043f\u043f\u0438\u0440\u043e\u0432\u043a\u0430\u043f\u043e\u0434\u0447\u0438\u043d\u0435\u043d\u043d\u044b\u0445\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u043e\u0432\u0444\u043e\u0440\u043c\u044b \u0433\u0440\u0443\u043f\u043f\u044b\u0438\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u044b \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0435\u043f\u0435\u0440\u0435\u0442\u0430\u0441\u043a\u0438\u0432\u0430\u043d\u0438\u044f \u0434\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0439\u0440\u0435\u0436\u0438\u043c\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f \u0434\u043e\u043f\u0443\u0441\u0442\u0438\u043c\u044b\u0435\u0434\u0435\u0439\u0441\u0442\u0432\u0438\u044f\u043f\u0435\u0440\u0435\u0442\u0430\u0441\u043a\u0438\u0432\u0430\u043d\u0438\u044f \u0438\u043d\u0442\u0435\u0440\u0432\u0430\u043b\u043c\u0435\u0436\u0434\u0443\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u043c\u0438\u0444\u043e\u0440\u043c\u044b \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0432\u044b\u0432\u043e\u0434\u0430 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043f\u043e\u043b\u043e\u0441\u044b\u043f\u0440\u043e\u043a\u0440\u0443\u0442\u043a\u0438 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u043c\u043e\u0435\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0442\u043e\u0447\u043a\u0438\u0431\u0438\u0440\u0436\u0435\u0432\u043e\u0439\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0438\u0441\u0442\u043e\u0440\u0438\u044f\u0432\u044b\u0431\u043e\u0440\u0430\u043f\u0440\u0438\u0432\u0432\u043e\u0434\u0435 \u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439\u043e\u0441\u0438\u0442\u043e\u0447\u0435\u043a\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f\u0440\u0430\u0437\u043c\u0435\u0440\u0430\u043f\u0443\u0437\u044b\u0440\u044c\u043a\u0430\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043a\u0430\u0442\u0435\u0433\u043e\u0440\u0438\u044f\u0433\u0440\u0443\u043f\u043f\u044b\u043a\u043e\u043c\u0430\u043d\u0434 \u043c\u0430\u043a\u0441\u0438\u043c\u0443\u043c\u0441\u0435\u0440\u0438\u0439 \u043d\u0430\u0447\u0430\u043b\u044c\u043d\u043e\u0435\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0434\u0435\u0440\u0435\u0432\u0430 \u043d\u0430\u0447\u0430\u043b\u044c\u043d\u043e\u0435\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0441\u043f\u0438\u0441\u043a\u0430 \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435\u0442\u0435\u043a\u0441\u0442\u0430\u0440\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u043e\u0440\u0438\u0435\u043d\u0442\u0430\u0446\u0438\u044f\u0434\u0435\u043d\u0434\u0440\u043e\u0433\u0440\u0430\u043c\u043c\u044b \u043e\u0440\u0438\u0435\u043d\u0442\u0430\u0446\u0438\u044f\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043e\u0440\u0438\u0435\u043d\u0442\u0430\u0446\u0438\u044f\u043c\u0435\u0442\u043e\u043a\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043e\u0440\u0438\u0435\u043d\u0442\u0430\u0446\u0438\u044f\u043c\u0435\u0442\u043e\u043a\u0441\u0432\u043e\u0434\u043d\u043e\u0439\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043e\u0440\u0438\u0435\u043d\u0442\u0430\u0446\u0438\u044f\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u0444\u043e\u0440\u043c\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0432\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u0435 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0432\u043b\u0435\u0433\u0435\u043d\u0434\u0435\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0433\u0440\u0443\u043f\u043f\u044b\u043a\u043d\u043e\u043f\u043e\u043a \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043a\u0430\u0448\u043a\u0430\u043b\u044b\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439\u0441\u0432\u043e\u0434\u043d\u043e\u0439\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f\u0438\u0437\u043c\u0435\u0440\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0439\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0438\u043d\u0442\u0435\u0440\u0432\u0430\u043b\u0430\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b\u0433\u0430\u043d\u0442\u0430 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u043a\u043d\u043e\u043f\u043a\u0438 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u043a\u043d\u043e\u043f\u043a\u0438\u0432\u044b\u0431\u043e\u0440\u0430 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u043e\u0431\u0441\u0443\u0436\u0434\u0435\u043d\u0438\u0439\u0444\u043e\u0440\u043c\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u043e\u0431\u044b\u0447\u043d\u043e\u0439\u0433\u0440\u0443\u043f\u043f\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u043e\u0442\u0440\u0438\u0446\u0430\u0442\u0435\u043b\u044c\u043d\u044b\u0445\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439\u043f\u0443\u0437\u044b\u0440\u044c\u043a\u043e\u0432\u043e\u0439\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u043f\u0430\u043d\u0435\u043b\u0438\u043f\u043e\u0438\u0441\u043a\u0430 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u043f\u043e\u0434\u0441\u043a\u0430\u0437\u043a\u0438 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u043f\u0440\u0435\u0434\u0443\u043f\u0440\u0435\u0436\u0434\u0435\u043d\u0438\u044f\u043f\u0440\u0438\u0440\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0438 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0440\u0430\u0437\u043c\u0435\u0442\u043a\u0438\u043f\u043e\u043b\u043e\u0441\u044b\u0440\u0435\u0433\u0443\u043b\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0441\u0442\u0440\u0430\u043d\u0438\u0446\u0444\u043e\u0440\u043c\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0442\u0430\u0431\u043b\u0438\u0446\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0442\u0435\u043a\u0441\u0442\u0430\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b\u0433\u0430\u043d\u0442\u0430 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u044f\u043e\u0431\u044b\u0447\u043d\u043e\u0439\u0433\u0440\u0443\u043f\u043f\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0444\u0438\u0433\u0443\u0440\u044b\u043a\u043d\u043e\u043f\u043a\u0438 \u043f\u0430\u043b\u0438\u0442\u0440\u0430\u0446\u0432\u0435\u0442\u043e\u0432\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043f\u043e\u0432\u0435\u0434\u0435\u043d\u0438\u0435\u043e\u0431\u044b\u0447\u043d\u043e\u0439\u0433\u0440\u0443\u043f\u043f\u044b \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u043a\u0430\u043c\u0430\u0441\u0448\u0442\u0430\u0431\u0430\u0434\u0435\u043d\u0434\u0440\u043e\u0433\u0440\u0430\u043c\u043c\u044b \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u043a\u0430\u043c\u0430\u0441\u0448\u0442\u0430\u0431\u0430\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b\u0433\u0430\u043d\u0442\u0430 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u043a\u0430\u043c\u0430\u0441\u0448\u0442\u0430\u0431\u0430\u0441\u0432\u043e\u0434\u043d\u043e\u0439\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043f\u043e\u0438\u0441\u043a\u0432\u0442\u0430\u0431\u043b\u0438\u0446\u0435\u043f\u0440\u0438\u0432\u0432\u043e\u0434\u0435 \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043a\u0430\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u0444\u043e\u0440\u043c\u044b \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043a\u0430\u0440\u0442\u0438\u043d\u043a\u0438\u043a\u043d\u043e\u043f\u043a\u0438\u0444\u043e\u0440\u043c\u044b \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043a\u0430\u0440\u0442\u0438\u043d\u043a\u0438\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043a\u043e\u043c\u0430\u043d\u0434\u043d\u043e\u0439\u043f\u0430\u043d\u0435\u043b\u0438\u0444\u043e\u0440\u043c\u044b \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043a\u043e\u043c\u0430\u043d\u0434\u043d\u043e\u0439\u043f\u0430\u043d\u0435\u043b\u0438\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u0444\u043e\u0440\u043c\u044b \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043e\u043f\u043e\u0440\u043d\u043e\u0439\u0442\u043e\u0447\u043a\u0438\u043e\u0442\u0440\u0438\u0441\u043e\u0432\u043a\u0438 \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043f\u043e\u0434\u043f\u0438\u0441\u0435\u0439\u043a\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u0435 \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043f\u043e\u0434\u043f\u0438\u0441\u0435\u0439\u0448\u043a\u0430\u043b\u044b\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439\u0438\u0437\u043c\u0435\u0440\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0439\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u044f\u043f\u0440\u043e\u0441\u043c\u043e\u0442\u0440\u0430 \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0441\u0442\u0440\u043e\u043a\u0438\u043f\u043e\u0438\u0441\u043a\u0430 \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0442\u0435\u043a\u0441\u0442\u0430\u0441\u043e\u0435\u0434\u0438\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0439\u043b\u0438\u043d\u0438\u0438 \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u044f\u043f\u043e\u0438\u0441\u043a\u043e\u043c \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0448\u043a\u0430\u043b\u044b\u0432\u0440\u0435\u043c\u0435\u043d\u0438 \u043f\u043e\u0440\u044f\u0434\u043e\u043a\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u0442\u043e\u0447\u0435\u043a\u0433\u043e\u0440\u0438\u0437\u043e\u043d\u0442\u0430\u043b\u044c\u043d\u043e\u0439\u0433\u0438\u0441\u0442\u043e\u0433\u0440\u0430\u043c\u043c\u044b \u043f\u043e\u0440\u044f\u0434\u043e\u043a\u0441\u0435\u0440\u0438\u0439\u0432\u043b\u0435\u0433\u0435\u043d\u0434\u0435\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0440\u0430\u0437\u043c\u0435\u0440\u043a\u0430\u0440\u0442\u0438\u043d\u043a\u0438 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043a\u0430\u0448\u043a\u0430\u043b\u044b\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0440\u0430\u0441\u0442\u044f\u0433\u0438\u0432\u0430\u043d\u0438\u0435\u043f\u043e\u0432\u0435\u0440\u0442\u0438\u043a\u0430\u043b\u0438\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b\u0433\u0430\u043d\u0442\u0430 \u0440\u0435\u0436\u0438\u043c\u0430\u0432\u0442\u043e\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u044f \u0440\u0435\u0436\u0438\u043c\u0432\u0432\u043e\u0434\u0430\u0441\u0442\u0440\u043e\u043a\u0442\u0430\u0431\u043b\u0438\u0446\u044b \u0440\u0435\u0436\u0438\u043c\u0432\u044b\u0431\u043e\u0440\u0430\u043d\u0435\u0437\u0430\u043f\u043e\u043b\u043d\u0435\u043d\u043d\u043e\u0433\u043e \u0440\u0435\u0436\u0438\u043c\u0432\u044b\u0434\u0435\u043b\u0435\u043d\u0438\u044f\u0434\u0430\u0442\u044b \u0440\u0435\u0436\u0438\u043c\u0432\u044b\u0434\u0435\u043b\u0435\u043d\u0438\u044f\u0441\u0442\u0440\u043e\u043a\u0438\u0442\u0430\u0431\u043b\u0438\u0446\u044b \u0440\u0435\u0436\u0438\u043c\u0432\u044b\u0434\u0435\u043b\u0435\u043d\u0438\u044f\u0442\u0430\u0431\u043b\u0438\u0446\u044b \u0440\u0435\u0436\u0438\u043c\u0438\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u044f\u0440\u0430\u0437\u043c\u0435\u0440\u0430 \u0440\u0435\u0436\u0438\u043c\u0438\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u044f\u0441\u0432\u044f\u0437\u0430\u043d\u043d\u043e\u0433\u043e\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f \u0440\u0435\u0436\u0438\u043c\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u0434\u0438\u0430\u043b\u043e\u0433\u0430\u043f\u0435\u0447\u0430\u0442\u0438 \u0440\u0435\u0436\u0438\u043c\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0430\u043a\u043e\u043c\u0430\u043d\u0434\u044b \u0440\u0435\u0436\u0438\u043c\u043c\u0430\u0441\u0448\u0442\u0430\u0431\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f\u043f\u0440\u043e\u0441\u043c\u043e\u0442\u0440\u0430 \u0440\u0435\u0436\u0438\u043c\u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0433\u043e\u043e\u043a\u043d\u0430\u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u043e\u0433\u043e\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0440\u0435\u0436\u0438\u043c\u043e\u0442\u043a\u0440\u044b\u0442\u0438\u044f\u043e\u043a\u043d\u0430\u0444\u043e\u0440\u043c\u044b \u0440\u0435\u0436\u0438\u043c\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u0432\u044b\u0434\u0435\u043b\u0435\u043d\u0438\u044f \u0440\u0435\u0436\u0438\u043c\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0440\u0435\u0436\u0438\u043c\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439\u0441\u0435\u0440\u0438\u0438 \u0440\u0435\u0436\u0438\u043c\u043e\u0442\u0440\u0438\u0441\u043e\u0432\u043a\u0438\u0441\u0435\u0442\u043a\u0438\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0440\u0435\u0436\u0438\u043c\u043f\u043e\u043b\u0443\u043f\u0440\u043e\u0437\u0440\u0430\u0447\u043d\u043e\u0441\u0442\u0438\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0440\u0435\u0436\u0438\u043c\u043f\u0440\u043e\u0431\u0435\u043b\u043e\u0432\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0440\u0435\u0436\u0438\u043c\u0440\u0430\u0437\u043c\u0435\u0449\u0435\u043d\u0438\u044f\u043d\u0430\u0441\u0442\u0440\u0430\u043d\u0438\u0446\u0435 \u0440\u0435\u0436\u0438\u043c\u0440\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f\u043a\u043e\u043b\u043e\u043d\u043a\u0438 \u0440\u0435\u0436\u0438\u043c\u0441\u0433\u043b\u0430\u0436\u0438\u0432\u0430\u043d\u0438\u044f\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0440\u0435\u0436\u0438\u043c\u0441\u0433\u043b\u0430\u0436\u0438\u0432\u0430\u043d\u0438\u044f\u0438\u043d\u0434\u0438\u043a\u0430\u0442\u043e\u0440\u0430 \u0440\u0435\u0436\u0438\u043c\u0441\u043f\u0438\u0441\u043a\u0430\u0437\u0430\u0434\u0430\u0447 \u0441\u043a\u0432\u043e\u0437\u043d\u043e\u0435\u0432\u044b\u0440\u0430\u0432\u043d\u0438\u0432\u0430\u043d\u0438\u0435 \u0441\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u0438\u0435\u0434\u0430\u043d\u043d\u044b\u0445\u0444\u043e\u0440\u043c\u044b\u0432\u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430\u0445 \u0441\u043f\u043e\u0441\u043e\u0431\u0437\u0430\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f\u0442\u0435\u043a\u0441\u0442\u0430\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043a\u0430\u0448\u043a\u0430\u043b\u044b\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0441\u043f\u043e\u0441\u043e\u0431\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u044f\u043e\u0433\u0440\u0430\u043d\u0438\u0447\u0438\u0432\u0430\u044e\u0449\u0435\u0433\u043e\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u0430\u044f\u0433\u0440\u0443\u043f\u043f\u0430\u043a\u043e\u043c\u0430\u043d\u0434 \u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u043e\u0435\u043e\u0444\u043e\u0440\u043c\u043b\u0435\u043d\u0438\u0435 \u0441\u0442\u0430\u0442\u0443\u0441\u043e\u043f\u043e\u0432\u0435\u0449\u0435\u043d\u0438\u044f\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u0441\u0442\u0438\u043b\u044c\u0441\u0442\u0440\u0435\u043b\u043a\u0438 \u0442\u0438\u043f\u0430\u043f\u043f\u0440\u043e\u043a\u0441\u0438\u043c\u0430\u0446\u0438\u0438\u043b\u0438\u043d\u0438\u0438\u0442\u0440\u0435\u043d\u0434\u0430\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0442\u0438\u043f\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0442\u0438\u043f\u0435\u0434\u0438\u043d\u0438\u0446\u044b\u0448\u043a\u0430\u043b\u044b\u0432\u0440\u0435\u043c\u0435\u043d\u0438 \u0442\u0438\u043f\u0438\u043c\u043f\u043e\u0440\u0442\u0430\u0441\u0435\u0440\u0438\u0439\u0441\u043b\u043e\u044f\u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0442\u0438\u043f\u043b\u0438\u043d\u0438\u0438\u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0442\u0438\u043f\u043b\u0438\u043d\u0438\u0438\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0442\u0438\u043f\u043c\u0430\u0440\u043a\u0435\u0440\u0430\u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0442\u0438\u043f\u043c\u0430\u0440\u043a\u0435\u0440\u0430\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0442\u0438\u043f\u043e\u0431\u043b\u0430\u0441\u0442\u0438\u043e\u0444\u043e\u0440\u043c\u043b\u0435\u043d\u0438\u044f \u0442\u0438\u043f\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\u0438\u0438\u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0430\u0434\u0430\u043d\u043d\u044b\u0445\u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0442\u0438\u043f\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u0441\u0435\u0440\u0438\u0438\u0441\u043b\u043e\u044f\u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0442\u0438\u043f\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u0442\u043e\u0447\u0435\u0447\u043d\u043e\u0433\u043e\u043e\u0431\u044a\u0435\u043a\u0442\u0430\u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0442\u0438\u043f\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u0448\u043a\u0430\u043b\u044b\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u043b\u0435\u0433\u0435\u043d\u0434\u044b\u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0442\u0438\u043f\u043f\u043e\u0438\u0441\u043a\u0430\u043e\u0431\u044a\u0435\u043a\u0442\u043e\u0432\u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0442\u0438\u043f\u043f\u0440\u043e\u0435\u043a\u0446\u0438\u0438\u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0442\u0438\u043f\u0440\u0430\u0437\u043c\u0435\u0449\u0435\u043d\u0438\u044f\u0438\u0437\u043c\u0435\u0440\u0435\u043d\u0438\u0439 \u0442\u0438\u043f\u0440\u0430\u0437\u043c\u0435\u0449\u0435\u043d\u0438\u044f\u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u043e\u0432\u0438\u0437\u043c\u0435\u0440\u0435\u043d\u0438\u0439 \u0442\u0438\u043f\u0440\u0430\u043c\u043a\u0438\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u044f \u0442\u0438\u043f\u0441\u0432\u043e\u0434\u043d\u043e\u0439\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0442\u0438\u043f\u0441\u0432\u044f\u0437\u0438\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b\u0433\u0430\u043d\u0442\u0430 \u0442\u0438\u043f\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439\u043f\u043e\u0441\u0435\u0440\u0438\u044f\u043c\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0442\u0438\u043f\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f\u0442\u043e\u0447\u0435\u043a\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0442\u0438\u043f\u0441\u043e\u0435\u0434\u0438\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0439\u043b\u0438\u043d\u0438\u0438 \u0442\u0438\u043f\u0441\u0442\u043e\u0440\u043e\u043d\u044b\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0442\u0438\u043f\u0444\u043e\u0440\u043c\u044b\u043e\u0442\u0447\u0435\u0442\u0430 \u0442\u0438\u043f\u0448\u043a\u0430\u043b\u044b\u0440\u0430\u0434\u0430\u0440\u043d\u043e\u0439\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0444\u0430\u043a\u0442\u043e\u0440\u043b\u0438\u043d\u0438\u0438\u0442\u0440\u0435\u043d\u0434\u0430\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0444\u0438\u0433\u0443\u0440\u0430\u043a\u043d\u043e\u043f\u043a\u0438 \u0444\u0438\u0433\u0443\u0440\u044b\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0444\u0438\u043a\u0441\u0430\u0446\u0438\u044f\u0432\u0442\u0430\u0431\u043b\u0438\u0446\u0435 \u0444\u043e\u0440\u043c\u0430\u0442\u0434\u043d\u044f\u0448\u043a\u0430\u043b\u044b\u0432\u0440\u0435\u043c\u0435\u043d\u0438 \u0444\u043e\u0440\u043c\u0430\u0442\u043a\u0430\u0440\u0442\u0438\u043d\u043a\u0438 \u0448\u0438\u0440\u0438\u043d\u0430\u043f\u043e\u0434\u0447\u0438\u043d\u0435\u043d\u043d\u044b\u0445\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u043e\u0432\u0444\u043e\u0440\u043c\u044b \u0432\u0438\u0434\u0434\u0432\u0438\u0436\u0435\u043d\u0438\u044f\u0431\u0443\u0445\u0433\u0430\u043b\u0442\u0435\u0440\u0438\u0438 \u0432\u0438\u0434\u0434\u0432\u0438\u0436\u0435\u043d\u0438\u044f\u043d\u0430\u043a\u043e\u043f\u043b\u0435\u043d\u0438\u044f \u0432\u0438\u0434\u043f\u0435\u0440\u0438\u043e\u0434\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0440\u0430\u0441\u0447\u0435\u0442\u0430 \u0432\u0438\u0434\u0441\u0447\u0435\u0442\u0430 \u0432\u0438\u0434\u0442\u043e\u0447\u043a\u0438\u043c\u0430\u0440\u0448\u0440\u0443\u0442\u0430\u0431\u0438\u0437\u043d\u0435\u0441\u043f\u0440\u043e\u0446\u0435\u0441\u0441\u0430 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0430\u0433\u0440\u0435\u0433\u0430\u0442\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u043d\u0430\u043a\u043e\u043f\u043b\u0435\u043d\u0438\u044f \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0433\u0440\u0443\u043f\u043f\u0438\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u043e\u0432 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0440\u0435\u0436\u0438\u043c\u0430\u043f\u0440\u043e\u0432\u0435\u0434\u0435\u043d\u0438\u044f \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0441\u0440\u0435\u0437\u0430 \u043f\u0435\u0440\u0438\u043e\u0434\u0438\u0447\u043d\u043e\u0441\u0442\u044c\u0430\u0433\u0440\u0435\u0433\u0430\u0442\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u043d\u0430\u043a\u043e\u043f\u043b\u0435\u043d\u0438\u044f \u0440\u0435\u0436\u0438\u043c\u0430\u0432\u0442\u043e\u0432\u0440\u0435\u043c\u044f \u0440\u0435\u0436\u0438\u043c\u0437\u0430\u043f\u0438\u0441\u0438\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0440\u0435\u0436\u0438\u043c\u043f\u0440\u043e\u0432\u0435\u0434\u0435\u043d\u0438\u044f\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0430\u0432\u0442\u043e\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u044f\u0438\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u0439 \u0434\u043e\u043f\u0443\u0441\u0442\u0438\u043c\u044b\u0439\u043d\u043e\u043c\u0435\u0440\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f \u043e\u0442\u043f\u0440\u0430\u0432\u043a\u0430\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u0435\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0440\u0430\u0441\u0448\u0438\u0444\u0440\u043e\u0432\u043a\u0438\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u043e\u0440\u0438\u0435\u043d\u0442\u0430\u0446\u0438\u044f\u0441\u0442\u0440\u0430\u043d\u0438\u0446\u044b \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0438\u0442\u043e\u0433\u043e\u0432\u043a\u043e\u043b\u043e\u043d\u043e\u043a\u0441\u0432\u043e\u0434\u043d\u043e\u0439\u0442\u0430\u0431\u043b\u0438\u0446\u044b \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0438\u0442\u043e\u0433\u043e\u0432\u0441\u0442\u0440\u043e\u043a\u0441\u0432\u043e\u0434\u043d\u043e\u0439\u0442\u0430\u0431\u043b\u0438\u0446\u044b \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0442\u0435\u043a\u0441\u0442\u0430\u043e\u0442\u043d\u043e\u0441\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u043a\u0430\u0440\u0442\u0438\u043d\u043a\u0438 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043a\u0430\u0433\u0440\u0443\u043f\u043f\u0438\u0440\u043e\u0432\u043a\u0438\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0441\u043f\u043e\u0441\u043e\u0431\u0447\u0442\u0435\u043d\u0438\u044f\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u0434\u0432\u0443\u0441\u0442\u043e\u0440\u043e\u043d\u043d\u0435\u0439\u043f\u0435\u0447\u0430\u0442\u0438 \u0442\u0438\u043f\u0437\u0430\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f\u043e\u0431\u043b\u0430\u0441\u0442\u0438\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u043a\u0443\u0440\u0441\u043e\u0440\u043e\u0432\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u043b\u0438\u043d\u0438\u0438\u0440\u0438\u0441\u0443\u043d\u043a\u0430\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u043b\u0438\u043d\u0438\u0438\u044f\u0447\u0435\u0439\u043a\u0438\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u043d\u0430\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u044f\u043f\u0435\u0440\u0435\u0445\u043e\u0434\u0430\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u0432\u044b\u0434\u0435\u043b\u0435\u043d\u0438\u044f\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u043b\u0438\u043d\u0438\u0439\u0441\u0432\u043e\u0434\u043d\u043e\u0439\u0442\u0430\u0431\u043b\u0438\u0446\u044b \u0442\u0438\u043f\u0440\u0430\u0437\u043c\u0435\u0449\u0435\u043d\u0438\u044f\u0442\u0435\u043a\u0441\u0442\u0430\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u0440\u0438\u0441\u0443\u043d\u043a\u0430\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u0441\u043c\u0435\u0449\u0435\u043d\u0438\u044f\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u0443\u0437\u043e\u0440\u0430\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u0444\u0430\u0439\u043b\u0430\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u043e\u0447\u043d\u043e\u0441\u0442\u044c\u043f\u0435\u0447\u0430\u0442\u0438 \u0447\u0435\u0440\u0435\u0434\u043e\u0432\u0430\u043d\u0438\u0435\u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u044f\u0441\u0442\u0440\u0430\u043d\u0438\u0446 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0432\u0440\u0435\u043c\u0435\u043d\u0438\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u043e\u0432\u043f\u043b\u0430\u043d\u0438\u0440\u043e\u0432\u0449\u0438\u043a\u0430 \u0442\u0438\u043f\u0444\u0430\u0439\u043b\u0430\u0444\u043e\u0440\u043c\u0430\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u043e\u0431\u0445\u043e\u0434\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u0430\u0437\u0430\u043f\u0440\u043e\u0441\u0430 \u0442\u0438\u043f\u0437\u0430\u043f\u0438\u0441\u0438\u0437\u0430\u043f\u0440\u043e\u0441\u0430 \u0432\u0438\u0434\u0437\u0430\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f\u0440\u0430\u0441\u0448\u0438\u0444\u0440\u043e\u0432\u043a\u0438\u043f\u043e\u0441\u0442\u0440\u043e\u0438\u0442\u0435\u043b\u044f\u043e\u0442\u0447\u0435\u0442\u0430 \u0442\u0438\u043f\u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0438\u044f\u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0439 \u0442\u0438\u043f\u0438\u0437\u043c\u0435\u0440\u0435\u043d\u0438\u044f\u043f\u043e\u0441\u0442\u0440\u043e\u0438\u0442\u0435\u043b\u044f\u043e\u0442\u0447\u0435\u0442\u0430 \u0442\u0438\u043f\u0440\u0430\u0437\u043c\u0435\u0449\u0435\u043d\u0438\u044f\u0438\u0442\u043e\u0433\u043e\u0432 \u0434\u043e\u0441\u0442\u0443\u043f\u043a\u0444\u0430\u0439\u043b\u0443 \u0440\u0435\u0436\u0438\u043c\u0434\u0438\u0430\u043b\u043e\u0433\u0430\u0432\u044b\u0431\u043e\u0440\u0430\u0444\u0430\u0439\u043b\u0430 \u0440\u0435\u0436\u0438\u043c\u043e\u0442\u043a\u0440\u044b\u0442\u0438\u044f\u0444\u0430\u0439\u043b\u0430 \u0442\u0438\u043f\u0438\u0437\u043c\u0435\u0440\u0435\u043d\u0438\u044f\u043f\u043e\u0441\u0442\u0440\u043e\u0438\u0442\u0435\u043b\u044f\u0437\u0430\u043f\u0440\u043e\u0441\u0430 \u0432\u0438\u0434\u0434\u0430\u043d\u043d\u044b\u0445\u0430\u043d\u0430\u043b\u0438\u0437\u0430 \u043c\u0435\u0442\u043e\u0434\u043a\u043b\u0430\u0441\u0442\u0435\u0440\u0438\u0437\u0430\u0446\u0438\u0438 \u0442\u0438\u043f\u0435\u0434\u0438\u043d\u0438\u0446\u044b\u0438\u043d\u0442\u0435\u0440\u0432\u0430\u043b\u0430\u0432\u0440\u0435\u043c\u0435\u043d\u0438\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0437\u0430\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f\u0442\u0430\u0431\u043b\u0438\u0446\u044b\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u0430\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u0447\u0438\u0441\u043b\u043e\u0432\u044b\u0445\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0430\u0434\u0430\u043d\u043d\u044b\u0445\u043f\u043e\u0438\u0441\u043a\u0430\u0430\u0441\u0441\u043e\u0446\u0438\u0430\u0446\u0438\u0439 \u0442\u0438\u043f\u043a\u043e\u043b\u043e\u043d\u043a\u0438\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445\u0434\u0435\u0440\u0435\u0432\u043e\u0440\u0435\u0448\u0435\u043d\u0438\u0439 \u0442\u0438\u043f\u043a\u043e\u043b\u043e\u043d\u043a\u0438\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445\u043a\u043b\u0430\u0441\u0442\u0435\u0440\u0438\u0437\u0430\u0446\u0438\u044f \u0442\u0438\u043f\u043a\u043e\u043b\u043e\u043d\u043a\u0438\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445\u043e\u0431\u0449\u0430\u044f\u0441\u0442\u0430\u0442\u0438\u0441\u0442\u0438\u043a\u0430 \u0442\u0438\u043f\u043a\u043e\u043b\u043e\u043d\u043a\u0438\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445\u043f\u043e\u0438\u0441\u043a\u0430\u0441\u0441\u043e\u0446\u0438\u0430\u0446\u0438\u0439 \u0442\u0438\u043f\u043a\u043e\u043b\u043e\u043d\u043a\u0438\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445\u043f\u043e\u0438\u0441\u043a\u043f\u043e\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043d\u043e\u0441\u0442\u0435\u0439 \u0442\u0438\u043f\u043a\u043e\u043b\u043e\u043d\u043a\u0438\u043c\u043e\u0434\u0435\u043b\u0438\u043f\u0440\u043e\u0433\u043d\u043e\u0437\u0430 \u0442\u0438\u043f\u043c\u0435\u0440\u044b\u0440\u0430\u0441\u0441\u0442\u043e\u044f\u043d\u0438\u044f\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u043e\u0442\u0441\u0435\u0447\u0435\u043d\u0438\u044f\u043f\u0440\u0430\u0432\u0438\u043b\u0430\u0441\u0441\u043e\u0446\u0438\u0430\u0446\u0438\u0438 \u0442\u0438\u043f\u043f\u043e\u043b\u044f\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u0438\u0437\u0430\u0446\u0438\u0438\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0443\u043f\u043e\u0440\u044f\u0434\u043e\u0447\u0438\u0432\u0430\u043d\u0438\u044f\u043f\u0440\u0430\u0432\u0438\u043b\u0430\u0441\u0441\u043e\u0446\u0438\u0430\u0446\u0438\u0438\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0443\u043f\u043e\u0440\u044f\u0434\u043e\u0447\u0438\u0432\u0430\u043d\u0438\u044f\u0448\u0430\u0431\u043b\u043e\u043d\u043e\u0432\u043f\u043e\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043d\u043e\u0441\u0442\u0435\u0439\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0443\u043f\u0440\u043e\u0449\u0435\u043d\u0438\u044f\u0434\u0435\u0440\u0435\u0432\u0430\u0440\u0435\u0448\u0435\u043d\u0438\u0439 ws\u043d\u0430\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0430 \u0432\u0430\u0440\u0438\u0430\u043d\u0442xpathxs \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u0437\u0430\u043f\u0438\u0441\u0438\u0434\u0430\u0442\u044bjson \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u043f\u0440\u043e\u0441\u0442\u043e\u0433\u043e\u0442\u0438\u043f\u0430xs \u0432\u0438\u0434\u0433\u0440\u0443\u043f\u043f\u044b\u043c\u043e\u0434\u0435\u043b\u0438xs \u0432\u0438\u0434\u0444\u0430\u0441\u0435\u0442\u0430xdto \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0435\u043f\u043e\u0441\u0442\u0440\u043e\u0438\u0442\u0435\u043b\u044fdom \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u043d\u043e\u0441\u0442\u044c\u043f\u0440\u043e\u0441\u0442\u043e\u0433\u043e\u0442\u0438\u043f\u0430xs \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u043d\u043e\u0441\u0442\u044c\u0441\u043e\u0441\u0442\u0430\u0432\u043d\u043e\u0433\u043e\u0442\u0438\u043f\u0430xs \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u043d\u043e\u0441\u0442\u044c\u0441\u0445\u0435\u043c\u044bxs \u0437\u0430\u043f\u0440\u0435\u0449\u0435\u043d\u043d\u044b\u0435\u043f\u043e\u0434\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0438xs \u0438\u0441\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f\u0433\u0440\u0443\u043f\u043f\u043f\u043e\u0434\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0438xs \u043a\u0430\u0442\u0435\u0433\u043e\u0440\u0438\u044f\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u0430\u0442\u0440\u0438\u0431\u0443\u0442\u0430xs \u043a\u0430\u0442\u0435\u0433\u043e\u0440\u0438\u044f\u043e\u0433\u0440\u0430\u043d\u0438\u0447\u0435\u043d\u0438\u044f\u0438\u0434\u0435\u043d\u0442\u0438\u0447\u043d\u043e\u0441\u0442\u0438xs \u043a\u0430\u0442\u0435\u0433\u043e\u0440\u0438\u044f\u043e\u0433\u0440\u0430\u043d\u0438\u0447\u0435\u043d\u0438\u044f\u043f\u0440\u043e\u0441\u0442\u0440\u0430\u043d\u0441\u0442\u0432\u0438\u043c\u0435\u043dxs \u043c\u0435\u0442\u043e\u0434\u043d\u0430\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u043d\u0438\u044fxs \u043c\u043e\u0434\u0435\u043b\u044c\u0441\u043e\u0434\u0435\u0440\u0436\u0438\u043c\u043e\u0433\u043exs \u043d\u0430\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0442\u0438\u043f\u0430xml \u043d\u0435\u0434\u043e\u043f\u0443\u0441\u0442\u0438\u043c\u044b\u0435\u043f\u043e\u0434\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0438xs \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0430\u043f\u0440\u043e\u0431\u0435\u043b\u044c\u043d\u044b\u0445\u0441\u0438\u043c\u0432\u043e\u043b\u043e\u0432xs \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0430\u0441\u043e\u0434\u0435\u0440\u0436\u0438\u043c\u043e\u0433\u043exs \u043e\u0433\u0440\u0430\u043d\u0438\u0447\u0435\u043d\u0438\u0435\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044fxs \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b\u043e\u0442\u0431\u043e\u0440\u0430\u0443\u0437\u043b\u043e\u0432dom \u043f\u0435\u0440\u0435\u043d\u043e\u0441\u0441\u0442\u0440\u043e\u043ajson \u043f\u043e\u0437\u0438\u0446\u0438\u044f\u0432\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0435dom \u043f\u0440\u043e\u0431\u0435\u043b\u044c\u043d\u044b\u0435\u0441\u0438\u043c\u0432\u043e\u043b\u044bxml \u0442\u0438\u043f\u0430\u0442\u0440\u0438\u0431\u0443\u0442\u0430xml \u0442\u0438\u043f\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044fjson \u0442\u0438\u043f\u043a\u0430\u043d\u043e\u043d\u0438\u0447\u0435\u0441\u043a\u043e\u0433\u043exml \u0442\u0438\u043f\u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u044bxs \u0442\u0438\u043f\u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438xml \u0442\u0438\u043f\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u0430domxpath \u0442\u0438\u043f\u0443\u0437\u043b\u0430dom \u0442\u0438\u043f\u0443\u0437\u043b\u0430xml \u0444\u043e\u0440\u043c\u0430xml \u0444\u043e\u0440\u043c\u0430\u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u044fxs \u0444\u043e\u0440\u043c\u0430\u0442\u0434\u0430\u0442\u044bjson \u044d\u043a\u0440\u0430\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435\u0441\u0438\u043c\u0432\u043e\u043b\u043e\u0432json \u0432\u0438\u0434\u0441\u0440\u0430\u0432\u043d\u0435\u043d\u0438\u044f\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0435\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0438\u0440\u0430\u0441\u0448\u0438\u0444\u0440\u043e\u0432\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043d\u0430\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u0441\u043e\u0440\u0442\u0438\u0440\u043e\u0432\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0432\u043b\u043e\u0436\u0435\u043d\u043d\u044b\u0445\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u043e\u0432\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0438\u0442\u043e\u0433\u043e\u0432\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0433\u0440\u0443\u043f\u043f\u0438\u0440\u043e\u0432\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043f\u043e\u043b\u0435\u0439\u0433\u0440\u0443\u043f\u043f\u0438\u0440\u043e\u0432\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043f\u043e\u043b\u044f\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u043e\u0432\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0440\u0435\u0441\u0443\u0440\u0441\u043e\u0432\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0431\u0443\u0445\u0433\u0430\u043b\u0442\u0435\u0440\u0441\u043a\u043e\u0433\u043e\u043e\u0441\u0442\u0430\u0442\u043a\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0432\u044b\u0432\u043e\u0434\u0430\u0442\u0435\u043a\u0441\u0442\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0433\u0440\u0443\u043f\u043f\u0438\u0440\u043e\u0432\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0433\u0440\u0443\u043f\u043f\u044b\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u043e\u0432\u043e\u0442\u0431\u043e\u0440\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0434\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f\u043f\u0435\u0440\u0438\u043e\u0434\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043a\u0430\u043f\u043e\u043b\u0435\u0439\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u043c\u0430\u043a\u0435\u0442\u0430\u0433\u0440\u0443\u043f\u043f\u0438\u0440\u043e\u0432\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u043c\u0430\u043a\u0435\u0442\u0430\u043e\u0431\u043b\u0430\u0441\u0442\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u043e\u0441\u0442\u0430\u0442\u043a\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u043f\u0435\u0440\u0438\u043e\u0434\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0440\u0430\u0437\u043c\u0435\u0449\u0435\u043d\u0438\u044f\u0442\u0435\u043a\u0441\u0442\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0441\u0432\u044f\u0437\u0438\u043d\u0430\u0431\u043e\u0440\u043e\u0432\u0434\u0430\u043d\u043d\u044b\u0445\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043b\u0435\u0433\u0435\u043d\u0434\u044b\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u043f\u0440\u0438\u043c\u0435\u043d\u0435\u043d\u0438\u044f\u043e\u0442\u0431\u043e\u0440\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0435\u0436\u0438\u043c\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0435\u0436\u0438\u043c\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0435\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0441\u043f\u043e\u0441\u043e\u0431\u0432\u043e\u0441\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u044f\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0435\u0436\u0438\u043c\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u0430 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0430\u0432\u0442\u043e\u043f\u043e\u0437\u0438\u0446\u0438\u044f\u0440\u0435\u0441\u0443\u0440\u0441\u043e\u0432\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u0433\u0440\u0443\u043f\u043f\u0438\u0440\u043e\u0432\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0440\u0435\u0441\u0443\u0440\u0441\u043e\u0432\u0432\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u0435\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0444\u0438\u043a\u0441\u0430\u0446\u0438\u044f\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0443\u0441\u043b\u043e\u0432\u043d\u043e\u0433\u043e\u043e\u0444\u043e\u0440\u043c\u043b\u0435\u043d\u0438\u044f\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0432\u0430\u0436\u043d\u043e\u0441\u0442\u044c\u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u043f\u043e\u0447\u0442\u043e\u0432\u043e\u0433\u043e\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0430\u0442\u0435\u043a\u0441\u0442\u0430\u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u043f\u043e\u0447\u0442\u043e\u0432\u043e\u0433\u043e\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f \u0441\u043f\u043e\u0441\u043e\u0431\u043a\u043e\u0434\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f\u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u043f\u043e\u0447\u0442\u043e\u0432\u043e\u0433\u043e\u0432\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0441\u043f\u043e\u0441\u043e\u0431\u043a\u043e\u0434\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f\u043d\u0435ascii\u0441\u0438\u043c\u0432\u043e\u043b\u043e\u0432\u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u043f\u043e\u0447\u0442\u043e\u0432\u043e\u0433\u043e\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f \u0442\u0438\u043f\u0442\u0435\u043a\u0441\u0442\u0430\u043f\u043e\u0447\u0442\u043e\u0432\u043e\u0433\u043e\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f \u043f\u0440\u043e\u0442\u043e\u043a\u043e\u043b\u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u043f\u043e\u0447\u0442\u044b \u0441\u0442\u0430\u0442\u0443\u0441\u0440\u0430\u0437\u0431\u043e\u0440\u0430\u043f\u043e\u0447\u0442\u043e\u0432\u043e\u0433\u043e\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f \u0440\u0435\u0436\u0438\u043c\u0442\u0440\u0430\u043d\u0437\u0430\u043a\u0446\u0438\u0438\u0437\u0430\u043f\u0438\u0441\u0438\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u0441\u0442\u0430\u0442\u0443\u0441\u0442\u0440\u0430\u043d\u0437\u0430\u043a\u0446\u0438\u0438\u0437\u0430\u043f\u0438\u0441\u0438\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u0443\u0440\u043e\u0432\u0435\u043d\u044c\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0430\u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0432\u043a\u0440\u0438\u043f\u0442\u043e\u0433\u0440\u0430\u0444\u0438\u0438 \u0440\u0435\u0436\u0438\u043c\u0432\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f\u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0432\u043a\u0440\u0438\u043f\u0442\u043e\u0433\u0440\u0430\u0444\u0438\u0438 \u0440\u0435\u0436\u0438\u043c\u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438\u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u0430\u043a\u0440\u0438\u043f\u0442\u043e\u0433\u0440\u0430\u0444\u0438\u0438 \u0442\u0438\u043f\u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0430\u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0432\u043a\u0440\u0438\u043f\u0442\u043e\u0433\u0440\u0430\u0444\u0438\u0438 \u043a\u043e\u0434\u0438\u0440\u043e\u0432\u043a\u0430\u0438\u043c\u0435\u043d\u0444\u0430\u0439\u043b\u043e\u0432\u0432zip\u0444\u0430\u0439\u043b\u0435 \u043c\u0435\u0442\u043e\u0434\u0441\u0436\u0430\u0442\u0438\u044fzip \u043c\u0435\u0442\u043e\u0434\u0448\u0438\u0444\u0440\u043e\u0432\u0430\u043d\u0438\u044fzip \u0440\u0435\u0436\u0438\u043c\u0432\u043e\u0441\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u044f\u043f\u0443\u0442\u0435\u0439\u0444\u0430\u0439\u043b\u043e\u0432zip \u0440\u0435\u0436\u0438\u043c\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0438\u043f\u043e\u0434\u043a\u0430\u0442\u0430\u043b\u043e\u0433\u043e\u0432zip \u0440\u0435\u0436\u0438\u043c\u0441\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u0438\u044f\u043f\u0443\u0442\u0435\u0439zip \u0443\u0440\u043e\u0432\u0435\u043d\u044c\u0441\u0436\u0430\u0442\u0438\u044fzip \u0437\u0432\u0443\u043a\u043e\u0432\u043e\u0435\u043e\u043f\u043e\u0432\u0435\u0449\u0435\u043d\u0438\u0435 \u043d\u0430\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u0435\u0440\u0435\u0445\u043e\u0434\u0430\u043a\u0441\u0442\u0440\u043e\u043a\u0435 \u043f\u043e\u0437\u0438\u0446\u0438\u044f\u0432\u043f\u043e\u0442\u043e\u043a\u0435 \u043f\u043e\u0440\u044f\u0434\u043e\u043a\u0431\u0430\u0439\u0442\u043e\u0432 \u0440\u0435\u0436\u0438\u043c\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0435\u0436\u0438\u043c\u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u044f\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u043a\u043e\u0439\u0434\u0430\u043d\u043d\u044b\u0445 \u0441\u0435\u0440\u0432\u0438\u0441\u0432\u0441\u0442\u0440\u043e\u0435\u043d\u043d\u044b\u0445\u043f\u043e\u043a\u0443\u043f\u043e\u043a \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0435\u0444\u043e\u043d\u043e\u0432\u043e\u0433\u043e\u0437\u0430\u0434\u0430\u043d\u0438\u044f \u0442\u0438\u043f\u043f\u043e\u0434\u043f\u0438\u0441\u0447\u0438\u043a\u0430\u0434\u043e\u0441\u0442\u0430\u0432\u043b\u044f\u0435\u043c\u044b\u0445\u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u0439 \u0443\u0440\u043e\u0432\u0435\u043d\u044c\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u0437\u0430\u0449\u0438\u0449\u0435\u043d\u043d\u043e\u0433\u043e\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044fftp \u043d\u0430\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u043e\u0440\u044f\u0434\u043a\u0430\u0441\u0445\u0435\u043c\u044b\u0437\u0430\u043f\u0440\u043e\u0441\u0430 \u0442\u0438\u043f\u0434\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f\u043f\u0435\u0440\u0438\u043e\u0434\u0430\u043c\u0438\u0441\u0445\u0435\u043c\u044b\u0437\u0430\u043f\u0440\u043e\u0441\u0430 \u0442\u0438\u043f\u043a\u043e\u043d\u0442\u0440\u043e\u043b\u044c\u043d\u043e\u0439\u0442\u043e\u0447\u043a\u0438\u0441\u0445\u0435\u043c\u044b\u0437\u0430\u043f\u0440\u043e\u0441\u0430 \u0442\u0438\u043f\u043e\u0431\u044a\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f\u0441\u0445\u0435\u043c\u044b\u0437\u0430\u043f\u0440\u043e\u0441\u0430 \u0442\u0438\u043f\u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0430\u0434\u043e\u0441\u0442\u0443\u043f\u043d\u043e\u0439\u0442\u0430\u0431\u043b\u0438\u0446\u044b\u0441\u0445\u0435\u043c\u044b\u0437\u0430\u043f\u0440\u043e\u0441\u0430 \u0442\u0438\u043f\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f\u0441\u0445\u0435\u043c\u044b\u0437\u0430\u043f\u0440\u043e\u0441\u0430 http\u043c\u0435\u0442\u043e\u0434 \u0430\u0432\u0442\u043e\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043e\u0431\u0449\u0435\u0433\u043e\u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u0430 \u0430\u0432\u0442\u043e\u043f\u0440\u0435\u0444\u0438\u043a\u0441\u043d\u043e\u043c\u0435\u0440\u0430\u0437\u0430\u0434\u0430\u0447\u0438 \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u0432\u0441\u0442\u0440\u043e\u0435\u043d\u043d\u043e\u0433\u043e\u044f\u0437\u044b\u043a\u0430 \u0432\u0438\u0434\u0438\u0435\u0440\u0430\u0440\u0445\u0438\u0438 \u0432\u0438\u0434\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u043d\u0430\u043a\u043e\u043f\u043b\u0435\u043d\u0438\u044f \u0432\u0438\u0434\u0442\u0430\u0431\u043b\u0438\u0446\u044b\u0432\u043d\u0435\u0448\u043d\u0435\u0433\u043e\u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0437\u0430\u043f\u0438\u0441\u044c\u0434\u0432\u0438\u0436\u0435\u043d\u0438\u0439\u043f\u0440\u0438\u043f\u0440\u043e\u0432\u0435\u0434\u0435\u043d\u0438\u0438 \u0437\u0430\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u0435\u043f\u043e\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043d\u043e\u0441\u0442\u0435\u0439 \u0438\u043d\u0434\u0435\u043a\u0441\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0431\u0430\u0437\u044b\u043f\u043b\u0430\u043d\u0430\u0432\u0438\u0434\u043e\u0432\u0440\u0430\u0441\u0447\u0435\u0442\u0430 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0431\u044b\u0441\u0442\u0440\u043e\u0433\u043e\u0432\u044b\u0431\u043e\u0440\u0430 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043e\u0431\u0449\u0435\u0433\u043e\u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u0430 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043f\u043e\u0434\u0447\u0438\u043d\u0435\u043d\u0438\u044f \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043f\u043e\u043b\u043d\u043e\u0442\u0435\u043a\u0441\u0442\u043e\u0432\u043e\u0433\u043e\u043f\u043e\u0438\u0441\u043a\u0430 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0440\u0430\u0437\u0434\u0435\u043b\u044f\u0435\u043c\u044b\u0445\u0434\u0430\u043d\u043d\u044b\u0445\u043e\u0431\u0449\u0435\u0433\u043e\u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u0430 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u0430 \u043d\u0430\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u043d\u0430\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u044f\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u043d\u0430\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u0435\u0440\u0435\u0434\u0430\u0447\u0438 \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u0440\u0435\u0434\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u043d\u044b\u0445\u0434\u0430\u043d\u043d\u044b\u0445 \u043e\u043f\u0435\u0440\u0430\u0442\u0438\u0432\u043d\u043e\u0435\u043f\u0440\u043e\u0432\u0435\u0434\u0435\u043d\u0438\u0435 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0435\u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u0432\u0438\u0434\u0430\u0440\u0430\u0441\u0447\u0435\u0442\u0430 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0435\u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u0432\u0438\u0434\u0430\u0445\u0430\u0440\u0430\u043a\u0442\u0435\u0440\u0438\u0441\u0442\u0438\u043a\u0438 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0435\u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u0437\u0430\u0434\u0430\u0447\u0438 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0435\u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u043b\u0430\u043d\u0430\u043e\u0431\u043c\u0435\u043d\u0430 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0435\u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u0441\u043f\u0440\u0430\u0432\u043e\u0447\u043d\u0438\u043a\u0430 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0435\u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u0441\u0447\u0435\u0442\u0430 \u043f\u0435\u0440\u0435\u043c\u0435\u0449\u0435\u043d\u0438\u0435\u0433\u0440\u0430\u043d\u0438\u0446\u044b\u043f\u0440\u0438\u043f\u0440\u043e\u0432\u0435\u0434\u0435\u043d\u0438\u0438 \u043f\u0435\u0440\u0438\u043e\u0434\u0438\u0447\u043d\u043e\u0441\u0442\u044c\u043d\u043e\u043c\u0435\u0440\u0430\u0431\u0438\u0437\u043d\u0435\u0441\u043f\u0440\u043e\u0446\u0435\u0441\u0441\u0430 \u043f\u0435\u0440\u0438\u043e\u0434\u0438\u0447\u043d\u043e\u0441\u0442\u044c\u043d\u043e\u043c\u0435\u0440\u0430\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u043f\u0435\u0440\u0438\u043e\u0434\u0438\u0447\u043d\u043e\u0441\u0442\u044c\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0440\u0430\u0441\u0447\u0435\u0442\u0430 \u043f\u0435\u0440\u0438\u043e\u0434\u0438\u0447\u043d\u043e\u0441\u0442\u044c\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0441\u0432\u0435\u0434\u0435\u043d\u0438\u0439 \u043f\u043e\u0432\u0442\u043e\u0440\u043d\u043e\u0435\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0432\u043e\u0437\u0432\u0440\u0430\u0449\u0430\u0435\u043c\u044b\u0445\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439 \u043f\u043e\u043b\u043d\u043e\u0442\u0435\u043a\u0441\u0442\u043e\u0432\u044b\u0439\u043f\u043e\u0438\u0441\u043a\u043f\u0440\u0438\u0432\u0432\u043e\u0434\u0435\u043f\u043e\u0441\u0442\u0440\u043e\u043a\u0435 \u043f\u0440\u0438\u043d\u0430\u0434\u043b\u0435\u0436\u043d\u043e\u0441\u0442\u044c\u043e\u0431\u044a\u0435\u043a\u0442\u0430 \u043f\u0440\u043e\u0432\u0435\u0434\u0435\u043d\u0438\u0435 \u0440\u0430\u0437\u0434\u0435\u043b\u0435\u043d\u0438\u0435\u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438\u043e\u0431\u0449\u0435\u0433\u043e\u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u0430 \u0440\u0430\u0437\u0434\u0435\u043b\u0435\u043d\u0438\u0435\u0434\u0430\u043d\u043d\u044b\u0445\u043e\u0431\u0449\u0435\u0433\u043e\u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u0430 \u0440\u0430\u0437\u0434\u0435\u043b\u0435\u043d\u0438\u0435\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0439\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438\u043e\u0431\u0449\u0435\u0433\u043e\u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u0430 \u0440\u0435\u0436\u0438\u043c\u0430\u0432\u0442\u043e\u043d\u0443\u043c\u0435\u0440\u0430\u0446\u0438\u0438\u043e\u0431\u044a\u0435\u043a\u0442\u043e\u0432 \u0440\u0435\u0436\u0438\u043c\u0437\u0430\u043f\u0438\u0441\u0438\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430 \u0440\u0435\u0436\u0438\u043c\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u043c\u043e\u0434\u0430\u043b\u044c\u043d\u043e\u0441\u0442\u0438 \u0440\u0435\u0436\u0438\u043c\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u0441\u0438\u043d\u0445\u0440\u043e\u043d\u043d\u044b\u0445\u0432\u044b\u0437\u043e\u0432\u043e\u0432\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0439\u043f\u043b\u0430\u0442\u0444\u043e\u0440\u043c\u044b\u0438\u0432\u043d\u0435\u0448\u043d\u0438\u0445\u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442 \u0440\u0435\u0436\u0438\u043c\u043f\u043e\u0432\u0442\u043e\u0440\u043d\u043e\u0433\u043e\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u0441\u0435\u0430\u043d\u0441\u043e\u0432 \u0440\u0435\u0436\u0438\u043c\u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u044f\u0434\u0430\u043d\u043d\u044b\u0445\u0432\u044b\u0431\u043e\u0440\u0430\u043f\u0440\u0438\u0432\u0432\u043e\u0434\u0435\u043f\u043e\u0441\u0442\u0440\u043e\u043a\u0435 \u0440\u0435\u0436\u0438\u043c\u0441\u043e\u0432\u043c\u0435\u0441\u0442\u0438\u043c\u043e\u0441\u0442\u0438 \u0440\u0435\u0436\u0438\u043c\u0441\u043e\u0432\u043c\u0435\u0441\u0442\u0438\u043c\u043e\u0441\u0442\u0438\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430 \u0440\u0435\u0436\u0438\u043c\u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u044f\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u043a\u043e\u0439\u0434\u0430\u043d\u043d\u044b\u0445\u043f\u043e\u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e \u0441\u0435\u0440\u0438\u0438\u043a\u043e\u0434\u043e\u0432\u043f\u043b\u0430\u043d\u0430\u0432\u0438\u0434\u043e\u0432\u0445\u0430\u0440\u0430\u043a\u0442\u0435\u0440\u0438\u0441\u0442\u0438\u043a \u0441\u0435\u0440\u0438\u0438\u043a\u043e\u0434\u043e\u0432\u043f\u043b\u0430\u043d\u0430\u0441\u0447\u0435\u0442\u043e\u0432 \u0441\u0435\u0440\u0438\u0438\u043a\u043e\u0434\u043e\u0432\u0441\u043f\u0440\u0430\u0432\u043e\u0447\u043d\u0438\u043a\u0430 \u0441\u043e\u0437\u0434\u0430\u043d\u0438\u0435\u043f\u0440\u0438\u0432\u0432\u043e\u0434\u0435 \u0441\u043f\u043e\u0441\u043e\u0431\u0432\u044b\u0431\u043e\u0440\u0430 \u0441\u043f\u043e\u0441\u043e\u0431\u043f\u043e\u0438\u0441\u043a\u0430\u0441\u0442\u0440\u043e\u043a\u0438\u043f\u0440\u0438\u0432\u0432\u043e\u0434\u0435\u043f\u043e\u0441\u0442\u0440\u043e\u043a\u0435 \u0441\u043f\u043e\u0441\u043e\u0431\u0440\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u0442\u0438\u043f\u0434\u0430\u043d\u043d\u044b\u0445\u0442\u0430\u0431\u043b\u0438\u0446\u044b\u0432\u043d\u0435\u0448\u043d\u0435\u0433\u043e\u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u043a\u043e\u0434\u0430\u043f\u043b\u0430\u043d\u0430\u0432\u0438\u0434\u043e\u0432\u0440\u0430\u0441\u0447\u0435\u0442\u0430 \u0442\u0438\u043f\u043a\u043e\u0434\u0430\u0441\u043f\u0440\u0430\u0432\u043e\u0447\u043d\u0438\u043a\u0430 \u0442\u0438\u043f\u043c\u0430\u043a\u0435\u0442\u0430 \u0442\u0438\u043f\u043d\u043e\u043c\u0435\u0440\u0430\u0431\u0438\u0437\u043d\u0435\u0441\u043f\u0440\u043e\u0446\u0435\u0441\u0441\u0430 \u0442\u0438\u043f\u043d\u043e\u043c\u0435\u0440\u0430\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u043d\u043e\u043c\u0435\u0440\u0430\u0437\u0430\u0434\u0430\u0447\u0438 \u0442\u0438\u043f\u0444\u043e\u0440\u043c\u044b \u0443\u0434\u0430\u043b\u0435\u043d\u0438\u0435\u0434\u0432\u0438\u0436\u0435\u043d\u0438\u0439 \u0432\u0430\u0436\u043d\u043e\u0441\u0442\u044c\u043f\u0440\u043e\u0431\u043b\u0435\u043c\u044b\u043f\u0440\u0438\u043c\u0435\u043d\u0435\u043d\u0438\u044f\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u044f\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430\u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u043e\u0433\u043e\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u043c\u0430\u0441\u0448\u0442\u0430\u0431\u0430\u0444\u043e\u0440\u043c\u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u043e\u0433\u043e\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0433\u043e\u0448\u0440\u0438\u0444\u0442\u0430\u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u043e\u0433\u043e\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u043e\u0433\u043e\u043f\u0435\u0440\u0438\u043e\u0434\u0430 \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u043e\u0439\u0434\u0430\u0442\u044b\u043d\u0430\u0447\u0430\u043b\u0430 \u0432\u0438\u0434\u0433\u0440\u0430\u043d\u0438\u0446\u044b \u0432\u0438\u0434\u043a\u0430\u0440\u0442\u0438\u043d\u043a\u0438 \u0432\u0438\u0434\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u043f\u043e\u043b\u043d\u043e\u0442\u0435\u043a\u0441\u0442\u043e\u0432\u043e\u0433\u043e\u043f\u043e\u0438\u0441\u043a\u0430 \u0432\u0438\u0434\u0440\u0430\u043c\u043a\u0438 \u0432\u0438\u0434\u0441\u0440\u0430\u0432\u043d\u0435\u043d\u0438\u044f \u0432\u0438\u0434\u0446\u0432\u0435\u0442\u0430 \u0432\u0438\u0434\u0447\u0438\u0441\u043b\u043e\u0432\u043e\u0433\u043e\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f \u0432\u0438\u0434\u0448\u0440\u0438\u0444\u0442\u0430 \u0434\u043e\u043f\u0443\u0441\u0442\u0438\u043c\u0430\u044f\u0434\u043b\u0438\u043d\u0430 \u0434\u043e\u043f\u0443\u0441\u0442\u0438\u043c\u044b\u0439\u0437\u043d\u0430\u043a \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435byteordermark \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043c\u0435\u0442\u0430\u0434\u0430\u043d\u043d\u044b\u0445\u043f\u043e\u043b\u043d\u043e\u0442\u0435\u043a\u0441\u0442\u043e\u0432\u043e\u0433\u043e\u043f\u043e\u0438\u0441\u043a\u0430 \u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0439\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u043a\u043b\u0430\u0432\u0438\u0448\u0430 \u043a\u043e\u0434\u0432\u043e\u0437\u0432\u0440\u0430\u0442\u0430\u0434\u0438\u0430\u043b\u043e\u0433\u0430 \u043a\u043e\u0434\u0438\u0440\u043e\u0432\u043a\u0430xbase \u043a\u043e\u0434\u0438\u0440\u043e\u0432\u043a\u0430\u0442\u0435\u043a\u0441\u0442\u0430 \u043d\u0430\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u043e\u0438\u0441\u043a\u0430 \u043d\u0430\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u0441\u043e\u0440\u0442\u0438\u0440\u043e\u0432\u043a\u0438 \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u0440\u0435\u0434\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u043d\u044b\u0445\u0434\u0430\u043d\u043d\u044b\u0445 \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u0440\u0438\u0438\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u043f\u0430\u043d\u0435\u043b\u0438\u0440\u0430\u0437\u0434\u0435\u043b\u043e\u0432 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0430\u0437\u0430\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f \u0440\u0435\u0436\u0438\u043c\u0434\u0438\u0430\u043b\u043e\u0433\u0430\u0432\u043e\u043f\u0440\u043e\u0441 \u0440\u0435\u0436\u0438\u043c\u0437\u0430\u043f\u0443\u0441\u043a\u0430\u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u043e\u0433\u043e\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0440\u0435\u0436\u0438\u043c\u043e\u043a\u0440\u0443\u0433\u043b\u0435\u043d\u0438\u044f \u0440\u0435\u0436\u0438\u043c\u043e\u0442\u043a\u0440\u044b\u0442\u0438\u044f\u0444\u043e\u0440\u043c\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0440\u0435\u0436\u0438\u043c\u043f\u043e\u043b\u043d\u043e\u0442\u0435\u043a\u0441\u0442\u043e\u0432\u043e\u0433\u043e\u043f\u043e\u0438\u0441\u043a\u0430 \u0441\u043a\u043e\u0440\u043e\u0441\u0442\u044c\u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u043e\u0433\u043e\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0435\u0432\u043d\u0435\u0448\u043d\u0435\u0433\u043e\u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0435\u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u044f\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438\u0431\u0430\u0437\u044b\u0434\u0430\u043d\u043d\u044b\u0445 \u0441\u043f\u043e\u0441\u043e\u0431\u0432\u044b\u0431\u043e\u0440\u0430\u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u0430windows \u0441\u043f\u043e\u0441\u043e\u0431\u043a\u043e\u0434\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f\u0441\u0442\u0440\u043e\u043a\u0438 \u0441\u0442\u0430\u0442\u0443\u0441\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f \u0442\u0438\u043f\u0432\u043d\u0435\u0448\u043d\u0435\u0439\u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u044b \u0442\u0438\u043f\u043f\u043b\u0430\u0442\u0444\u043e\u0440\u043c\u044b \u0442\u0438\u043f\u043f\u043e\u0432\u0435\u0434\u0435\u043d\u0438\u044f\u043a\u043b\u0430\u0432\u0438\u0448\u0438enter \u0442\u0438\u043f\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u0438\u043e\u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u0438\u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u044f\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438\u0431\u0430\u0437\u044b\u0434\u0430\u043d\u043d\u044b\u0445 \u0443\u0440\u043e\u0432\u0435\u043d\u044c\u0438\u0437\u043e\u043b\u044f\u0446\u0438\u0438\u0442\u0440\u0430\u043d\u0437\u0430\u043a\u0446\u0438\u0439 \u0445\u0435\u0448\u0444\u0443\u043d\u043a\u0446\u0438\u044f \u0447\u0430\u0441\u0442\u0438\u0434\u0430\u0442\u044b", +type:"com\u043e\u0431\u044a\u0435\u043a\u0442 ftp\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0435 http\u0437\u0430\u043f\u0440\u043e\u0441 http\u0441\u0435\u0440\u0432\u0438\u0441\u043e\u0442\u0432\u0435\u0442 http\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0435 ws\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u044f ws\u043f\u0440\u043e\u043a\u0441\u0438 xbase \u0430\u043d\u0430\u043b\u0438\u0437\u0434\u0430\u043d\u043d\u044b\u0445 \u0430\u043d\u043d\u043e\u0442\u0430\u0446\u0438\u044fxs \u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u043a\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0431\u0443\u0444\u0435\u0440\u0434\u0432\u043e\u0438\u0447\u043d\u044b\u0445\u0434\u0430\u043d\u043d\u044b\u0445 \u0432\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435xs \u0432\u044b\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0433\u0435\u043d\u0435\u0440\u0430\u0442\u043e\u0440\u0441\u043b\u0443\u0447\u0430\u0439\u043d\u044b\u0445\u0447\u0438\u0441\u0435\u043b \u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u0430\u044f\u0441\u0445\u0435\u043c\u0430 \u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u0438\u0435\u043a\u043e\u043e\u0440\u0434\u0438\u043d\u0430\u0442\u044b \u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u0430\u044f\u0441\u0445\u0435\u043c\u0430 \u0433\u0440\u0443\u043f\u043f\u0430\u043c\u043e\u0434\u0435\u043b\u0438xs \u0434\u0430\u043d\u043d\u044b\u0435\u0440\u0430\u0441\u0448\u0438\u0444\u0440\u043e\u0432\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0434\u0432\u043e\u0438\u0447\u043d\u044b\u0435\u0434\u0430\u043d\u043d\u044b\u0435 \u0434\u0435\u043d\u0434\u0440\u043e\u0433\u0440\u0430\u043c\u043c\u0430 \u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u0430 \u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u0430\u0433\u0430\u043d\u0442\u0430 \u0434\u0438\u0430\u043b\u043e\u0433\u0432\u044b\u0431\u043e\u0440\u0430\u0444\u0430\u0439\u043b\u0430 \u0434\u0438\u0430\u043b\u043e\u0433\u0432\u044b\u0431\u043e\u0440\u0430\u0446\u0432\u0435\u0442\u0430 \u0434\u0438\u0430\u043b\u043e\u0433\u0432\u044b\u0431\u043e\u0440\u0430\u0448\u0440\u0438\u0444\u0442\u0430 \u0434\u0438\u0430\u043b\u043e\u0433\u0440\u0430\u0441\u043f\u0438\u0441\u0430\u043d\u0438\u044f\u0440\u0435\u0433\u043b\u0430\u043c\u0435\u043d\u0442\u043d\u043e\u0433\u043e\u0437\u0430\u0434\u0430\u043d\u0438\u044f \u0434\u0438\u0430\u043b\u043e\u0433\u0440\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f\u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u043e\u0433\u043e\u043f\u0435\u0440\u0438\u043e\u0434\u0430 \u0434\u0438\u0430\u043f\u0430\u0437\u043e\u043d \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442dom \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442html \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u044fxs \u0434\u043e\u0441\u0442\u0430\u0432\u043b\u044f\u0435\u043c\u043e\u0435\u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u0435 \u0437\u0430\u043f\u0438\u0441\u044cdom \u0437\u0430\u043f\u0438\u0441\u044cfastinfoset \u0437\u0430\u043f\u0438\u0441\u044chtml \u0437\u0430\u043f\u0438\u0441\u044cjson \u0437\u0430\u043f\u0438\u0441\u044cxml \u0437\u0430\u043f\u0438\u0441\u044czip\u0444\u0430\u0439\u043b\u0430 \u0437\u0430\u043f\u0438\u0441\u044c\u0434\u0430\u043d\u043d\u044b\u0445 \u0437\u0430\u043f\u0438\u0441\u044c\u0442\u0435\u043a\u0441\u0442\u0430 \u0437\u0430\u043f\u0438\u0441\u044c\u0443\u0437\u043b\u043e\u0432dom \u0437\u0430\u043f\u0440\u043e\u0441 \u0437\u0430\u0449\u0438\u0449\u0435\u043d\u043d\u043e\u0435\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0435openssl \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f\u043f\u043e\u043b\u0435\u0439\u0440\u0430\u0441\u0448\u0438\u0444\u0440\u043e\u0432\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0438\u0437\u0432\u043b\u0435\u0447\u0435\u043d\u0438\u0435\u0442\u0435\u043a\u0441\u0442\u0430 \u0438\u043c\u043f\u043e\u0440\u0442xs \u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u043f\u043e\u0447\u0442\u0430 \u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u043f\u043e\u0447\u0442\u043e\u0432\u043e\u0435\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435 \u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u043f\u043e\u0447\u0442\u043e\u0432\u044b\u0439\u043f\u0440\u043e\u0444\u0438\u043b\u044c \u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u043f\u0440\u043e\u043a\u0441\u0438 \u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0435 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f\u0434\u043b\u044f\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044fxs \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0430\u0442\u0440\u0438\u0431\u0443\u0442\u0430xs \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0441\u043e\u0431\u044b\u0442\u0438\u044f\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0434\u043e\u0441\u0442\u0443\u043f\u043d\u044b\u0445\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0438\u0442\u0435\u0440\u0430\u0442\u043e\u0440\u0443\u0437\u043b\u043e\u0432dom \u043a\u0430\u0440\u0442\u0438\u043d\u043a\u0430 \u043a\u0432\u0430\u043b\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440\u044b\u0434\u0430\u0442\u044b \u043a\u0432\u0430\u043b\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440\u044b\u0434\u0432\u043e\u0438\u0447\u043d\u044b\u0445\u0434\u0430\u043d\u043d\u044b\u0445 \u043a\u0432\u0430\u043b\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440\u044b\u0441\u0442\u0440\u043e\u043a\u0438 \u043a\u0432\u0430\u043b\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440\u044b\u0447\u0438\u0441\u043b\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u0449\u0438\u043a\u043c\u0430\u043a\u0435\u0442\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u0449\u0438\u043a\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043a\u043e\u043d\u0441\u0442\u0440\u0443\u043a\u0442\u043e\u0440\u043c\u0430\u043a\u0435\u0442\u0430\u043e\u0444\u043e\u0440\u043c\u043b\u0435\u043d\u0438\u044f\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043a\u043e\u043d\u0441\u0442\u0440\u0443\u043a\u0442\u043e\u0440\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043a\u043e\u043d\u0441\u0442\u0440\u0443\u043a\u0442\u043e\u0440\u0444\u043e\u0440\u043c\u0430\u0442\u043d\u043e\u0439\u0441\u0442\u0440\u043e\u043a\u0438 \u043b\u0438\u043d\u0438\u044f \u043c\u0430\u043a\u0435\u0442\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043c\u0430\u043a\u0435\u0442\u043e\u0431\u043b\u0430\u0441\u0442\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043c\u0430\u043a\u0435\u0442\u043e\u0444\u043e\u0440\u043c\u043b\u0435\u043d\u0438\u044f\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043c\u0430\u0441\u043a\u0430xs \u043c\u0435\u043d\u0435\u0434\u0436\u0435\u0440\u043a\u0440\u0438\u043f\u0442\u043e\u0433\u0440\u0430\u0444\u0438\u0438 \u043d\u0430\u0431\u043e\u0440\u0441\u0445\u0435\u043cxml \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438\u0441\u0435\u0440\u0438\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u0438json \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0430\u043a\u0430\u0440\u0442\u0438\u043d\u043e\u043a \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0430\u0440\u0430\u0441\u0448\u0438\u0444\u0440\u043e\u0432\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043e\u0431\u0445\u043e\u0434\u0434\u0435\u0440\u0435\u0432\u0430dom \u043e\u0431\u044a\u044f\u0432\u043b\u0435\u043d\u0438\u0435\u0430\u0442\u0440\u0438\u0431\u0443\u0442\u0430xs \u043e\u0431\u044a\u044f\u0432\u043b\u0435\u043d\u0438\u0435\u043d\u043e\u0442\u0430\u0446\u0438\u0438xs \u043e\u0431\u044a\u044f\u0432\u043b\u0435\u043d\u0438\u0435\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430xs \u043e\u043f\u0438\u0441\u0430\u043d\u0438\u0435\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u0441\u043e\u0431\u044b\u0442\u0438\u044f\u0434\u043e\u0441\u0442\u0443\u043f\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u043e\u043f\u0438\u0441\u0430\u043d\u0438\u0435\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u0441\u043e\u0431\u044b\u0442\u0438\u044f\u043e\u0442\u043a\u0430\u0437\u0432\u0434\u043e\u0441\u0442\u0443\u043f\u0435\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u043e\u043f\u0438\u0441\u0430\u043d\u0438\u0435\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0438\u0440\u0430\u0441\u0448\u0438\u0444\u0440\u043e\u0432\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043e\u043f\u0438\u0441\u0430\u043d\u0438\u0435\u043f\u0435\u0440\u0435\u0434\u0430\u0432\u0430\u0435\u043c\u043e\u0433\u043e\u0444\u0430\u0439\u043b\u0430 \u043e\u043f\u0438\u0441\u0430\u043d\u0438\u0435\u0442\u0438\u043f\u043e\u0432 \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u0435\u0433\u0440\u0443\u043f\u043f\u044b\u0430\u0442\u0440\u0438\u0431\u0443\u0442\u043e\u0432xs \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u0435\u0433\u0440\u0443\u043f\u043f\u044b\u043c\u043e\u0434\u0435\u043b\u0438xs \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u0435\u043e\u0433\u0440\u0430\u043d\u0438\u0447\u0435\u043d\u0438\u044f\u0438\u0434\u0435\u043d\u0442\u0438\u0447\u043d\u043e\u0441\u0442\u0438xs \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u0435\u043f\u0440\u043e\u0441\u0442\u043e\u0433\u043e\u0442\u0438\u043f\u0430xs \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u0435\u0441\u043e\u0441\u0442\u0430\u0432\u043d\u043e\u0433\u043e\u0442\u0438\u043f\u0430xs \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u0435\u0442\u0438\u043f\u0430\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430dom \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u044fxpathxs \u043e\u0442\u0431\u043e\u0440\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043f\u0430\u043a\u0435\u0442\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0430\u0435\u043c\u044b\u0445\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u043e\u0432 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0432\u044b\u0431\u043e\u0440\u0430 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b\u0437\u0430\u043f\u0438\u0441\u0438json \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b\u0437\u0430\u043f\u0438\u0441\u0438xml \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b\u0447\u0442\u0435\u043d\u0438\u044fxml \u043f\u0435\u0440\u0435\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u0435xs \u043f\u043b\u0430\u043d\u0438\u0440\u043e\u0432\u0449\u0438\u043a \u043f\u043e\u043b\u0435\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u043f\u043e\u043b\u0435\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043f\u043e\u0441\u0442\u0440\u043e\u0438\u0442\u0435\u043b\u044cdom \u043f\u043e\u0441\u0442\u0440\u043e\u0438\u0442\u0435\u043b\u044c\u0437\u0430\u043f\u0440\u043e\u0441\u0430 \u043f\u043e\u0441\u0442\u0440\u043e\u0438\u0442\u0435\u043b\u044c\u043e\u0442\u0447\u0435\u0442\u0430 \u043f\u043e\u0441\u0442\u0440\u043e\u0438\u0442\u0435\u043b\u044c\u043e\u0442\u0447\u0435\u0442\u0430\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u043f\u043e\u0441\u0442\u0440\u043e\u0438\u0442\u0435\u043b\u044c\u0441\u0445\u0435\u043cxml \u043f\u043e\u0442\u043e\u043a \u043f\u043e\u0442\u043e\u043a\u0432\u043f\u0430\u043c\u044f\u0442\u0438 \u043f\u043e\u0447\u0442\u0430 \u043f\u043e\u0447\u0442\u043e\u0432\u043e\u0435\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435 \u043f\u0440\u0435\u043e\u0431\u0440\u0430\u0437\u043e\u0432\u0430\u043d\u0438\u0435xsl \u043f\u0440\u0435\u043e\u0431\u0440\u0430\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043a\u043a\u0430\u043d\u043e\u043d\u0438\u0447\u0435\u0441\u043a\u043e\u043c\u0443xml \u043f\u0440\u043e\u0446\u0435\u0441\u0441\u043e\u0440\u0432\u044b\u0432\u043e\u0434\u0430\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445\u0432\u043a\u043e\u043b\u043b\u0435\u043a\u0446\u0438\u044e\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439 \u043f\u0440\u043e\u0446\u0435\u0441\u0441\u043e\u0440\u0432\u044b\u0432\u043e\u0434\u0430\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445\u0432\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u044b\u0439\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442 \u043f\u0440\u043e\u0446\u0435\u0441\u0441\u043e\u0440\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0430\u0437\u044b\u043c\u0435\u043d\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043f\u0440\u043e\u0441\u0442\u0440\u0430\u043d\u0441\u0442\u0432\u0438\u043c\u0435\u043ddom \u0440\u0430\u043c\u043a\u0430 \u0440\u0430\u0441\u043f\u0438\u0441\u0430\u043d\u0438\u0435\u0440\u0435\u0433\u043b\u0430\u043c\u0435\u043d\u0442\u043d\u043e\u0433\u043e\u0437\u0430\u0434\u0430\u043d\u0438\u044f \u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u043d\u043e\u0435\u0438\u043c\u044fxml \u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u0447\u0442\u0435\u043d\u0438\u044f\u0434\u0430\u043d\u043d\u044b\u0445 \u0441\u0432\u043e\u0434\u043d\u0430\u044f\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u0430 \u0441\u0432\u044f\u0437\u044c\u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0430\u0432\u044b\u0431\u043e\u0440\u0430 \u0441\u0432\u044f\u0437\u044c\u043f\u043e\u0442\u0438\u043f\u0443 \u0441\u0432\u044f\u0437\u044c\u043f\u043e\u0442\u0438\u043f\u0443\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0441\u0435\u0440\u0438\u0430\u043b\u0438\u0437\u0430\u0442\u043e\u0440xdto \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043a\u043b\u0438\u0435\u043d\u0442\u0430windows \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043a\u043b\u0438\u0435\u043d\u0442\u0430\u0444\u0430\u0439\u043b \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043a\u0440\u0438\u043f\u0442\u043e\u0433\u0440\u0430\u0444\u0438\u0438 \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u044b\u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u044e\u0449\u0438\u0445\u0446\u0435\u043d\u0442\u0440\u043e\u0432windows \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u044b\u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u044e\u0449\u0438\u0445\u0446\u0435\u043d\u0442\u0440\u043e\u0432\u0444\u0430\u0439\u043b \u0441\u0436\u0430\u0442\u0438\u0435\u0434\u0430\u043d\u043d\u044b\u0445 \u0441\u0438\u0441\u0442\u0435\u043c\u043d\u0430\u044f\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044e \u0441\u043e\u0447\u0435\u0442\u0430\u043d\u0438\u0435\u043a\u043b\u0430\u0432\u0438\u0448 \u0441\u0440\u0430\u0432\u043d\u0435\u043d\u0438\u0435\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439 \u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u0430\u044f\u0434\u0430\u0442\u0430\u043d\u0430\u0447\u0430\u043b\u0430 \u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u044b\u0439\u043f\u0435\u0440\u0438\u043e\u0434 \u0441\u0445\u0435\u043c\u0430xml \u0441\u0445\u0435\u043c\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0430\u0431\u043b\u0438\u0447\u043d\u044b\u0439\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442 \u0442\u0435\u043a\u0441\u0442\u043e\u0432\u044b\u0439\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442 \u0442\u0435\u0441\u0442\u0438\u0440\u0443\u0435\u043c\u043e\u0435\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u0442\u0438\u043f\u0434\u0430\u043d\u043d\u044b\u0445xml \u0443\u043d\u0438\u043a\u0430\u043b\u044c\u043d\u044b\u0439\u0438\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440 \u0444\u0430\u0431\u0440\u0438\u043a\u0430xdto \u0444\u0430\u0439\u043b \u0444\u0430\u0439\u043b\u043e\u0432\u044b\u0439\u043f\u043e\u0442\u043e\u043a \u0444\u0430\u0441\u0435\u0442\u0434\u043b\u0438\u043d\u044bxs \u0444\u0430\u0441\u0435\u0442\u043a\u043e\u043b\u0438\u0447\u0435\u0441\u0442\u0432\u0430\u0440\u0430\u0437\u0440\u044f\u0434\u043e\u0432\u0434\u0440\u043e\u0431\u043d\u043e\u0439\u0447\u0430\u0441\u0442\u0438xs \u0444\u0430\u0441\u0435\u0442\u043c\u0430\u043a\u0441\u0438\u043c\u0430\u043b\u044c\u043d\u043e\u0433\u043e\u0432\u043a\u043b\u044e\u0447\u0430\u044e\u0449\u0435\u0433\u043e\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044fxs \u0444\u0430\u0441\u0435\u0442\u043c\u0430\u043a\u0441\u0438\u043c\u0430\u043b\u044c\u043d\u043e\u0433\u043e\u0438\u0441\u043a\u043b\u044e\u0447\u0430\u044e\u0449\u0435\u0433\u043e\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044fxs \u0444\u0430\u0441\u0435\u0442\u043c\u0430\u043a\u0441\u0438\u043c\u0430\u043b\u044c\u043d\u043e\u0439\u0434\u043b\u0438\u043d\u044bxs \u0444\u0430\u0441\u0435\u0442\u043c\u0438\u043d\u0438\u043c\u0430\u043b\u044c\u043d\u043e\u0433\u043e\u0432\u043a\u043b\u044e\u0447\u0430\u044e\u0449\u0435\u0433\u043e\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044fxs \u0444\u0430\u0441\u0435\u0442\u043c\u0438\u043d\u0438\u043c\u0430\u043b\u044c\u043d\u043e\u0433\u043e\u0438\u0441\u043a\u043b\u044e\u0447\u0430\u044e\u0449\u0435\u0433\u043e\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044fxs \u0444\u0430\u0441\u0435\u0442\u043c\u0438\u043d\u0438\u043c\u0430\u043b\u044c\u043d\u043e\u0439\u0434\u043b\u0438\u043d\u044bxs \u0444\u0430\u0441\u0435\u0442\u043e\u0431\u0440\u0430\u0437\u0446\u0430xs \u0444\u0430\u0441\u0435\u0442\u043e\u0431\u0449\u0435\u0433\u043e\u043a\u043e\u043b\u0438\u0447\u0435\u0441\u0442\u0432\u0430\u0440\u0430\u0437\u0440\u044f\u0434\u043e\u0432xs \u0444\u0430\u0441\u0435\u0442\u043f\u0435\u0440\u0435\u0447\u0438\u0441\u043b\u0435\u043d\u0438\u044fxs \u0444\u0430\u0441\u0435\u0442\u043f\u0440\u043e\u0431\u0435\u043b\u044c\u043d\u044b\u0445\u0441\u0438\u043c\u0432\u043e\u043b\u043e\u0432xs \u0444\u0438\u043b\u044c\u0442\u0440\u0443\u0437\u043b\u043e\u0432dom \u0444\u043e\u0440\u043c\u0430\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u0430\u044f\u0441\u0442\u0440\u043e\u043a\u0430 \u0444\u043e\u0440\u043c\u0430\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u044b\u0439\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442 \u0444\u0440\u0430\u0433\u043c\u0435\u043d\u0442xs \u0445\u0435\u0448\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435\u0434\u0430\u043d\u043d\u044b\u0445 \u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0435\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f \u0446\u0432\u0435\u0442 \u0447\u0442\u0435\u043d\u0438\u0435fastinfoset \u0447\u0442\u0435\u043d\u0438\u0435html \u0447\u0442\u0435\u043d\u0438\u0435json \u0447\u0442\u0435\u043d\u0438\u0435xml \u0447\u0442\u0435\u043d\u0438\u0435zip\u0444\u0430\u0439\u043b\u0430 \u0447\u0442\u0435\u043d\u0438\u0435\u0434\u0430\u043d\u043d\u044b\u0445 \u0447\u0442\u0435\u043d\u0438\u0435\u0442\u0435\u043a\u0441\u0442\u0430 \u0447\u0442\u0435\u043d\u0438\u0435\u0443\u0437\u043b\u043e\u0432dom \u0448\u0440\u0438\u0444\u0442 \u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 comsafearray \u0434\u0435\u0440\u0435\u0432\u043e\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439 \u043c\u0430\u0441\u0441\u0438\u0432 \u0441\u043e\u043e\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0438\u0435 \u0441\u043f\u0438\u0441\u043e\u043a\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439 \u0441\u0442\u0440\u0443\u043a\u0442\u0443\u0440\u0430 \u0442\u0430\u0431\u043b\u0438\u0446\u0430\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439 \u0444\u0438\u043a\u0441\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u0430\u044f\u0441\u0442\u0440\u0443\u043a\u0442\u0443\u0440\u0430 \u0444\u0438\u043a\u0441\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u043e\u0435\u0441\u043e\u043e\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0438\u0435 \u0444\u0438\u043a\u0441\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u044b\u0439\u043c\u0430\u0441\u0441\u0438\u0432 ",literal:n},contains:[{className:"meta",lexemes:t,begin:"#|&",end:"$",keywords:{"meta-keyword":a+"\u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044c\u0438\u0437\u0444\u0430\u0439\u043b\u0430 \u0432\u0435\u0431\u043a\u043b\u0438\u0435\u043d\u0442 \u0432\u043c\u0435\u0441\u0442\u043e \u0432\u043d\u0435\u0448\u043d\u0435\u0435\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0435 \u043a\u043b\u0438\u0435\u043d\u0442 \u043a\u043e\u043d\u0435\u0446\u043e\u0431\u043b\u0430\u0441\u0442\u0438 \u043c\u043e\u0431\u0438\u043b\u044c\u043d\u043e\u0435\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043a\u043b\u0438\u0435\u043d\u0442 \u043c\u043e\u0431\u0438\u043b\u044c\u043d\u043e\u0435\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0441\u0435\u0440\u0432\u0435\u0440 \u043d\u0430\u043a\u043b\u0438\u0435\u043d\u0442\u0435 \u043d\u0430\u043a\u043b\u0438\u0435\u043d\u0442\u0435\u043d\u0430\u0441\u0435\u0440\u0432\u0435\u0440\u0435 \u043d\u0430\u043a\u043b\u0438\u0435\u043d\u0442\u0435\u043d\u0430\u0441\u0435\u0440\u0432\u0435\u0440\u0435\u0431\u0435\u0437\u043a\u043e\u043d\u0442\u0435\u043a\u0441\u0442\u0430 \u043d\u0430\u0441\u0435\u0440\u0432\u0435\u0440\u0435 \u043d\u0430\u0441\u0435\u0440\u0432\u0435\u0440\u0435\u0431\u0435\u0437\u043a\u043e\u043d\u0442\u0435\u043a\u0441\u0442\u0430 \u043e\u0431\u043b\u0430\u0441\u0442\u044c \u043f\u0435\u0440\u0435\u0434 \u043f\u043e\u0441\u043b\u0435 \u0441\u0435\u0440\u0432\u0435\u0440 \u0442\u043e\u043b\u0441\u0442\u044b\u0439\u043a\u043b\u0438\u0435\u043d\u0442\u043e\u0431\u044b\u0447\u043d\u043e\u0435\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u0442\u043e\u043b\u0441\u0442\u044b\u0439\u043a\u043b\u0438\u0435\u043d\u0442\u0443\u043f\u0440\u0430\u0432\u043b\u044f\u0435\u043c\u043e\u0435\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u0442\u043e\u043d\u043a\u0438\u0439\u043a\u043b\u0438\u0435\u043d\u0442 "},contains:[s]},{className:"function",lexemes:t,variants:[{begin:"\u043f\u0440\u043e\u0446\u0435\u0434\u0443\u0440\u0430|\u0444\u0443\u043d\u043a\u0446\u0438\u044f",end:"\\)",keywords:"\u043f\u0440\u043e\u0446\u0435\u0434\u0443\u0440\u0430 \u0444\u0443\u043d\u043a\u0446\u0438\u044f"},{begin:"\u043a\u043e\u043d\u0435\u0446\u043f\u0440\u043e\u0446\u0435\u0434\u0443\u0440\u044b|\u043a\u043e\u043d\u0435\u0446\u0444\u0443\u043d\u043a\u0446\u0438\u0438",keywords:"\u043a\u043e\u043d\u0435\u0446\u043f\u0440\u043e\u0446\u0435\u0434\u0443\u0440\u044b \u043a\u043e\u043d\u0435\u0446\u0444\u0443\u043d\u043a\u0446\u0438\u0438"}],contains:[{begin:"\\(",end:"\\)",endsParent:!0,contains:[{className:"params",lexemes:t,begin:t,end:",",excludeEnd:!0,endsWithParent:!0,keywords:{keyword:"\u0437\u043d\u0430\u0447",literal:n},contains:[r,i,o]},s]},e.inherit(e.TITLE_MODE,{begin:t})]},s,{className:"symbol",begin:"~",end:";|:",excludeEnd:!0},r,i,o]}},qr=function(e){var t="^[a-zA-Z][a-zA-Z0-9-]*",a="[!@#$^&',?+~`|:]",n=e.COMMENT(";","$"),r={className:"attribute",begin:t+"(?=\\s*=)"};return{name:"Augmented Backus-Naur Form",illegal:a,keywords:["ALPHA","BIT","CHAR","CR","CRLF","CTL","DIGIT","DQUOTE","HEXDIG","HTAB","LF","LWSP","OCTET","SP","VCHAR","WSP"].join(" "),contains:[r,n,{className:"symbol",begin:/%b[0-1]+(-[0-1]+|(\.[0-1]+)+){0,1}/},{className:"symbol",begin:/%d[0-9]+(-[0-9]+|(\.[0-9]+)+){0,1}/},{className:"symbol",begin:/%x[0-9A-F]+(-[0-9A-F]+|(\.[0-9A-F]+)+){0,1}/},{className:"symbol",begin:/%[si]/},e.QUOTE_STRING_MODE,e.NUMBER_MODE]}},zr=function(){var e=["GET","POST","HEAD","PUT","DELETE","CONNECT","OPTIONS","PATCH","TRACE"];return{name:"Apache Access Log",contains:[{className:"number",begin:"^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}(:\\d{1,5})?\\b",relevance:5},{className:"number",begin:"\\b\\d+\\b",relevance:0},{className:"string",begin:'"('+e.join("|")+")",end:'"',keywords:e.join(" "),illegal:"\\n",relevance:5,contains:[{begin:"HTTP/[12]\\.\\d",relevance:5}]},{className:"string",begin:/\[\d[^\]\n]{8,}\]/,illegal:"\\n",relevance:1},{className:"string",begin:/\[/,end:/\]/,illegal:"\\n",relevance:0},{className:"string",begin:'"Mozilla/\\d\\.\\d \\(',end:'"',illegal:"\\n",relevance:3},{className:"string",begin:'"',end:'"',illegal:"\\n",relevance:0}]}},Wr=function(e){var t={className:"rest_arg",begin:"[.]{3}",end:"[a-zA-Z_$][a-zA-Z0-9_$]*",relevance:10};return{name:"ActionScript",aliases:["as"],keywords:{keyword:"as break case catch class const continue default delete do dynamic each else extends final finally for function get if implements import in include instanceof interface internal is namespace native new override package private protected public return set static super switch this throw try typeof use var void while with",literal:"true false null undefined"},contains:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.C_NUMBER_MODE,{className:"class",beginKeywords:"package",end:"{",contains:[e.TITLE_MODE]},{className:"class",beginKeywords:"class interface",end:"{",excludeEnd:!0,contains:[{beginKeywords:"extends implements"},e.TITLE_MODE]},{className:"meta",beginKeywords:"import include",end:";",keywords:{"meta-keyword":"import include"}},{className:"function",beginKeywords:"function",end:"[{;]",excludeEnd:!0,illegal:"\\S",contains:[e.TITLE_MODE,{className:"params",begin:"\\(",end:"\\)",contains:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,t]},{begin:":\\s*([*]|[a-zA-Z_$][a-zA-Z0-9_$]*)"}]},e.METHOD_GUARD],illegal:/#/}},Qr=function(e){var t="[A-Za-z](_?[A-Za-z0-9.])*",a=e.COMMENT("--","$"),n={begin:"\\s+:\\s+",end:"\\s*(:=|;|\\)|=>|$)",illegal:"[]{}%#'\"",contains:[{beginKeywords:"loop for declare others",endsParent:!0},{className:"keyword",beginKeywords:"not null constant access function procedure in out aliased exception"},{className:"type",begin:t,endsParent:!0,relevance:0}]};return{name:"Ada",case_insensitive:!0,keywords:{keyword:"abort else new return abs elsif not reverse abstract end accept entry select access exception of separate aliased exit or some all others subtype and for out synchronized array function overriding at tagged generic package task begin goto pragma terminate body private then if procedure type case in protected constant interface is raise use declare range delay limited record when delta loop rem while digits renames with do mod requeue xor",literal:"True False"},contains:[a,{className:"string",begin:/"/,end:/"/,contains:[{begin:/""/,relevance:0}]},{className:"string",begin:/'.'/},{className:"number",begin:"\\b(\\d(_|\\d)*#\\w+(\\.\\w+)?#([eE][-+]?\\d(_|\\d)*)?|\\d(_|\\d)*(\\.\\d(_|\\d)*)?([eE][-+]?\\d(_|\\d)*)?)",relevance:0},{className:"symbol",begin:"'"+t},{className:"title",begin:"(\\bwith\\s+)?(\\bprivate\\s+)?\\bpackage\\s+(\\bbody\\s+)?",end:"(is|$)",keywords:"package body",excludeBegin:!0,excludeEnd:!0,illegal:"[]{}%#'\""},{begin:"(\\b(with|overriding)\\s+)?\\b(function|procedure)\\s+",end:"(\\bis|\\bwith|\\brenames|\\)\\s*;)",keywords:"overriding function procedure with is renames return",returnBegin:!0,contains:[a,{className:"title",begin:"(\\bwith\\s+)?\\b(function|procedure)\\s+",end:"(\\(|\\s+|$)",excludeBegin:!0,excludeEnd:!0,illegal:"[]{}%#'\""},n,{className:"type",begin:"\\breturn\\s+",end:"(\\s+|;|$)",keywords:"return",excludeBegin:!0,excludeEnd:!0,endsParent:!0,illegal:"[]{}%#'\""}]},{className:"type",begin:"\\b(sub)?type\\s+",end:"\\s+",keywords:"type",excludeBegin:!0,illegal:"[]{}%#'\""},n]}},$r=function(e){var t={className:"built_in",begin:"\\b(void|bool|int|int8|int16|int32|int64|uint|uint8|uint16|uint32|uint64|string|ref|array|double|float|auto|dictionary)"},a={className:"symbol",begin:"[a-zA-Z0-9_]+@"},n={className:"keyword",begin:"<",end:">",contains:[t,a]};return t.contains=[n],a.contains=[n],{name:"AngelScript",aliases:["asc"],keywords:"for in|0 break continue while do|0 return if else case switch namespace is cast or and xor not get|0 in inout|10 out override set|0 private public const default|0 final shared external mixin|10 enum typedef funcdef this super import from interface abstract|0 try catch protected explicit property",illegal:"(^using\\s+[A-Za-z0-9_\\.]+;$|\\bfunctions*[^\\(])",contains:[{className:"string",begin:"'",end:"'",illegal:"\\n",contains:[e.BACKSLASH_ESCAPE],relevance:0},{className:"string",begin:'"',end:'"',illegal:"\\n",contains:[e.BACKSLASH_ESCAPE],relevance:0},{className:"string",begin:'"""',end:'"""'},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{beginKeywords:"interface namespace",end:"{",illegal:"[;.\\-]",contains:[{className:"symbol",begin:"[a-zA-Z0-9_]+"}]},{beginKeywords:"class",end:"{",illegal:"[;.\\-]",contains:[{className:"symbol",begin:"[a-zA-Z0-9_]+",contains:[{begin:"[:,]\\s*",contains:[{className:"symbol",begin:"[a-zA-Z0-9_]+"}]}]}]},t,a,{className:"literal",begin:"\\b(null|true|false)"},{className:"number",begin:"(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?f?|\\.\\d+f?)([eE][-+]?\\d+f?)?)"}]}},Kr=function(e){var t={className:"number",begin:"\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}(:\\d{1,5})?"};return{name:"Apache config",aliases:["apacheconf"],case_insensitive:!0,contains:[e.HASH_COMMENT_MODE,{className:"section",begin:"",contains:[t,{className:"number",begin:":\\d{1,5}"},e.inherit(e.QUOTE_STRING_MODE,{relevance:0})]},{className:"attribute",begin:/\w+/,relevance:0,keywords:{nomarkup:"order deny allow setenv rewriterule rewriteengine rewritecond documentroot sethandler errordocument loadmodule options header listen serverroot servername"},starts:{end:/$/,relevance:0,keywords:{literal:"on off all deny allow"},contains:[{className:"meta",begin:"\\s\\[",end:"\\]$"},{className:"variable",begin:"[\\$%]\\{",end:"\\}",contains:["self",{className:"number",begin:"[\\$%]\\d+"}]},t,{className:"number",begin:"\\d+"},e.QUOTE_STRING_MODE]}}],illegal:/\S/}},jr=function(e){var t=e.inherit(e.QUOTE_STRING_MODE,{illegal:""}),a={className:"params",begin:"\\(",end:"\\)",contains:["self",e.C_NUMBER_MODE,t]},n=e.COMMENT("--","$"),r=[n,e.COMMENT("\\(\\*","\\*\\)",{contains:["self",n]}),e.HASH_COMMENT_MODE];return{name:"AppleScript",aliases:["osascript"],keywords:{keyword:"about above after against and around as at back before beginning behind below beneath beside between but by considering contain contains continue copy div does eighth else end equal equals error every exit fifth first for fourth from front get given global if ignoring in into is it its last local me middle mod my ninth not of on onto or over prop property put ref reference repeat returning script second set seventh since sixth some tell tenth that the|0 then third through thru timeout times to transaction try until where while whose with without",literal:"AppleScript false linefeed return pi quote result space tab true", +built_in:"alias application boolean class constant date file integer list number real record string text activate beep count delay launch log offset read round run say summarize write character characters contents day frontmost id item length month name paragraph paragraphs rest reverse running time version weekday word words year"},contains:[t,e.C_NUMBER_MODE,{className:"built_in",begin:"\\b(clipboard info|the clipboard|info for|list (disks|folder)|mount volume|path to|(close|open for) access|(get|set) eof|current date|do shell script|get volume settings|random number|set volume|system attribute|system info|time to GMT|(load|run|store) script|scripting components|ASCII (character|number)|localized string|choose (application|color|file|file name|folder|from list|remote application|URL)|display (alert|dialog))\\b|^\\s*return\\b"},{className:"literal",begin:"\\b(text item delimiters|current application|missing value)\\b"},{className:"keyword",begin:"\\b(apart from|aside from|instead of|out of|greater than|isn't|(doesn't|does not) (equal|come before|come after|contain)|(greater|less) than( or equal)?|(starts?|ends|begins?) with|contained by|comes (before|after)|a (ref|reference)|POSIX file|POSIX path|(date|time) string|quoted form)\\b"},{beginKeywords:"on",illegal:"[${=;\\n]",contains:[e.UNDERSCORE_TITLE_MODE,a]}].concat(r),illegal:"//|->|=>|\\[\\["}},Xr=function(e){var t="[A-Za-z_][0-9A-Za-z_]*",a={keyword:"if for while var new function do return void else break",literal:"BackSlash DoubleQuote false ForwardSlash Infinity NaN NewLine null PI SingleQuote Tab TextFormatting true undefined",built_in:"Abs Acos Angle Attachments Area AreaGeodetic Asin Atan Atan2 Average Bearing Boolean Buffer BufferGeodetic Ceil Centroid Clip Console Constrain Contains Cos Count Crosses Cut Date DateAdd DateDiff Day Decode DefaultValue Dictionary Difference Disjoint Distance DistanceGeodetic Distinct DomainCode DomainName Equals Exp Extent Feature FeatureSet FeatureSetByAssociation FeatureSetById FeatureSetByPortalItem FeatureSetByRelationshipName FeatureSetByTitle FeatureSetByUrl Filter First Floor Geometry GroupBy Guid HasKey Hour IIf IndexOf Intersection Intersects IsEmpty IsNan IsSelfIntersecting Length LengthGeodetic Log Max Mean Millisecond Min Minute Month MultiPartToSinglePart Multipoint NextSequenceValue Now Number OrderBy Overlaps Point Polygon Polyline Portal Pow Random Relate Reverse RingIsClockWise Round Second SetGeometry Sin Sort Sqrt Stdev Sum SymmetricDifference Tan Text Timestamp Today ToLocal Top Touches ToUTC TrackCurrentTime TrackGeometryWindow TrackIndex TrackStartTime TrackWindow TypeOf Union UrlEncode Variance Weekday When Within Year "},n={className:"number",variants:[{begin:"\\b(0[bB][01]+)"},{begin:"\\b(0[oO][0-7]+)"},{begin:e.C_NUMBER_RE}],relevance:0},r={className:"subst",begin:"\\$\\{",end:"\\}",keywords:a,contains:[]},i={className:"string",begin:"`",end:"`",contains:[e.BACKSLASH_ESCAPE,r]};r.contains=[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,i,n,e.REGEXP_MODE];var o=r.contains.concat([e.C_BLOCK_COMMENT_MODE,e.C_LINE_COMMENT_MODE]);return{name:"ArcGIS Arcade",aliases:["arcade"],keywords:a,contains:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,i,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{className:"symbol",begin:"\\$[datastore|feature|layer|map|measure|sourcefeature|sourcelayer|targetfeature|targetlayer|value|view]+"},n,{begin:/[{,]\s*/,relevance:0,contains:[{begin:t+"\\s*:",returnBegin:!0,relevance:0,contains:[{className:"attr",begin:t,relevance:0}]}]},{begin:"("+e.RE_STARTERS_RE+"|\\b(return)\\b)\\s*",keywords:"return",contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.REGEXP_MODE,{className:"function",begin:"(\\(.*?\\)|"+t+")\\s*=>",returnBegin:!0,end:"\\s*=>",contains:[{className:"params",variants:[{begin:t},{begin:/\(\s*\)/},{begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:a,contains:o}]}]}],relevance:0},{className:"function",beginKeywords:"function",end:/\{/,excludeEnd:!0,contains:[e.inherit(e.TITLE_MODE,{begin:t}),{className:"params",begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,contains:o}],illegal:/\[|%/},{begin:/\$[(.]/}],illegal:/#(?!!)/}},Zr=function(e){function t(e){return"(?:"+e+")?"}var a="(decltype\\(auto\\)|"+t("[a-zA-Z_]\\w*::")+"[a-zA-Z_]\\w*"+t("<.*?>")+")",n={className:"keyword",begin:"\\b[a-z\\d_]*_t\\b"},r={className:"string",variants:[{begin:'(u8?|U|L)?"',end:'"',illegal:"\\n",contains:[e.BACKSLASH_ESCAPE]},{begin:"(u8?|U|L)?'(\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4,8}|[0-7]{3}|\\S)|.)",end:"'",illegal:"."},{begin:/(?:u8?|U|L)?R"([^()\\ ]{0,16})\((?:.|\n)*?\)\1"/}]},i={className:"number",variants:[{begin:"\\b(0b[01']+)"},{begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)(u|U|l|L|ul|UL|f|F|b|B)"},{begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)"}],relevance:0},o={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{"meta-keyword":"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef include"},contains:[{begin:/\\\n/,relevance:0},e.inherit(r,{className:"meta-string"}),{className:"meta-string",begin:/<.*?>/,end:/$/,illegal:"\\n"},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},s={className:"title",begin:t("[a-zA-Z_]\\w*::")+e.IDENT_RE,relevance:0},l=t("[a-zA-Z_]\\w*::")+e.IDENT_RE+"\\s*\\(",_={keyword:"int float while private char char8_t char16_t char32_t catch import module export virtual operator sizeof dynamic_cast|10 typedef const_cast|10 const for static_cast|10 union namespace unsigned long volatile static protected bool template mutable if public friend do goto auto void enum else break extern using asm case typeid wchar_t short reinterpret_cast|10 default double register explicit signed typename try this switch continue inline delete alignas alignof constexpr consteval constinit decltype concept co_await co_return co_yield requires noexcept static_assert thread_local restrict final override atomic_bool atomic_char atomic_schar atomic_uchar atomic_short atomic_ushort atomic_int atomic_uint atomic_long atomic_ulong atomic_llong atomic_ullong new throw return and and_eq bitand bitor compl not not_eq or or_eq xor xor_eq",built_in:"std string wstring cin cout cerr clog stdin stdout stderr stringstream istringstream ostringstream auto_ptr deque list queue stack vector map set bitset multiset multimap unordered_set unordered_map unordered_multiset unordered_multimap array shared_ptr abort terminate abs acos asin atan2 atan calloc ceil cosh cos exit exp fabs floor fmod fprintf fputs free frexp fscanf future isalnum isalpha iscntrl isdigit isgraph islower isprint ispunct isspace isupper isxdigit tolower toupper labs ldexp log10 log malloc realloc memchr memcmp memcpy memset modf pow printf putchar puts scanf sinh sin snprintf sprintf sqrt sscanf strcat strchr strcmp strcpy strcspn strlen strncat strncmp strncpy strpbrk strrchr strspn strstr tanh tan vfprintf vprintf vsprintf endl initializer_list unique_ptr _Bool complex _Complex imaginary _Imaginary",literal:"true false nullptr NULL"},c=[n,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,i,r],d={variants:[{begin:/=/,end:/;/},{begin:/\(/,end:/\)/},{beginKeywords:"new throw return else",end:/;/}],keywords:_,contains:c.concat([{begin:/\(/,end:/\)/,keywords:_,contains:c.concat(["self"]),relevance:0}]),relevance:0},u={className:"function",begin:"("+a+"[\\*&\\s]+)+"+l,returnBegin:!0,end:/[{;=]/,excludeEnd:!0,keywords:_,illegal:/[^\w\s\*&:<>]/,contains:[{begin:"decltype\\(auto\\)",keywords:_,relevance:0},{begin:l,returnBegin:!0,contains:[s],relevance:0},{className:"params",begin:/\(/,end:/\)/,keywords:_,relevance:0,contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,r,i,n,{begin:/\(/,end:/\)/,keywords:_,relevance:0,contains:["self",e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,r,i,n]}]},n,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,o]};return{aliases:["c","cc","h","c++","h++","hpp","hh","hxx","cxx"],keywords:_,disableAutodetect:!0,illegal:"",keywords:_,contains:["self",n]},{begin:e.IDENT_RE+"::",keywords:_},{className:"class",beginKeywords:"class struct",end:/[{;:]/,contains:[{begin://,contains:["self"]},e.TITLE_MODE]}]),exports:{preprocessor:o,strings:r,keywords:_}}},Jr=function(e){var t=e.getLanguage("c-like").rawDefinition();return t.disableAutodetect=!1,t.name="C++",t.aliases=["cc","c++","h++","hpp","hh","hxx","cxx"],t},ei=function(e){var t="boolean byte word String",a="setup loop KeyboardController MouseController SoftwareSerial EthernetServer EthernetClient LiquidCrystal RobotControl GSMVoiceCall EthernetUDP EsploraTFT HttpClient RobotMotor WiFiClient GSMScanner FileSystem Scheduler GSMServer YunClient YunServer IPAddress GSMClient GSMModem Keyboard Ethernet Console GSMBand Esplora Stepper Process WiFiUDP GSM_SMS Mailbox USBHost Firmata PImage Client Server GSMPIN FileIO Bridge Serial EEPROM Stream Mouse Audio Servo File Task GPRS WiFi Wire TFT GSM SPI SD runShellCommandAsynchronously analogWriteResolution retrieveCallingNumber printFirmwareVersion analogReadResolution sendDigitalPortPair noListenOnLocalhost readJoystickButton setFirmwareVersion readJoystickSwitch scrollDisplayRight getVoiceCallStatus scrollDisplayLeft writeMicroseconds delayMicroseconds beginTransmission getSignalStrength runAsynchronously getAsynchronously listenOnLocalhost getCurrentCarrier readAccelerometer messageAvailable sendDigitalPorts lineFollowConfig countryNameWrite runShellCommand readStringUntil rewindDirectory readTemperature setClockDivider readLightSensor endTransmission analogReference detachInterrupt countryNameRead attachInterrupt encryptionType readBytesUntil robotNameWrite readMicrophone robotNameRead cityNameWrite userNameWrite readJoystickY readJoystickX mouseReleased openNextFile scanNetworks noInterrupts digitalWrite beginSpeaker mousePressed isActionDone mouseDragged displayLogos noAutoscroll addParameter remoteNumber getModifiers keyboardRead userNameRead waitContinue processInput parseCommand printVersion readNetworks writeMessage blinkVersion cityNameRead readMessage setDataMode parsePacket isListening setBitOrder beginPacket isDirectory motorsWrite drawCompass digitalRead clearScreen serialEvent rightToLeft setTextSize leftToRight requestFrom keyReleased compassRead analogWrite interrupts WiFiServer disconnect playMelody parseFloat autoscroll getPINUsed setPINUsed setTimeout sendAnalog readSlider analogRead beginWrite createChar motorsStop keyPressed tempoWrite readButton subnetMask debugPrint macAddress writeGreen randomSeed attachGPRS readString sendString remotePort releaseAll mouseMoved background getXChange getYChange answerCall getResult voiceCall endPacket constrain getSocket writeJSON getButton available connected findUntil readBytes exitValue readGreen writeBlue startLoop IPAddress isPressed sendSysex pauseMode gatewayIP setCursor getOemKey tuneWrite noDisplay loadImage switchPIN onRequest onReceive changePIN playFile noBuffer parseInt overflow checkPIN knobRead beginTFT bitClear updateIR bitWrite position writeRGB highByte writeRed setSpeed readBlue noStroke remoteIP transfer shutdown hangCall beginSMS endWrite attached maintain noCursor checkReg checkPUK shiftOut isValid shiftIn pulseIn connect println localIP pinMode getIMEI display noBlink process getBand running beginSD drawBMP lowByte setBand release bitRead prepare pointTo readRed setMode noFill remove listen stroke detach attach noTone exists buffer height bitSet circle config cursor random IRread setDNS endSMS getKey micros millis begin print write ready flush width isPIN blink clear press mkdir rmdir close point yield image BSSID click delay read text move peek beep rect line open seek fill size turn stop home find step tone sqrt RSSI SSID end bit tan cos sin pow map abs max min get run put",n="DIGITAL_MESSAGE FIRMATA_STRING ANALOG_MESSAGE REPORT_DIGITAL REPORT_ANALOG INPUT_PULLUP SET_PIN_MODE INTERNAL2V56 SYSTEM_RESET LED_BUILTIN INTERNAL1V1 SYSEX_START INTERNAL EXTERNAL DEFAULT OUTPUT INPUT HIGH LOW",r=e.requireLanguage("cpp").rawDefinition(),i=r.keywords;return i.keyword+=" "+t,i.literal+=" "+n,i.built_in+=" "+a,r.name="Arduino",r},ti=function(e){var t={variants:[e.COMMENT("^[ \\t]*(?=#)","$",{relevance:0,excludeBegin:!0}),e.COMMENT("[;@]","$",{relevance:0}),e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]};return{name:"ARM Assembly",case_insensitive:!0,aliases:["arm"],lexemes:"\\.?"+e.IDENT_RE,keywords:{meta:".2byte .4byte .align .ascii .asciz .balign .byte .code .data .else .end .endif .endm .endr .equ .err .exitm .extern .global .hword .if .ifdef .ifndef .include .irp .long .macro .rept .req .section .set .skip .space .text .word .arm .thumb .code16 .code32 .force_thumb .thumb_func .ltorg ALIAS ALIGN ARM AREA ASSERT ATTR CN CODE CODE16 CODE32 COMMON CP DATA DCB DCD DCDU DCDO DCFD DCFDU DCI DCQ DCQU DCW DCWU DN ELIF ELSE END ENDFUNC ENDIF ENDP ENTRY EQU EXPORT EXPORTAS EXTERN FIELD FILL FUNCTION GBLA GBLL GBLS GET GLOBAL IF IMPORT INCBIN INCLUDE INFO KEEP LCLA LCLL LCLS LTORG MACRO MAP MEND MEXIT NOFP OPT PRESERVE8 PROC QN READONLY RELOC REQUIRE REQUIRE8 RLIST FN ROUT SETA SETL SETS SN SPACE SUBT THUMB THUMBX TTL WHILE WEND ",built_in:"r0 r1 r2 r3 r4 r5 r6 r7 r8 r9 r10 r11 r12 r13 r14 r15 pc lr sp ip sl sb fp a1 a2 a3 a4 v1 v2 v3 v4 v5 v6 v7 v8 f0 f1 f2 f3 f4 f5 f6 f7 p0 p1 p2 p3 p4 p5 p6 p7 p8 p9 p10 p11 p12 p13 p14 p15 c0 c1 c2 c3 c4 c5 c6 c7 c8 c9 c10 c11 c12 c13 c14 c15 q0 q1 q2 q3 q4 q5 q6 q7 q8 q9 q10 q11 q12 q13 q14 q15 cpsr_c cpsr_x cpsr_s cpsr_f cpsr_cx cpsr_cxs cpsr_xs cpsr_xsf cpsr_sf cpsr_cxsf spsr_c spsr_x spsr_s spsr_f spsr_cx spsr_cxs spsr_xs spsr_xsf spsr_sf spsr_cxsf s0 s1 s2 s3 s4 s5 s6 s7 s8 s9 s10 s11 s12 s13 s14 s15 s16 s17 s18 s19 s20 s21 s22 s23 s24 s25 s26 s27 s28 s29 s30 s31 d0 d1 d2 d3 d4 d5 d6 d7 d8 d9 d10 d11 d12 d13 d14 d15 d16 d17 d18 d19 d20 d21 d22 d23 d24 d25 d26 d27 d28 d29 d30 d31 {PC} {VAR} {TRUE} {FALSE} {OPT} {CONFIG} {ENDIAN} {CODESIZE} {CPU} {FPU} {ARCHITECTURE} {PCSTOREOFFSET} {ARMASM_VERSION} {INTER} {ROPI} {RWPI} {SWST} {NOSWST} . @"},contains:[{className:"keyword",begin:"\\b(adc|(qd?|sh?|u[qh]?)?add(8|16)?|usada?8|(q|sh?|u[qh]?)?(as|sa)x|and|adrl?|sbc|rs[bc]|asr|b[lx]?|blx|bxj|cbn?z|tb[bh]|bic|bfc|bfi|[su]bfx|bkpt|cdp2?|clz|clrex|cmp|cmn|cpsi[ed]|cps|setend|dbg|dmb|dsb|eor|isb|it[te]{0,3}|lsl|lsr|ror|rrx|ldm(([id][ab])|f[ds])?|ldr((s|ex)?[bhd])?|movt?|mvn|mra|mar|mul|[us]mull|smul[bwt][bt]|smu[as]d|smmul|smmla|mla|umlaal|smlal?([wbt][bt]|d)|mls|smlsl?[ds]|smc|svc|sev|mia([bt]{2}|ph)?|mrr?c2?|mcrr2?|mrs|msr|orr|orn|pkh(tb|bt)|rbit|rev(16|sh)?|sel|[su]sat(16)?|nop|pop|push|rfe([id][ab])?|stm([id][ab])?|str(ex)?[bhd]?|(qd?)?sub|(sh?|q|u[qh]?)?sub(8|16)|[su]xt(a?h|a?b(16)?)|srs([id][ab])?|swpb?|swi|smi|tst|teq|wfe|wfi|yield)(eq|ne|cs|cc|mi|pl|vs|vc|hi|ls|ge|lt|gt|le|al|hs|lo)?[sptrx]?(?=\\s)"},t,e.QUOTE_STRING_MODE,{className:"string",begin:"'",end:"[^\\\\]'",relevance:0},{className:"title",begin:"\\|",end:"\\|",illegal:"\\n",relevance:0},{className:"number",variants:[{begin:"[#$=]?0x[0-9a-f]+"},{begin:"[#$=]?0b[01]+"},{begin:"[#$=]\\d+"},{begin:"\\b\\d+"}],relevance:0},{className:"symbol",variants:[{begin:"^[ \\t]*[a-z_\\.\\$][a-z0-9_\\.\\$]+:"},{begin:"^[a-z_\\.\\$][a-z0-9_\\.\\$]+"},{begin:"[=#]\\w+"}],relevance:0}]}},ai=function(e){var t={className:"symbol",begin:"&[a-z]+;|&#[0-9]+;|&#x[a-f0-9]+;"},a={begin:"\\s",contains:[{className:"meta-keyword",begin:"#?[a-z_][a-z1-9_-]+",illegal:"\\n"}]},n=e.inherit(a,{begin:"\\(",end:"\\)"}),r=e.inherit(e.APOS_STRING_MODE,{className:"meta-string"}),i=e.inherit(e.QUOTE_STRING_MODE,{className:"meta-string"}),o={endsWithParent:!0,illegal:/`]+/}]}]}]};return{name:"HTML, XML",aliases:["html","xhtml","rss","atom","xjb","xsd","xsl","plist","wsf","svg"],case_insensitive:!0,contains:[{className:"meta",begin:"",relevance:10,contains:[a,i,r,n,{begin:"\\[",end:"\\]",contains:[{className:"meta",begin:"",contains:[a,n,i,r]}]}]},e.COMMENT("",{relevance:10}),{begin:"<\\!\\[CDATA\\[",end:"\\]\\]>",relevance:10},t,{className:"meta",begin:/<\?xml/,end:/\?>/,relevance:10},{className:"tag",begin:")",end:">",keywords:{name:"style"},contains:[o],starts:{end:"",returnEnd:!0,subLanguage:["css","xml"]}},{className:"tag",begin:")",end:">",keywords:{name:"script"},contains:[o],starts:{end:"",returnEnd:!0,subLanguage:["javascript","handlebars","xml"]}},{className:"tag",begin:"",contains:[{className:"name",begin:/[^\/><\s]+/,relevance:0},o]}]}},ni=function(e){return{name:"AsciiDoc",aliases:["adoc"],contains:[e.COMMENT("^/{4,}\\n","\\n/{4,}$",{relevance:10}),e.COMMENT("^//","$",{relevance:0}),{className:"title",begin:"^\\.\\w.*$"},{begin:"^[=\\*]{4,}\\n",end:"\\n^[=\\*]{4,}$",relevance:10},{className:"section",relevance:10,variants:[{begin:"^(={1,5}) .+?( \\1)?$"},{begin:"^[^\\[\\]\\n]+?\\n[=\\-~\\^\\+]{2,}$"}]},{className:"meta",begin:"^:.+?:",end:"\\s",excludeEnd:!0,relevance:10},{className:"meta",begin:"^\\[.+?\\]$",relevance:0},{className:"quote",begin:"^_{4,}\\n",end:"\\n_{4,}$",relevance:10},{className:"code",begin:"^[\\-\\.]{4,}\\n",end:"\\n[\\-\\.]{4,}$",relevance:10},{begin:"^\\+{4,}\\n",end:"\\n\\+{4,}$",contains:[{begin:"<",end:">",subLanguage:"xml",relevance:0}],relevance:10},{className:"bullet",begin:"^(\\*+|\\-+|\\.+|[^\\n]+?::)\\s+"},{className:"symbol",begin:"^(NOTE|TIP|IMPORTANT|WARNING|CAUTION):\\s+",relevance:10},{className:"strong",begin:"\\B\\*(?![\\*\\s])",end:"(\\n{2}|\\*)",contains:[{begin:"\\\\*\\w",relevance:0}]},{className:"emphasis",begin:"\\B'(?!['\\s])",end:"(\\n{2}|')",contains:[{begin:"\\\\'\\w",relevance:0}],relevance:0},{className:"emphasis",begin:"_(?![_\\s])",end:"(\\n{2}|_)",relevance:0},{className:"string",variants:[{begin:"``.+?''"},{begin:"`.+?'"}]},{className:"code",begin:"(`.+?`|\\+.+?\\+)",relevance:0},{className:"code",begin:"^[ \\t]",end:"$",relevance:0},{begin:"^'{3,}[ \\t]*$",relevance:10},{begin:"(link:)?(http|https|ftp|file|irc|image:?):\\S+\\[.*?\\]",returnBegin:!0,contains:[{begin:"(link|image:?):",relevance:0},{className:"link",begin:"\\w",end:"[^\\[]+",relevance:0},{className:"string",begin:"\\[",end:"\\]",excludeBegin:!0,excludeEnd:!0,relevance:0}],relevance:10}]}},ri=function(e){var t="false synchronized int abstract float private char boolean static null if const for true while long throw strictfp finally protected import native final return void enum else extends implements break transient new catch instanceof byte super volatile case assert short package default double public try this switch continue throws privileged aspectOf adviceexecution proceed cflowbelow cflow initialization preinitialization staticinitialization withincode target within execution getWithinTypeName handler thisJoinPoint thisJoinPointStaticPart thisEnclosingJoinPointStaticPart declare parents warning error soft precedence thisAspectInstance";return{name:"AspectJ",keywords:t,illegal:/<\/|#/,contains:[e.COMMENT("/\\*\\*","\\*/",{relevance:0,contains:[{begin:/\w+@/,relevance:0},{className:"doctag",begin:"@[A-Za-z]+"}]}),e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{className:"class",beginKeywords:"aspect",end:/[{;=]/,excludeEnd:!0,illegal:/[:;"\[\]]/,contains:[{beginKeywords:"extends implements pertypewithin perthis pertarget percflowbelow percflow issingleton"},e.UNDERSCORE_TITLE_MODE,{begin:/\([^\)]*/,end:/[)]+/,keywords:t+" get set args call",excludeEnd:!1}]},{className:"class",beginKeywords:"class interface",end:/[{;=]/,excludeEnd:!0,relevance:0,keywords:"class interface",illegal:/[:"\[\]]/,contains:[{beginKeywords:"extends implements"},e.UNDERSCORE_TITLE_MODE]},{beginKeywords:"pointcut after before around throwing returning",end:/[)]/,excludeEnd:!1,illegal:/["\[\]]/,contains:[{begin:e.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,contains:[e.UNDERSCORE_TITLE_MODE]}]},{begin:/[:]/,returnBegin:!0,end:/[{;]/,relevance:0,excludeEnd:!1,keywords:t,illegal:/["\[\]]/,contains:[{begin:e.UNDERSCORE_IDENT_RE+"\\s*\\(",keywords:t+" get set args call",relevance:0},e.QUOTE_STRING_MODE]},{beginKeywords:"new throw",relevance:0},{className:"function",begin:/\w+ +\w+(\.)?\w+\s*\([^\)]*\)\s*((throws)[\w\s,]+)?[\{;]/,returnBegin:!0,end:/[{;=]/,keywords:t,excludeEnd:!0,contains:[{begin:e.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,relevance:0,contains:[e.UNDERSCORE_TITLE_MODE]},{className:"params",begin:/\(/,end:/\)/,relevance:0,keywords:t,contains:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,e.C_NUMBER_MODE,e.C_BLOCK_COMMENT_MODE]},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},e.C_NUMBER_MODE,{className:"meta",begin:"@[A-Za-z]+"}]}},ii=function(e){var t={begin:"`[\\s\\S]"};return{name:"AutoHotkey",case_insensitive:!0,aliases:["ahk"],keywords:{keyword:"Break Continue Critical Exit ExitApp Gosub Goto New OnExit Pause return SetBatchLines SetTimer Suspend Thread Throw Until ahk_id ahk_class ahk_pid ahk_exe ahk_group",literal:"true false NOT AND OR",built_in:"ComSpec Clipboard ClipboardAll ErrorLevel"},contains:[t,e.inherit(e.QUOTE_STRING_MODE,{contains:[t]}),e.COMMENT(";","$",{relevance:0}),e.C_BLOCK_COMMENT_MODE,{className:"number",begin:e.NUMBER_RE,relevance:0},{className:"variable",begin:"%[a-zA-Z0-9#_$@]+%"},{className:"built_in",begin:"^\\s*\\w+\\s*(,|%)"},{className:"title",variants:[{begin:'^[^\\n";]+::(?!=)'},{begin:'^[^\\n";]+:(?!=)',relevance:0}]},{className:"meta",begin:"^\\s*#\\w+",end:"$",relevance:0},{className:"built_in",begin:"A_[a-zA-Z0-9]+"},{begin:",\\s*,"}]}},oi=function(e){var t={variants:[e.COMMENT(";","$",{relevance:0}),e.COMMENT("#cs","#ce"),e.COMMENT("#comments-start","#comments-end")]},a={begin:"\\$[A-z0-9_]+"},n={className:"string",variants:[{begin:/"/,end:/"/,contains:[{begin:/""/,relevance:0}]},{begin:/'/,end:/'/,contains:[{begin:/''/,relevance:0}]}]},r={variants:[e.BINARY_NUMBER_MODE,e.C_NUMBER_MODE]};return{name:"AutoIt",case_insensitive:!0,illegal:/\/\*/,keywords:{keyword:"ByRef Case Const ContinueCase ContinueLoop Default Dim Do Else ElseIf EndFunc EndIf EndSelect EndSwitch EndWith Enum Exit ExitLoop For Func Global If In Local Next ReDim Return Select Static Step Switch Then To Until Volatile WEnd While With",built_in:"Abs ACos AdlibRegister AdlibUnRegister Asc AscW ASin Assign ATan AutoItSetOption AutoItWinGetTitle AutoItWinSetTitle Beep Binary BinaryLen BinaryMid BinaryToString BitAND BitNOT BitOR BitRotate BitShift BitXOR BlockInput Break Call CDTray Ceiling Chr ChrW ClipGet ClipPut ConsoleRead ConsoleWrite ConsoleWriteError ControlClick ControlCommand ControlDisable ControlEnable ControlFocus ControlGetFocus ControlGetHandle ControlGetPos ControlGetText ControlHide ControlListView ControlMove ControlSend ControlSetText ControlShow ControlTreeView Cos Dec DirCopy DirCreate DirGetSize DirMove DirRemove DllCall DllCallAddress DllCallbackFree DllCallbackGetPtr DllCallbackRegister DllClose DllOpen DllStructCreate DllStructGetData DllStructGetPtr DllStructGetSize DllStructSetData DriveGetDrive DriveGetFileSystem DriveGetLabel DriveGetSerial DriveGetType DriveMapAdd DriveMapDel DriveMapGet DriveSetLabel DriveSpaceFree DriveSpaceTotal DriveStatus EnvGet EnvSet EnvUpdate Eval Execute Exp FileChangeDir FileClose FileCopy FileCreateNTFSLink FileCreateShortcut FileDelete FileExists FileFindFirstFile FileFindNextFile FileFlush FileGetAttrib FileGetEncoding FileGetLongName FileGetPos FileGetShortcut FileGetShortName FileGetSize FileGetTime FileGetVersion FileInstall FileMove FileOpen FileOpenDialog FileRead FileReadLine FileReadToArray FileRecycle FileRecycleEmpty FileSaveDialog FileSelectFolder FileSetAttrib FileSetEnd FileSetPos FileSetTime FileWrite FileWriteLine Floor FtpSetProxy FuncName GUICreate GUICtrlCreateAvi GUICtrlCreateButton GUICtrlCreateCheckbox GUICtrlCreateCombo GUICtrlCreateContextMenu GUICtrlCreateDate GUICtrlCreateDummy GUICtrlCreateEdit GUICtrlCreateGraphic GUICtrlCreateGroup GUICtrlCreateIcon GUICtrlCreateInput GUICtrlCreateLabel GUICtrlCreateList GUICtrlCreateListView GUICtrlCreateListViewItem GUICtrlCreateMenu GUICtrlCreateMenuItem GUICtrlCreateMonthCal GUICtrlCreateObj GUICtrlCreatePic GUICtrlCreateProgress GUICtrlCreateRadio GUICtrlCreateSlider GUICtrlCreateTab GUICtrlCreateTabItem GUICtrlCreateTreeView GUICtrlCreateTreeViewItem GUICtrlCreateUpdown GUICtrlDelete GUICtrlGetHandle GUICtrlGetState GUICtrlRead GUICtrlRecvMsg GUICtrlRegisterListViewSort GUICtrlSendMsg GUICtrlSendToDummy GUICtrlSetBkColor GUICtrlSetColor GUICtrlSetCursor GUICtrlSetData GUICtrlSetDefBkColor GUICtrlSetDefColor GUICtrlSetFont GUICtrlSetGraphic GUICtrlSetImage GUICtrlSetLimit GUICtrlSetOnEvent GUICtrlSetPos GUICtrlSetResizing GUICtrlSetState GUICtrlSetStyle GUICtrlSetTip GUIDelete GUIGetCursorInfo GUIGetMsg GUIGetStyle GUIRegisterMsg GUISetAccelerators GUISetBkColor GUISetCoord GUISetCursor GUISetFont GUISetHelp GUISetIcon GUISetOnEvent GUISetState GUISetStyle GUIStartGroup GUISwitch Hex HotKeySet HttpSetProxy HttpSetUserAgent HWnd InetClose InetGet InetGetInfo InetGetSize InetRead IniDelete IniRead IniReadSection IniReadSectionNames IniRenameSection IniWrite IniWriteSection InputBox Int IsAdmin IsArray IsBinary IsBool IsDeclared IsDllStruct IsFloat IsFunc IsHWnd IsInt IsKeyword IsNumber IsObj IsPtr IsString Log MemGetStats Mod MouseClick MouseClickDrag MouseDown MouseGetCursor MouseGetPos MouseMove MouseUp MouseWheel MsgBox Number ObjCreate ObjCreateInterface ObjEvent ObjGet ObjName OnAutoItExitRegister OnAutoItExitUnRegister Ping PixelChecksum PixelGetColor PixelSearch ProcessClose ProcessExists ProcessGetStats ProcessList ProcessSetPriority ProcessWait ProcessWaitClose ProgressOff ProgressOn ProgressSet Ptr Random RegDelete RegEnumKey RegEnumVal RegRead RegWrite Round Run RunAs RunAsWait RunWait Send SendKeepActive SetError SetExtended ShellExecute ShellExecuteWait Shutdown Sin Sleep SoundPlay SoundSetWaveVolume SplashImageOn SplashOff SplashTextOn Sqrt SRandom StatusbarGetText StderrRead StdinWrite StdioClose StdoutRead String StringAddCR StringCompare StringFormat StringFromASCIIArray StringInStr StringIsAlNum StringIsAlpha StringIsASCII StringIsDigit StringIsFloat StringIsInt StringIsLower StringIsSpace StringIsUpper StringIsXDigit StringLeft StringLen StringLower StringMid StringRegExp StringRegExpReplace StringReplace StringReverse StringRight StringSplit StringStripCR StringStripWS StringToASCIIArray StringToBinary StringTrimLeft StringTrimRight StringUpper Tan TCPAccept TCPCloseSocket TCPConnect TCPListen TCPNameToIP TCPRecv TCPSend TCPShutdown, UDPShutdown TCPStartup, UDPStartup TimerDiff TimerInit ToolTip TrayCreateItem TrayCreateMenu TrayGetMsg TrayItemDelete TrayItemGetHandle TrayItemGetState TrayItemGetText TrayItemSetOnEvent TrayItemSetState TrayItemSetText TraySetClick TraySetIcon TraySetOnEvent TraySetPauseIcon TraySetState TraySetToolTip TrayTip UBound UDPBind UDPCloseSocket UDPOpen UDPRecv UDPSend VarGetType WinActivate WinActive WinClose WinExists WinFlash WinGetCaretPos WinGetClassList WinGetClientSize WinGetHandle WinGetPos WinGetProcess WinGetState WinGetText WinGetTitle WinKill WinList WinMenuSelectItem WinMinimizeAll WinMinimizeAllUndo WinMove WinSetOnTop WinSetState WinSetTitle WinSetTrans WinWait",literal:"True False And Null Not Or"},contains:[t,a,n,r,{className:"meta",begin:"#",end:"$",keywords:{"meta-keyword":"comments include include-once NoTrayIcon OnAutoItStartRegister pragma compile RequireAdmin"},contains:[{begin:/\\\n/,relevance:0},{beginKeywords:"include",keywords:{"meta-keyword":"include"},end:"$",contains:[n,{className:"meta-string",variants:[{begin:"<",end:">"},{begin:/"/,end:/"/,contains:[{begin:/""/,relevance:0}]},{begin:/'/,end:/'/,contains:[{begin:/''/,relevance:0}]}]}]},n,t]},{className:"symbol",begin:"@[A-z0-9_]+"},{className:"function",beginKeywords:"Func",end:"$",illegal:"\\$|\\[|%",contains:[e.UNDERSCORE_TITLE_MODE,{className:"params",begin:"\\(",end:"\\)",contains:[a,n,r]}]}]}},si=function(e){return{name:"AVR Assembly",case_insensitive:!0,lexemes:"\\.?"+e.IDENT_RE,keywords:{keyword:"adc add adiw and andi asr bclr bld brbc brbs brcc brcs break breq brge brhc brhs brid brie brlo brlt brmi brne brpl brsh brtc brts brvc brvs bset bst call cbi cbr clc clh cli cln clr cls clt clv clz com cp cpc cpi cpse dec eicall eijmp elpm eor fmul fmuls fmulsu icall ijmp in inc jmp ld ldd ldi lds lpm lsl lsr mov movw mul muls mulsu neg nop or ori out pop push rcall ret reti rjmp rol ror sbc sbr sbrc sbrs sec seh sbi sbci sbic sbis sbiw sei sen ser ses set sev sez sleep spm st std sts sub subi swap tst wdr",built_in:"r0 r1 r2 r3 r4 r5 r6 r7 r8 r9 r10 r11 r12 r13 r14 r15 r16 r17 r18 r19 r20 r21 r22 r23 r24 r25 r26 r27 r28 r29 r30 r31 x|0 xh xl y|0 yh yl z|0 zh zl ucsr1c udr1 ucsr1a ucsr1b ubrr1l ubrr1h ucsr0c ubrr0h tccr3c tccr3a tccr3b tcnt3h tcnt3l ocr3ah ocr3al ocr3bh ocr3bl ocr3ch ocr3cl icr3h icr3l etimsk etifr tccr1c ocr1ch ocr1cl twcr twdr twar twsr twbr osccal xmcra xmcrb eicra spmcsr spmcr portg ddrg ping portf ddrf sreg sph spl xdiv rampz eicrb eimsk gimsk gicr eifr gifr timsk tifr mcucr mcucsr tccr0 tcnt0 ocr0 assr tccr1a tccr1b tcnt1h tcnt1l ocr1ah ocr1al ocr1bh ocr1bl icr1h icr1l tccr2 tcnt2 ocr2 ocdr wdtcr sfior eearh eearl eedr eecr porta ddra pina portb ddrb pinb portc ddrc pinc portd ddrd pind spdr spsr spcr udr0 ucsr0a ucsr0b ubrr0l acsr admux adcsr adch adcl porte ddre pine pinf",meta:".byte .cseg .db .def .device .dseg .dw .endmacro .equ .eseg .exit .include .list .listmac .macro .nolist .org .set"},contains:[e.C_BLOCK_COMMENT_MODE,e.COMMENT(";","$",{relevance:0}),e.C_NUMBER_MODE,e.BINARY_NUMBER_MODE,{className:"number",begin:"\\b(\\$[a-zA-Z0-9]+|0o[0-7]+)"},e.QUOTE_STRING_MODE,{className:"string",begin:"'",end:"[^\\\\]'",illegal:"[^\\\\][^']"},{className:"symbol",begin:"^[A-Za-z0-9_.$]+:"},{className:"meta",begin:"#",end:"$"},{className:"subst",begin:"@[0-9]+"}]}},li=function(e){return{name:"Awk",keywords:{keyword:"BEGIN END if else while do for in break continue delete next nextfile function func exit|10"},contains:[{className:"variable",variants:[{begin:/\$[\w\d#@][\w\d_]*/},{begin:/\$\{(.*?)}/}]},{className:"string",contains:[e.BACKSLASH_ESCAPE],variants:[{begin:/(u|b)?r?'''/,end:/'''/,relevance:10},{begin:/(u|b)?r?"""/,end:/"""/,relevance:10},{begin:/(u|r|ur)'/,end:/'/,relevance:10},{begin:/(u|r|ur)"/,end:/"/,relevance:10},{begin:/(b|br)'/,end:/'/},{begin:/(b|br)"/,end:/"/},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]},e.REGEXP_MODE,e.HASH_COMMENT_MODE,e.NUMBER_MODE]}},_i=function(e){return{name:"Dynamics 365",keywords:"false int abstract private char boolean static null if for true while long throw finally protected final return void enum else break new catch byte super case short default double public try this switch continue reverse firstfast firstonly forupdate nofetch sum avg minof maxof count order group by asc desc index hint like dispaly edit client server ttsbegin ttscommit str real date container anytype common div mod",contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,e.C_NUMBER_MODE,{className:"meta",begin:"#",end:"$"},{className:"class",beginKeywords:"class interface",end:"{",excludeEnd:!0,illegal:":",contains:[{beginKeywords:"extends implements"},e.UNDERSCORE_TITLE_MODE]}]}},ci=function(e){var t={},a={begin:/\$\{/,end:/\}/,contains:[{begin:/:-/,contains:[t]}]};Object.assign(t,{className:"variable",variants:[{begin:/\$[\w\d#@][\w\d_]*/},a]});var n={className:"subst",begin:/\$\(/,end:/\)/,contains:[e.BACKSLASH_ESCAPE]},r={className:"string",begin:/"/,end:/"/,contains:[e.BACKSLASH_ESCAPE,t,n]};n.contains.push(r);var i={begin:/\$\(\(/,end:/\)\)/,contains:[{begin:/\d+#[0-9a-f]+/,className:"number"},e.NUMBER_MODE,t]};return{name:"Bash",aliases:["sh","zsh"],lexemes:/\b-?[a-z\._]+\b/,keywords:{keyword:"if then else elif fi for while in do done case esac function",literal:"true false", +built_in:"break cd continue eval exec exit export getopts hash pwd readonly return shift test times trap umask unset alias bind builtin caller command declare echo enable help let local logout mapfile printf read readarray source type typeset ulimit unalias set shopt autoload bg bindkey bye cap chdir clone comparguments compcall compctl compdescribe compfiles compgroups compquote comptags comptry compvalues dirs disable disown echotc echoti emulate fc fg float functions getcap getln history integer jobs kill limit log noglob popd print pushd pushln rehash sched setcap setopt stat suspend ttyctl unfunction unhash unlimit unsetopt vared wait whence where which zcompile zformat zftp zle zmodload zparseopts zprof zpty zregexparse zsocket zstyle ztcp",_:"-ne -eq -lt -gt -f -d -e -s -l -a"},contains:[{className:"meta",begin:/^#![^\n]+sh\s*$/,relevance:10},{className:"function",begin:/\w[\w\d_]*\s*\(\s*\)\s*\{/,returnBegin:!0,contains:[e.inherit(e.TITLE_MODE,{begin:/\w[\w\d_]*/})],relevance:0},i,e.HASH_COMMENT_MODE,r,{className:"",begin:/\\"/},{className:"string",begin:/'/,end:/'/},t]}},di=function(e){return{name:"BASIC",case_insensitive:!0,illegal:"^.",lexemes:"[a-zA-Z][a-zA-Z0-9_$%!#]*",keywords:{keyword:"ABS ASC AND ATN AUTO|0 BEEP BLOAD|10 BSAVE|10 CALL CALLS CDBL CHAIN CHDIR CHR$|10 CINT CIRCLE CLEAR CLOSE CLS COLOR COM COMMON CONT COS CSNG CSRLIN CVD CVI CVS DATA DATE$ DEFDBL DEFINT DEFSNG DEFSTR DEF|0 SEG USR DELETE DIM DRAW EDIT END ENVIRON ENVIRON$ EOF EQV ERASE ERDEV ERDEV$ ERL ERR ERROR EXP FIELD FILES FIX FOR|0 FRE GET GOSUB|10 GOTO HEX$ IF THEN ELSE|0 INKEY$ INP INPUT INPUT# INPUT$ INSTR IMP INT IOCTL IOCTL$ KEY ON OFF LIST KILL LEFT$ LEN LET LINE LLIST LOAD LOC LOCATE LOF LOG LPRINT USING LSET MERGE MID$ MKDIR MKD$ MKI$ MKS$ MOD NAME NEW NEXT NOISE NOT OCT$ ON OR PEN PLAY STRIG OPEN OPTION BASE OUT PAINT PALETTE PCOPY PEEK PMAP POINT POKE POS PRINT PRINT] PSET PRESET PUT RANDOMIZE READ REM RENUM RESET|0 RESTORE RESUME RETURN|0 RIGHT$ RMDIR RND RSET RUN SAVE SCREEN SGN SHELL SIN SOUND SPACE$ SPC SQR STEP STICK STOP STR$ STRING$ SWAP SYSTEM TAB TAN TIME$ TIMER TROFF TRON TO USR VAL VARPTR VARPTR$ VIEW WAIT WHILE WEND WIDTH WINDOW WRITE XOR"},contains:[e.QUOTE_STRING_MODE,e.COMMENT("REM","$",{relevance:10}),e.COMMENT("'","$",{relevance:0}),{className:"symbol",begin:"^[0-9]+ ",relevance:10},{className:"number",begin:"\\b([0-9]+[0-9edED.]*[#!]?)",relevance:0},{className:"number",begin:"(&[hH][0-9a-fA-F]{1,4})"},{className:"number",begin:"(&[oO][0-7]{1,6})"}]}},ui=function(e){return{name:"Backus\u2013Naur Form",contains:[{className:"attribute",begin://},{begin:/::=/,end:/$/,contains:[{begin://},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]}]}},mi=function(e){var t={className:"literal",begin:"[\\+\\-]",relevance:0};return{name:"Brainfuck",aliases:["bf"],contains:[e.COMMENT("[^\\[\\]\\.,\\+\\-<> \r\n]","[\\[\\]\\.,\\+\\-<> \r\n]",{returnEnd:!0,relevance:0}),{className:"title",begin:"[\\[\\]]",relevance:0},{className:"string",begin:"[\\.,]",relevance:0},{begin:/(?:\+\+|\-\-)/,contains:[t]},t]}},pi=function(e){var t=e.getLanguage("c-like").rawDefinition();return t.name="C",t.aliases=["c","h"],t},Ei=function(e){var t="div mod in and or not xor asserterror begin case do downto else end exit for if of repeat then to until while with var",a=[e.C_LINE_COMMENT_MODE,e.COMMENT(/\{/,/\}/,{relevance:0}),e.COMMENT(/\(\*/,/\*\)/,{relevance:10})],n={className:"string",begin:/'/,end:/'/,contains:[{begin:/''/}]},r={className:"string",begin:/(#\d+)+/},i={className:"function",beginKeywords:"procedure",end:/[:;]/,keywords:"procedure|10",contains:[e.TITLE_MODE,{className:"params",begin:/\(/,end:/\)/,keywords:t,contains:[n,r]}].concat(a)},o={className:"class",begin:"OBJECT (Table|Form|Report|Dataport|Codeunit|XMLport|MenuSuite|Page|Query) (\\d+) ([^\\r\\n]+)",returnBegin:!0,contains:[e.TITLE_MODE,i]};return{name:"C/AL",case_insensitive:!0,keywords:{keyword:t,literal:"false true"},illegal:/\/\*/,contains:[n,r,{className:"number",begin:"\\b\\d+(\\.\\d+)?(DT|D|T)",relevance:0},{className:"string",begin:'"',end:'"'},e.NUMBER_MODE,o,i]}},gi=function(e){return{name:"Cap\u2019n Proto",aliases:["capnp"],keywords:{keyword:"struct enum interface union group import using const annotation extends in of on as with from fixed",built_in:"Void Bool Int8 Int16 Int32 Int64 UInt8 UInt16 UInt32 UInt64 Float32 Float64 Text Data AnyPointer AnyStruct Capability List",literal:"true false"},contains:[e.QUOTE_STRING_MODE,e.NUMBER_MODE,e.HASH_COMMENT_MODE,{className:"meta",begin:/@0x[\w\d]{16};/,illegal:/\n/},{className:"symbol",begin:/@\d+\b/},{className:"class",beginKeywords:"struct enum",end:/\{/,illegal:/\n/,contains:[e.inherit(e.TITLE_MODE,{starts:{endsWithParent:!0,excludeEnd:!0}})]},{className:"class",beginKeywords:"interface",end:/\{/,illegal:/\n/,contains:[e.inherit(e.TITLE_MODE,{starts:{endsWithParent:!0,excludeEnd:!0}})]}]}},Si=function(e){var t="assembly module package import alias class interface object given value assign void function new of extends satisfies abstracts in out return break continue throw assert dynamic if else switch case for while try catch finally then let this outer super is exists nonempty",a={className:"subst",excludeBegin:!0,excludeEnd:!0,begin:/``/,end:/``/,keywords:t,relevance:10},n=[{className:"string",begin:'"""',end:'"""',relevance:10},{className:"string",begin:'"',end:'"',contains:[a]},{className:"string",begin:"'",end:"'"},{className:"number",begin:"#[0-9a-fA-F_]+|\\$[01_]+|[0-9_]+(?:\\.[0-9_](?:[eE][+-]?\\d+)?)?[kMGTPmunpf]?",relevance:0}];return a.contains=n,{name:"Ceylon",keywords:{keyword:t+" shared abstract formal default actual variable late native deprecated final sealed annotation suppressWarnings small",meta:"doc by license see throws tagged"},illegal:"\\$[^01]|#[^0-9a-fA-F]",contains:[e.C_LINE_COMMENT_MODE,e.COMMENT("/\\*","\\*/",{contains:["self"]}),{className:"meta",begin:'@[a-z]\\w*(?:\\:"[^"]*")?'}].concat(n)}},Ti=function(e){return{name:"Clean",aliases:["clean","icl","dcl"],keywords:{keyword:"if let in with where case of class instance otherwise implementation definition system module from import qualified as special code inline foreign export ccall stdcall generic derive infix infixl infixr",built_in:"Int Real Char Bool",literal:"True False"},contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,e.C_NUMBER_MODE,{begin:"->|<-[|:]?|#!?|>>=|\\{\\||\\|\\}|:==|=:|<>"}]}},bi=function(e){var t="def defonce defprotocol defstruct defmulti defmethod defn- defn defmacro deftype defrecord",a={"builtin-name":t+" cond apply if-not if-let if not not= = < > <= >= == + / * - rem quot neg? pos? delay? symbol? keyword? true? false? integer? empty? coll? list? set? ifn? fn? associative? sequential? sorted? counted? reversible? number? decimal? class? distinct? isa? float? rational? reduced? ratio? odd? even? char? seq? vector? string? map? nil? contains? zero? instance? not-every? not-any? libspec? -> ->> .. . inc compare do dotimes mapcat take remove take-while drop letfn drop-last take-last drop-while while intern condp case reduced cycle split-at split-with repeat replicate iterate range merge zipmap declare line-seq sort comparator sort-by dorun doall nthnext nthrest partition eval doseq await await-for let agent atom send send-off release-pending-sends add-watch mapv filterv remove-watch agent-error restart-agent set-error-handler error-handler set-error-mode! error-mode shutdown-agents quote var fn loop recur throw try monitor-enter monitor-exit macroexpand macroexpand-1 for dosync and or when when-not when-let comp juxt partial sequence memoize constantly complement identity assert peek pop doto proxy first rest cons cast coll last butlast sigs reify second ffirst fnext nfirst nnext meta with-meta ns in-ns create-ns import refer keys select-keys vals key val rseq name namespace promise into transient persistent! conj! assoc! dissoc! pop! disj! use class type num float double short byte boolean bigint biginteger bigdec print-method print-dup throw-if printf format load compile get-in update-in pr pr-on newline flush read slurp read-line subvec with-open memfn time re-find re-groups rand-int rand mod locking assert-valid-fdecl alias resolve ref deref refset swap! reset! set-validator! compare-and-set! alter-meta! reset-meta! commute get-validator alter ref-set ref-history-count ref-min-history ref-max-history ensure sync io! new next conj set! to-array future future-call into-array aset gen-class reduce map filter find empty hash-map hash-set sorted-map sorted-map-by sorted-set sorted-set-by vec vector seq flatten reverse assoc dissoc list disj get union difference intersection extend extend-type extend-protocol int nth delay count concat chunk chunk-buffer chunk-append chunk-first chunk-rest max min dec unchecked-inc-int unchecked-inc unchecked-dec-inc unchecked-dec unchecked-negate unchecked-add-int unchecked-add unchecked-subtract-int unchecked-subtract chunk-next chunk-cons chunked-seq? prn vary-meta lazy-seq spread list* str find-keyword keyword symbol gensym force rationalize"},n="[a-zA-Z_\\-!.?+*=<>&#'][a-zA-Z_\\-!.?+*=<>&#'0-9/;:]*",r={begin:n,relevance:0},i={className:"number",begin:"[-+]?\\d+(\\.\\d+)?",relevance:0},o=e.inherit(e.QUOTE_STRING_MODE,{illegal:null}),s=e.COMMENT(";","$",{relevance:0}),l={className:"literal",begin:/\b(true|false|nil)\b/},_={begin:"[\\[\\{]",end:"[\\]\\}]"},c={className:"comment",begin:"\\^"+n},d=e.COMMENT("\\^\\{","\\}"),u={className:"symbol",begin:"[:]{1,2}"+n},m={begin:"\\(",end:"\\)"},p={endsWithParent:!0,relevance:0},E={keywords:a,lexemes:n,className:"name",begin:n,starts:p},g=[m,o,c,d,s,u,_,i,l,r],S={beginKeywords:t,lexemes:n,end:'(\\[|\\#|\\d|"|:|\\{|\\)|\\(|$)',contains:[{className:"title",begin:n,relevance:0,excludeEnd:!0,endsParent:!0}].concat(g)};return m.contains=[e.COMMENT("comment",""),S,E,p],p.contains=g,_.contains=g,d.contains=[_],{name:"Clojure",aliases:["clj"],illegal:/\S/,contains:[m,o,c,d,s,u,_,i,l]}},fi=function(){return{name:"Clojure REPL",contains:[{className:"meta",begin:/^([\w.-]+|\s*#_)?=>/,starts:{end:/$/,subLanguage:"clojure"}}]}},Ci=function(e){return{name:"CMake",aliases:["cmake.in"],case_insensitive:!0,keywords:{keyword:"break cmake_host_system_information cmake_minimum_required cmake_parse_arguments cmake_policy configure_file continue elseif else endforeach endfunction endif endmacro endwhile execute_process file find_file find_library find_package find_path find_program foreach function get_cmake_property get_directory_property get_filename_component get_property if include include_guard list macro mark_as_advanced math message option return separate_arguments set_directory_properties set_property set site_name string unset variable_watch while add_compile_definitions add_compile_options add_custom_command add_custom_target add_definitions add_dependencies add_executable add_library add_link_options add_subdirectory add_test aux_source_directory build_command create_test_sourcelist define_property enable_language enable_testing export fltk_wrap_ui get_source_file_property get_target_property get_test_property include_directories include_external_msproject include_regular_expression install link_directories link_libraries load_cache project qt_wrap_cpp qt_wrap_ui remove_definitions set_source_files_properties set_target_properties set_tests_properties source_group target_compile_definitions target_compile_features target_compile_options target_include_directories target_link_directories target_link_libraries target_link_options target_sources try_compile try_run ctest_build ctest_configure ctest_coverage ctest_empty_binary_directory ctest_memcheck ctest_read_custom_files ctest_run_script ctest_sleep ctest_start ctest_submit ctest_test ctest_update ctest_upload build_name exec_program export_library_dependencies install_files install_programs install_targets load_command make_directory output_required_files remove subdir_depends subdirs use_mangled_mesa utility_source variable_requires write_file qt5_use_modules qt5_use_package qt5_wrap_cpp on off true false and or not command policy target test exists is_newer_than is_directory is_symlink is_absolute matches less greater equal less_equal greater_equal strless strgreater strequal strless_equal strgreater_equal version_less version_greater version_equal version_less_equal version_greater_equal in_list defined"},contains:[{className:"variable",begin:"\\${",end:"}"},e.HASH_COMMENT_MODE,e.QUOTE_STRING_MODE,e.NUMBER_MODE]}},Ri=function(e){var t={keyword:"in if for while finally new do return else break catch instanceof throw try this switch continue typeof delete debugger super yield import export from as default await then unless until loop of by when and or is isnt not",literal:"true false null undefined yes no on off",built_in:"npm require console print module global window document"},a="[A-Za-z$_][0-9A-Za-z$_]*",n={className:"subst",begin:/#\{/,end:/}/,keywords:t},r=[e.BINARY_NUMBER_MODE,e.inherit(e.C_NUMBER_MODE,{starts:{end:"(\\s*/)?",relevance:0}}),{className:"string",variants:[{begin:/'''/,end:/'''/,contains:[e.BACKSLASH_ESCAPE]},{begin:/'/,end:/'/,contains:[e.BACKSLASH_ESCAPE]},{begin:/"""/,end:/"""/,contains:[e.BACKSLASH_ESCAPE,n]},{begin:/"/,end:/"/,contains:[e.BACKSLASH_ESCAPE,n]}]},{className:"regexp",variants:[{begin:"///",end:"///",contains:[n,e.HASH_COMMENT_MODE]},{begin:"//[gim]{0,3}(?=\\W)",relevance:0},{begin:/\/(?![ *]).*?(?![\\]).\/[gim]{0,3}(?=\W)/}]},{begin:"@"+a},{subLanguage:"javascript",excludeBegin:!0,excludeEnd:!0,variants:[{begin:"```",end:"```"},{begin:"`",end:"`"}]}];n.contains=r;var i=e.inherit(e.TITLE_MODE,{begin:a}),o={className:"params",begin:"\\([^\\(]",returnBegin:!0,contains:[{begin:/\(/,end:/\)/,keywords:t,contains:["self"].concat(r)}]};return{name:"CoffeeScript",aliases:["coffee","cson","iced"],keywords:t,illegal:/\/\*/,contains:r.concat([e.COMMENT("###","###"),e.HASH_COMMENT_MODE,{className:"function",begin:"^\\s*"+a+"\\s*=\\s*(\\(.*\\))?\\s*\\B[-=]>",end:"[-=]>",returnBegin:!0,contains:[i,o]},{begin:/[:\(,=]\s*/,relevance:0,contains:[{className:"function",begin:"(\\(.*\\))?\\s*\\B[-=]>",end:"[-=]>",returnBegin:!0,contains:[o]}]},{className:"class",beginKeywords:"class",end:"$",illegal:/[:="\[\]]/,contains:[{beginKeywords:"extends",endsWithParent:!0,illegal:/[:="\[\]]/,contains:[i]},i]},{begin:a+":",end:":",returnBegin:!0,returnEnd:!0,relevance:0}])}},Ni=function(e){return{name:"Coq",keywords:{keyword:"_|0 as at cofix else end exists exists2 fix for forall fun if IF in let match mod Prop return Set then Type using where with Abort About Add Admit Admitted All Arguments Assumptions Axiom Back BackTo Backtrack Bind Blacklist Canonical Cd Check Class Classes Close Coercion Coercions CoFixpoint CoInductive Collection Combined Compute Conjecture Conjectures Constant constr Constraint Constructors Context Corollary CreateHintDb Cut Declare Defined Definition Delimit Dependencies Dependent Derive Drop eauto End Equality Eval Example Existential Existentials Existing Export exporting Extern Extract Extraction Fact Field Fields File Fixpoint Focus for From Function Functional Generalizable Global Goal Grab Grammar Graph Guarded Heap Hint HintDb Hints Hypotheses Hypothesis ident Identity If Immediate Implicit Import Include Inductive Infix Info Initial Inline Inspect Instance Instances Intro Intros Inversion Inversion_clear Language Left Lemma Let Libraries Library Load LoadPath Local Locate Ltac ML Mode Module Modules Monomorphic Morphism Next NoInline Notation Obligation Obligations Opaque Open Optimize Options Parameter Parameters Parametric Path Paths pattern Polymorphic Preterm Print Printing Program Projections Proof Proposition Pwd Qed Quit Rec Record Recursive Redirect Relation Remark Remove Require Reserved Reset Resolve Restart Rewrite Right Ring Rings Save Scheme Scope Scopes Script Search SearchAbout SearchHead SearchPattern SearchRewrite Section Separate Set Setoid Show Solve Sorted Step Strategies Strategy Structure SubClass Table Tables Tactic Term Test Theorem Time Timeout Transparent Type Typeclasses Types Undelimit Undo Unfocus Unfocused Unfold Universe Universes Unset Unshelve using Variable Variables Variant Verbose Visibility where with",built_in:"abstract absurd admit after apply as assert assumption at auto autorewrite autounfold before bottom btauto by case case_eq cbn cbv change classical_left classical_right clear clearbody cofix compare compute congruence constr_eq constructor contradict contradiction cut cutrewrite cycle decide decompose dependent destruct destruction dintuition discriminate discrR do double dtauto eapply eassumption eauto ecase econstructor edestruct ediscriminate eelim eexact eexists einduction einjection eleft elim elimtype enough equality erewrite eright esimplify_eq esplit evar exact exactly_once exfalso exists f_equal fail field field_simplify field_simplify_eq first firstorder fix fold fourier functional generalize generalizing gfail give_up has_evar hnf idtac in induction injection instantiate intro intro_pattern intros intuition inversion inversion_clear is_evar is_var lapply lazy left lia lra move native_compute nia nsatz omega once pattern pose progress proof psatz quote record red refine reflexivity remember rename repeat replace revert revgoals rewrite rewrite_strat right ring ring_simplify rtauto set setoid_reflexivity setoid_replace setoid_rewrite setoid_symmetry setoid_transitivity shelve shelve_unifiable simpl simple simplify_eq solve specialize split split_Rabs split_Rmult stepl stepr subst sum swap symmetry tactic tauto time timeout top transitivity trivial try tryif unfold unify until using vm_compute with"},contains:[e.QUOTE_STRING_MODE,e.COMMENT("\\(\\*","\\*\\)"),e.C_NUMBER_MODE,{className:"type",excludeBegin:!0,begin:"\\|\\s*",end:"\\w+"},{begin:/[-=]>/}]}},Oi=function(e){return{name:"Cach\xe9 Object Script",case_insensitive:!0,aliases:["cos","cls"],keywords:"property parameter class classmethod clientmethod extends as break catch close continue do d|0 else elseif for goto halt hang h|0 if job j|0 kill k|0 lock l|0 merge new open quit q|0 read r|0 return set s|0 tcommit throw trollback try tstart use view while write w|0 xecute x|0 zkill znspace zn ztrap zwrite zw zzdump zzwrite print zbreak zinsert zload zprint zremove zsave zzprint mv mvcall mvcrt mvdim mvprint zquit zsync ascii",contains:[{className:"number",begin:"\\b(\\d+(\\.\\d*)?|\\.\\d+)",relevance:0},{className:"string",variants:[{begin:'"',end:'"',contains:[{begin:'""',relevance:0}]}]},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{className:"comment",begin:/;/,end:"$",relevance:0},{className:"built_in",begin:/(?:\$\$?|\.\.)\^?[a-zA-Z]+/},{className:"built_in",begin:/\$\$\$[a-zA-Z]+/},{className:"built_in",begin:/%[a-z]+(?:\.[a-z]+)*/},{className:"symbol",begin:/\^%?[a-zA-Z][\w]*/},{className:"keyword",begin:/##class|##super|#define|#dim/},{begin:/&sql\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,subLanguage:"sql"},{begin:/&(js|jscript|javascript)/,excludeBegin:!0,excludeEnd:!0,subLanguage:"javascript"},{begin:/&html<\s*\s*>/,subLanguage:"xml"}]}},vi=function(e){var t="group clone ms master location colocation order fencing_topology rsc_ticket acl_target acl_group user role tag xml";return{name:"crmsh",aliases:["crm","pcmk"],case_insensitive:!0,keywords:{keyword:"params meta operations op rule attributes utilization read write deny defined not_defined in_range date spec in ref reference attribute type xpath version and or lt gt tag lte gte eq ne \\ number string",literal:"Master Started Slave Stopped start promote demote stop monitor true false"},contains:[e.HASH_COMMENT_MODE,{beginKeywords:"node",starts:{end:"\\s*([\\w_-]+:)?",starts:{className:"title",end:"\\s*[\\$\\w_][\\w_-]*"}}},{beginKeywords:"primitive rsc_template",starts:{className:"title",end:"\\s*[\\$\\w_][\\w_-]*",starts:{end:"\\s*@?[\\w_][\\w_\\.:-]*"}}},{begin:"\\b("+t.split(" ").join("|")+")\\s+",keywords:t,starts:{className:"title",end:"[\\$\\w_][\\w_-]*"}},{beginKeywords:"property rsc_defaults op_defaults",starts:{className:"title",end:"\\s*([\\w_-]+:)?"}},e.QUOTE_STRING_MODE,{className:"meta",begin:"(ocf|systemd|service|lsb):[\\w_:-]+",relevance:0},{className:"number",begin:"\\b\\d+(\\.\\d+)?(ms|s|h|m)?",relevance:0},{className:"literal",begin:"[-]?(infinity|inf)",relevance:0},{className:"attr",begin:/([A-Za-z\$_\#][\w_-]+)=/,relevance:0},{className:"tag",begin:"",relevance:0}]}},Ii=function(e){function t(e,t){var a=[{begin:e,end:t}];return a[0].contains=a,a}var a="(_*[ui](8|16|32|64|128))?",n="[a-zA-Z_]\\w*[!?=]?|[-+~]\\@|<<|>>|[=!]~|===?|<=>|[<>]=?|\\*\\*|[-/+%^&*~|]|//|//=|&[-+*]=?|&\\*\\*|\\[\\][=?]?",r={keyword:"abstract alias annotation as as? asm begin break case class def do else elsif end ensure enum extend for fun if include instance_sizeof is_a? lib macro module next nil? of out pointerof private protected rescue responds_to? return require select self sizeof struct super then type typeof union uninitialized unless until verbatim when while with yield __DIR__ __END_LINE__ __FILE__ __LINE__",literal:"false nil true"},i={className:"subst",begin:"#{",end:"}",keywords:r},o={className:"template-variable",variants:[{begin:"\\{\\{",end:"\\}\\}"},{begin:"\\{%",end:"%\\}"}],keywords:r},s={className:"string",contains:[e.BACKSLASH_ESCAPE,i],variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/`/,end:/`/},{begin:"%[Qwi]?\\(",end:"\\)",contains:t("\\(","\\)")},{begin:"%[Qwi]?\\[",end:"\\]",contains:t("\\[","\\]")},{begin:"%[Qwi]?{",end:"}",contains:t("{","}")},{begin:"%[Qwi]?<",end:">",contains:t("<",">")},{begin:"%[Qwi]?\\|",end:"\\|"},{begin:/<<-\w+$/,end:/^\s*\w+$/}],relevance:0},l={className:"string",variants:[{begin:"%q\\(",end:"\\)",contains:t("\\(","\\)")},{begin:"%q\\[",end:"\\]",contains:t("\\[","\\]")},{begin:"%q{",end:"}",contains:t("{","}")},{begin:"%q<",end:">",contains:t("<",">")},{begin:"%q\\|",end:"\\|"},{begin:/<<-'\w+'$/,end:/^\s*\w+$/}],relevance:0},_={begin:"(?!%})("+e.RE_STARTERS_RE+"|\\n|\\b(case|if|select|unless|until|when|while)\\b)\\s*",keywords:"case if select unless until when while",contains:[{className:"regexp",contains:[e.BACKSLASH_ESCAPE,i],variants:[{begin:"//[a-z]*",relevance:0},{begin:"/(?!\\/)",end:"/[a-z]*"}]}],relevance:0},c=[o,s,l,{className:"regexp",contains:[e.BACKSLASH_ESCAPE,i],variants:[{begin:"%r\\(",end:"\\)",contains:t("\\(","\\)")},{begin:"%r\\[",end:"\\]",contains:t("\\[","\\]")},{begin:"%r{",end:"}",contains:t("{","}")},{begin:"%r<",end:">",contains:t("<",">")},{begin:"%r\\|",end:"\\|"}],relevance:0},_,{className:"meta",begin:"@\\[",end:"\\]",contains:[e.inherit(e.QUOTE_STRING_MODE,{className:"meta-string"})]},e.HASH_COMMENT_MODE,{className:"class",beginKeywords:"class module struct",end:"$|;",illegal:/=/,contains:[e.HASH_COMMENT_MODE,e.inherit(e.TITLE_MODE,{begin:"[A-Za-z_]\\w*(::\\w+)*(\\?|\\!)?"}),{begin:"<"}]},{className:"class",beginKeywords:"lib enum union",end:"$|;",illegal:/=/,contains:[e.HASH_COMMENT_MODE,e.inherit(e.TITLE_MODE,{begin:"[A-Za-z_]\\w*(::\\w+)*(\\?|\\!)?"})],relevance:10},{beginKeywords:"annotation",end:"$|;",illegal:/=/,contains:[e.HASH_COMMENT_MODE,e.inherit(e.TITLE_MODE,{begin:"[A-Za-z_]\\w*(::\\w+)*(\\?|\\!)?"})],relevance:10},{className:"function",beginKeywords:"def",end:/\B\b/,contains:[e.inherit(e.TITLE_MODE,{begin:n,endsParent:!0})]},{className:"function",beginKeywords:"fun macro",end:/\B\b/,contains:[e.inherit(e.TITLE_MODE,{begin:n,endsParent:!0})],relevance:5},{className:"symbol",begin:e.UNDERSCORE_IDENT_RE+"(\\!|\\?)?:",relevance:0},{className:"symbol",begin:":",contains:[s,{begin:n}],relevance:0},{className:"number",variants:[{begin:"\\b0b([01_]+)"+a},{begin:"\\b0o([0-7_]+)"+a},{begin:"\\b0x([A-Fa-f0-9_]+)"+a},{begin:"\\b([1-9][0-9_]*[0-9]|[0-9])(\\.[0-9][0-9_]*)?([eE]_*[-+]?[0-9_]*)?(_*f(32|64))?(?!_)"},{begin:"\\b([1-9][0-9_]*|0)"+a}],relevance:0}];return i.contains=c,o.contains=c.slice(1),{name:"Crystal",aliases:["cr"],lexemes:"[a-zA-Z_]\\w*[!?=]?",keywords:r,contains:c}},hi=function(e){var t={keyword:"abstract as base bool break byte case catch char checked const continue decimal default delegate do double enum event explicit extern finally fixed float for foreach goto if implicit in int interface internal is lock long object operator out override params private protected public readonly ref sbyte sealed short sizeof stackalloc static string struct switch this try typeof uint ulong unchecked unsafe ushort using virtual void volatile while add alias ascending async await by descending dynamic equals from get global group into join let nameof on orderby partial remove select set value var when where yield",literal:"null false true"},a=e.inherit(e.TITLE_MODE,{begin:"[a-zA-Z](\\.?\\w)*"}),n={className:"number",variants:[{begin:"\\b(0b[01']+)"},{begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)(u|U|l|L|ul|UL|f|F|b|B)"},{begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)"}],relevance:0},r={className:"string",begin:'@"',end:'"',contains:[{begin:'""'}]},i=e.inherit(r,{illegal:/\n/}),o={className:"subst",begin:"{",end:"}",keywords:t},s=e.inherit(o,{illegal:/\n/}),l={className:"string",begin:/\$"/,end:'"',illegal:/\n/,contains:[{begin:"{{"},{begin:"}}"},e.BACKSLASH_ESCAPE,s]},_={className:"string",begin:/\$@"/,end:'"',contains:[{begin:"{{"},{begin:"}}"},{begin:'""'},o]},c=e.inherit(_,{illegal:/\n/,contains:[{begin:"{{"},{begin:"}}"},{begin:'""'},s]});o.contains=[_,l,r,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,n,e.C_BLOCK_COMMENT_MODE],s.contains=[c,l,i,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,n,e.inherit(e.C_BLOCK_COMMENT_MODE,{illegal:/\n/})];var d={variants:[_,l,r,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]},u=e.IDENT_RE+"(<"+e.IDENT_RE+"(\\s*,\\s*"+e.IDENT_RE+")*>)?(\\[\\])?",m={begin:"@"+e.IDENT_RE,relevance:0};return{name:"C#",aliases:["cs","c#"],keywords:t,illegal:/::/,contains:[e.COMMENT("///","$",{returnBegin:!0,contains:[{className:"doctag",variants:[{begin:"///",relevance:0},{begin:""},{begin:""}]}]}),e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{className:"meta",begin:"#",end:"$",keywords:{"meta-keyword":"if else elif endif define undef warning error line region endregion pragma checksum"}},d,n,{beginKeywords:"class interface",end:/[{;=]/,illegal:/[^\s:,]/,contains:[{beginKeywords:"where class"},a,{begin:"<",end:">",keywords:"in out"},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{beginKeywords:"namespace",end:/[{;=]/,illegal:/[^\s:]/,contains:[a,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{className:"meta",begin:"^\\s*\\[",excludeBegin:!0,end:"\\]",excludeEnd:!0,contains:[{className:"meta-string",begin:/"/,end:/"/}]},{beginKeywords:"new return throw await else",relevance:0},{className:"function",begin:"("+u+"\\s+)+"+e.IDENT_RE+"\\s*\\(",returnBegin:!0,end:/\s*[{;=]/,excludeEnd:!0,keywords:t,contains:[{begin:e.IDENT_RE+"\\s*\\(",returnBegin:!0,contains:[e.TITLE_MODE],relevance:0},{className:"params",begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:t,relevance:0,contains:[d,n,e.C_BLOCK_COMMENT_MODE]},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},m]}},Ai=function(){return{name:"CSP",case_insensitive:!1,lexemes:"[a-zA-Z][a-zA-Z0-9_-]*",keywords:{keyword:"base-uri child-src connect-src default-src font-src form-action frame-ancestors frame-src img-src media-src object-src plugin-types report-uri sandbox script-src style-src"},contains:[{className:"string",begin:"'",end:"'"},{className:"attribute",begin:"^Content",end:":",excludeEnd:!0}]}},yi=function(e){var t={begin:/(?:[A-Z\_\.\-]+|--[a-zA-Z0-9_-]+)\s*:/,returnBegin:!0,end:";",endsWithParent:!0,contains:[{className:"attribute",begin:/\S/,end:":",excludeEnd:!0,starts:{endsWithParent:!0,excludeEnd:!0,contains:[{begin:/[\w-]+\(/,returnBegin:!0,contains:[{className:"built_in",begin:/[\w-]+/},{begin:/\(/,end:/\)/,contains:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,e.CSS_NUMBER_MODE]}]},e.CSS_NUMBER_MODE,e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,e.C_BLOCK_COMMENT_MODE,{className:"number",begin:"#[0-9A-Fa-f]+"},{className:"meta",begin:"!important"}]}}]};return{name:"CSS",case_insensitive:!0,illegal:/[=\/|'\$]/,contains:[e.C_BLOCK_COMMENT_MODE,{className:"selector-id",begin:/#[A-Za-z0-9_-]+/},{className:"selector-class",begin:/\.[A-Za-z0-9_-]+/},{className:"selector-attr",begin:/\[/,end:/\]/,illegal:"$",contains:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]},{className:"selector-pseudo",begin:/:(:)?[a-zA-Z0-9\_\-\+\(\)"'.]+/},{begin:"@(page|font-face)",lexemes:"@[a-z-]+",keywords:"@page @font-face"},{begin:"@",end:"[{;]",illegal:/:/,returnBegin:!0,contains:[{className:"keyword",begin:/@\-?\w[\w]*(\-\w+)*/},{begin:/\s/,endsWithParent:!0,excludeEnd:!0,relevance:0,keywords:"and or not only",contains:[{begin:/[a-z-]+:/,className:"attribute"},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,e.CSS_NUMBER_MODE]}]},{className:"selector-tag",begin:"[a-zA-Z-][a-zA-Z0-9_-]*",relevance:0},{begin:"{",end:"}",illegal:/\S/,contains:[e.C_BLOCK_COMMENT_MODE,t]}]}},Di=function(e){var t="((0|[1-9][\\d_]*)|0[bB][01_]+|0[xX]([\\da-fA-F][\\da-fA-F_]*|_[\\da-fA-F][\\da-fA-F_]*))",a="\\\\(['\"\\?\\\\abfnrtv]|u[\\dA-Fa-f]{4}|[0-7]{1,3}|x[\\dA-Fa-f]{2}|U[\\dA-Fa-f]{8})|&[a-zA-Z\\d]{2,};",n={className:"number",begin:"\\b"+t+"(L|u|U|Lu|LU|uL|UL)?",relevance:0},r={className:"number",begin:"\\b(((0[xX](([\\da-fA-F][\\da-fA-F_]*|_[\\da-fA-F][\\da-fA-F_]*)\\.([\\da-fA-F][\\da-fA-F_]*|_[\\da-fA-F][\\da-fA-F_]*)|\\.?([\\da-fA-F][\\da-fA-F_]*|_[\\da-fA-F][\\da-fA-F_]*))[pP][+-]?(0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d))|((0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d)(\\.\\d*|([eE][+-]?(0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d)))|\\d+\\.(0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d)(0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d)|\\.(0|[1-9][\\d_]*)([eE][+-]?(0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d))?))([fF]|L|i|[fF]i|Li)?|"+t+"(i|[fF]i|Li))",relevance:0},i={className:"string",begin:"'("+a+"|.)",end:"'",illegal:"."},o={className:"string",begin:'"',contains:[{begin:a,relevance:0}],end:'"[cwd]?'},s=e.COMMENT("\\/\\+","\\+\\/",{contains:["self"],relevance:10});return{name:"D",lexemes:e.UNDERSCORE_IDENT_RE,keywords:{keyword:"abstract alias align asm assert auto body break byte case cast catch class const continue debug default delete deprecated do else enum export extern final finally for foreach foreach_reverse|10 goto if immutable import in inout int interface invariant is lazy macro mixin module new nothrow out override package pragma private protected public pure ref return scope shared static struct super switch synchronized template this throw try typedef typeid typeof union unittest version void volatile while with __FILE__ __LINE__ __gshared|10 __thread __traits __DATE__ __EOF__ __TIME__ __TIMESTAMP__ __VENDOR__ __VERSION__",built_in:"bool cdouble cent cfloat char creal dchar delegate double dstring float function idouble ifloat ireal long real short string ubyte ucent uint ulong ushort wchar wstring",literal:"false null true"},contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,s,{className:"string",begin:'x"[\\da-fA-F\\s\\n\\r]*"[cwd]?',relevance:10},o,{className:"string",begin:'[rq]"',end:'"[cwd]?',relevance:5},{className:"string",begin:"`",end:"`[cwd]?"},{className:"string",begin:'q"\\{',end:'\\}"'},r,n,i,{className:"meta",begin:"^#!",end:"$",relevance:5},{className:"meta",begin:"#(line)",end:"$",relevance:5},{className:"keyword",begin:"@[a-zA-Z_][a-zA-Z_\\d]*"}]}},Mi=function(){var e={begin:"<",end:">",subLanguage:"xml",relevance:0},t={begin:"\\[.+?\\][\\(\\[].*?[\\)\\]]",returnBegin:!0,contains:[{className:"string",begin:"\\[",end:"\\]",excludeBegin:!0,returnEnd:!0,relevance:0},{className:"link",begin:"\\]\\(",end:"\\)",excludeBegin:!0,excludeEnd:!0},{className:"symbol",begin:"\\]\\[",end:"\\]",excludeBegin:!0,excludeEnd:!0}],relevance:10},a={className:"strong",contains:[],variants:[{begin:/_{2}/,end:/_{2}/},{begin:/\*{2}/,end:/\*{2}/}]},n={className:"emphasis",contains:[],variants:[{begin:/\*(?!\*)/,end:/\*/},{begin:/_(?!_)/,end:/_/,relevance:0}]};a.contains.push(n),n.contains.push(a);var r=[e,t];return a.contains=a.contains.concat(r),n.contains=n.contains.concat(r),{name:"Markdown",aliases:["md","mkdown","mkd"],contains:[{className:"section",variants:[{begin:"^#{1,6}",end:"$",contains:r=r.concat(a,n)},{begin:"(?=^.+?\\n[=-]{2,}$)",contains:[{begin:"^[=-]*$"},{begin:"^",end:"\\n",contains:r}]}]},e,{className:"bullet",begin:"^[ \t]*([*+-]|(\\d+\\.))(?=\\s+)",end:"\\s+",excludeEnd:!0},a,n,{className:"quote",begin:"^>\\s+",contains:r,end:"$"},{className:"code",variants:[{begin:"(`{3,})(.|\\n)*?\\1`*[ ]*"},{begin:"(~{3,})(.|\\n)*?\\1~*[ ]*"},{begin:"```",end:"```+[ ]*$"},{begin:"~~~",end:"~~~+[ ]*$"},{begin:"`.+?`"},{begin:"(?=^( {4}|\\t))",contains:[{ +begin:"^( {4}|\\t)",end:"(\\n)$"}],relevance:0}]},{begin:"^[-\\*]{3,}",end:"$"},t,{begin:/^\[[^\n]+\]:/,returnBegin:!0,contains:[{className:"symbol",begin:/\[/,end:/\]/,excludeBegin:!0,excludeEnd:!0},{className:"link",begin:/:\s*/,end:/$/,excludeBegin:!0}]}]}},Li=function(e){var t={className:"subst",variants:[{begin:"\\$[A-Za-z0-9_]+"}]},a={className:"subst",variants:[{begin:"\\${",end:"}"}],keywords:"true false null this is new super"},n={className:"string",variants:[{begin:"r'''",end:"'''"},{begin:'r"""',end:'"""'},{begin:"r'",end:"'",illegal:"\\n"},{begin:'r"',end:'"',illegal:"\\n"},{begin:"'''",end:"'''",contains:[e.BACKSLASH_ESCAPE,t,a]},{begin:'"""',end:'"""',contains:[e.BACKSLASH_ESCAPE,t,a]},{begin:"'",end:"'",illegal:"\\n",contains:[e.BACKSLASH_ESCAPE,t,a]},{begin:'"',end:'"',illegal:"\\n",contains:[e.BACKSLASH_ESCAPE,t,a]}]};return a.contains=[e.C_NUMBER_MODE,n],{name:"Dart",keywords:{keyword:"abstract as assert async await break case catch class const continue covariant default deferred do dynamic else enum export extends extension external factory false final finally for Function get hide if implements import in inferface is library mixin new null on operator part rethrow return set show static super switch sync this throw true try typedef var void while with yield",built_in:"Comparable DateTime Duration Function Iterable Iterator List Map Match Null Object Pattern RegExp Set Stopwatch String StringBuffer StringSink Symbol Type Uri bool double dynamic int num print Element ElementList document querySelector querySelectorAll window"},contains:[n,e.COMMENT("/\\*\\*","\\*/",{subLanguage:"markdown",relevance:0}),e.COMMENT("///+\\s*","$",{contains:[{subLanguage:"markdown",begin:".",end:"$",relevance:0}]}),e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{className:"class",beginKeywords:"class interface",end:"{",excludeEnd:!0,contains:[{beginKeywords:"extends implements"},e.UNDERSCORE_TITLE_MODE]},e.C_NUMBER_MODE,{className:"meta",begin:"@[A-Za-z]+"},{begin:"=>"}]}},xi=function(e){var t="exports register file shl array record property for mod while set ally label uses raise not stored class safecall var interface or private static exit index inherited to else stdcall override shr asm far resourcestring finalization packed virtual out and protected library do xorwrite goto near function end div overload object unit begin string on inline repeat until destructor write message program with read initialization except default nil if case cdecl in downto threadvar of try pascal const external constructor type public then implementation finally published procedure absolute reintroduce operator as is abstract alias assembler bitpacked break continue cppdecl cvar enumerator experimental platform deprecated unimplemented dynamic export far16 forward generic helper implements interrupt iochecks local name nodefault noreturn nostackframe oldfpccall otherwise saveregisters softfloat specialize strict unaligned varargs ",a=[e.C_LINE_COMMENT_MODE,e.COMMENT(/\{/,/\}/,{relevance:0}),e.COMMENT(/\(\*/,/\*\)/,{relevance:10})],n={className:"meta",variants:[{begin:/\{\$/,end:/\}/},{begin:/\(\*\$/,end:/\*\)/}]},r={className:"string",begin:/'/,end:/'/,contains:[{begin:/''/}]},i={className:"string",begin:/(#\d+)+/},o={begin:e.IDENT_RE+"\\s*=\\s*class\\s*\\(",returnBegin:!0,contains:[e.TITLE_MODE]},s={className:"function",beginKeywords:"function constructor destructor procedure",end:/[:;]/,keywords:"function constructor|10 destructor|10 procedure|10",contains:[e.TITLE_MODE,{className:"params",begin:/\(/,end:/\)/,keywords:t,contains:[r,i,n].concat(a)},n].concat(a)};return{name:"Delphi",aliases:["dpr","dfm","pas","pascal","freepascal","lazarus","lpr","lfm"],case_insensitive:!0,keywords:t,illegal:/"|\$[G-Zg-z]|\/\*|<\/|\|/,contains:[r,i,e.NUMBER_MODE,{className:"number",relevance:0,variants:[{begin:"\\$[0-9A-Fa-f]+"},{begin:"&[0-7]+"},{begin:"%[01]+"}]},o,s,n].concat(a)}},wi=function(){return{name:"Diff",aliases:["patch"],contains:[{className:"meta",relevance:10,variants:[{begin:/^@@ +\-\d+,\d+ +\+\d+,\d+ +@@$/},{begin:/^\*\*\* +\d+,\d+ +\*\*\*\*$/},{begin:/^\-\-\- +\d+,\d+ +\-\-\-\-$/}]},{className:"comment",variants:[{begin:/Index: /,end:/$/},{begin:/={3,}/,end:/$/},{begin:/^\-{3}/,end:/$/},{begin:/^\*{3} /,end:/$/},{begin:/^\+{3}/,end:/$/},{begin:/^\*{15}$/}]},{className:"addition",begin:"^\\+",end:"$"},{className:"deletion",begin:"^\\-",end:"$"},{className:"addition",begin:"^\\!",end:"$"}]}},Pi=function(e){var t={begin:/\|[A-Za-z]+:?/,keywords:{name:"truncatewords removetags linebreaksbr yesno get_digit timesince random striptags filesizeformat escape linebreaks length_is ljust rjust cut urlize fix_ampersands title floatformat capfirst pprint divisibleby add make_list unordered_list urlencode timeuntil urlizetrunc wordcount stringformat linenumbers slice date dictsort dictsortreversed default_if_none pluralize lower join center default truncatewords_html upper length phone2numeric wordwrap time addslashes slugify first escapejs force_escape iriencode last safe safeseq truncatechars localize unlocalize localtime utc timezone"},contains:[e.QUOTE_STRING_MODE,e.APOS_STRING_MODE]};return{name:"Django",aliases:["jinja"],case_insensitive:!0,subLanguage:"xml",contains:[e.COMMENT(/\{%\s*comment\s*%}/,/\{%\s*endcomment\s*%}/),e.COMMENT(/\{#/,/#}/),{className:"template-tag",begin:/\{%/,end:/%}/,contains:[{className:"name",begin:/\w+/,keywords:{name:"comment endcomment load templatetag ifchanged endifchanged if endif firstof for endfor ifnotequal endifnotequal widthratio extends include spaceless endspaceless regroup ifequal endifequal ssi now with cycle url filter endfilter debug block endblock else autoescape endautoescape csrf_token empty elif endwith static trans blocktrans endblocktrans get_static_prefix get_media_prefix plural get_current_language language get_available_languages get_current_language_bidi get_language_info get_language_info_list localize endlocalize localtime endlocaltime timezone endtimezone get_current_timezone verbatim"},starts:{endsWithParent:!0,keywords:"in by as",contains:[t],relevance:0}}]},{className:"template-variable",begin:/\{\{/,end:/}}/,contains:[t]}]}},ki=function(e){return{name:"DNS Zone",aliases:["bind","zone"],keywords:{keyword:"IN A AAAA AFSDB APL CAA CDNSKEY CDS CERT CNAME DHCID DLV DNAME DNSKEY DS HIP IPSECKEY KEY KX LOC MX NAPTR NS NSEC NSEC3 NSEC3PARAM PTR RRSIG RP SIG SOA SRV SSHFP TA TKEY TLSA TSIG TXT"},contains:[e.COMMENT(";","$",{relevance:0}),{className:"meta",begin:/^\$(TTL|GENERATE|INCLUDE|ORIGIN)\b/},{className:"number",begin:"((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:)))\\b"},{className:"number",begin:"((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]).){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\b"},e.inherit(e.NUMBER_MODE,{begin:/\b\d+[dhwm]?/})]}},Ui=function(e){return{name:"Dockerfile",aliases:["docker"],case_insensitive:!0,keywords:"from maintainer expose env arg user onbuild stopsignal",contains:[e.HASH_COMMENT_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,e.NUMBER_MODE,{beginKeywords:"run cmd entrypoint volume add copy workdir label healthcheck shell",starts:{end:/[^\\]$/,subLanguage:"bash"}}],illegal:"",illegal:"\\n"}]},t,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},r={className:"variable",begin:"\\&[a-z\\d_]*\\b"},i={className:"meta-keyword",begin:"/[a-z][a-z\\d-]*/"},o={className:"symbol",begin:"^\\s*[a-zA-Z_][a-zA-Z\\d_]*:"},s={className:"params",begin:"<",end:">",contains:[a,r]},l={className:"class",begin:/[a-zA-Z_][a-zA-Z\d_@]*\s{/,end:/[{;=]/,returnBegin:!0,excludeEnd:!0};return{name:"Device Tree",keywords:"",contains:[{className:"class",begin:"/\\s*{",end:"};",relevance:10,contains:[r,i,o,l,s,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,a,t]},r,i,o,l,s,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,a,t,n,{begin:e.IDENT_RE+"::",keywords:""}]}},Yi=function(e){return{name:"Dust",aliases:["dst"],case_insensitive:!0,subLanguage:"xml",contains:[{className:"template-tag",begin:/\{[#\/]/,end:/\}/,illegal:/;/,contains:[{className:"name",begin:/[a-zA-Z\.-]+/,starts:{endsWithParent:!0,relevance:0,contains:[e.QUOTE_STRING_MODE]}}]},{className:"template-variable",begin:/\{/,end:/\}/,illegal:/;/,keywords:"if eq ne lt lte gt gte select default math sep"}]}},Hi=function(e){var t=e.COMMENT(/\(\*/,/\*\)/);return{name:"Extended Backus-Naur Form",illegal:/\S/,contains:[t,{className:"attribute",begin:/^[ ]*[a-zA-Z][a-zA-Z-_]*([\s-_]+[a-zA-Z][a-zA-Z]*)*/},{begin:/=/,end:/[.;]/,contains:[t,{className:"meta",begin:/\?.*\?/},{className:"string",variants:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{begin:"`",end:"`"}]}]}]}},Vi=function(e){var t="[a-zA-Z_][a-zA-Z0-9_.]*(\\!|\\?)?",a="and false then defined module in return redo retry end for true self when next until do begin unless nil break not case cond alias while ensure or include use alias fn quote require import with|0",n={className:"subst",begin:"#\\{",end:"}",lexemes:t,keywords:a},r={className:"number",begin:"(\\b0o[0-7_]+)|(\\b0b[01_]+)|(\\b0x[0-9a-fA-F_]+)|(-?\\b[1-9][0-9_]*(.[0-9_]+([eE][-+]?[0-9]+)?)?)",relevance:0},i={className:"string",begin:"~[a-z](?=[/|([{<\"'])",contains:[{endsParent:!0,contains:[{contains:[e.BACKSLASH_ESCAPE,n],variants:[{begin:/"/,end:/"/},{begin:/'/,end:/'/},{begin:/\//,end:/\//},{begin:/\|/,end:/\|/},{begin:/\(/,end:/\)/},{begin:/\[/,end:/\]/},{begin:/\{/,end:/\}/},{begin://}]}]}]},o={className:"string",begin:"~[A-Z](?=[/|([{<\"'])",contains:[{begin:/"/,end:/"/},{begin:/'/,end:/'/},{begin:/\//,end:/\//},{begin:/\|/,end:/\|/},{begin:/\(/,end:/\)/},{begin:/\[/,end:/\]/},{begin:/\{/,end:/\}/},{begin:/\/}]},s={className:"string",contains:[e.BACKSLASH_ESCAPE,n],variants:[{begin:/"""/,end:/"""/},{begin:/'''/,end:/'''/},{begin:/~S"""/,end:/"""/,contains:[]},{begin:/~S"/,end:/"/,contains:[]},{begin:/~S'''/,end:/'''/,contains:[]},{begin:/~S'/,end:/'/,contains:[]},{begin:/'/,end:/'/},{begin:/"/,end:/"/}]},l={className:"function",beginKeywords:"def defp defmacro",end:/\B\b/,contains:[e.inherit(e.TITLE_MODE,{begin:t,endsParent:!0})]},_=e.inherit(l,{className:"class",beginKeywords:"defimpl defmodule defprotocol defrecord",end:/\bdo\b|$|;/}),c=[s,o,i,e.HASH_COMMENT_MODE,_,l,{begin:"::"},{className:"symbol",begin:":(?![\\s:])",contains:[s,{begin:"[a-zA-Z_]\\w*[!?=]?|[-+~]\\@|<<|>>|=~|===?|<=>|[<>]=?|\\*\\*|[-/+%^&*~`|]|\\[\\]=?"}],relevance:0},{className:"symbol",begin:t+":(?!:)",relevance:0},r,{className:"variable",begin:"(\\$\\W)|((\\$|\\@\\@?)(\\w+))"},{begin:"->"},{begin:"("+e.RE_STARTERS_RE+")\\s*",contains:[e.HASH_COMMENT_MODE,{begin:/\/: (?=\d+\s*[,\]])/,relevance:0,contains:[r]},{className:"regexp",illegal:"\\n",contains:[e.BACKSLASH_ESCAPE,n],variants:[{begin:"/",end:"/[a-z]*"},{begin:"%r\\[",end:"\\][a-z]*"}]}],relevance:0}];return n.contains=c,{name:"Elixir",lexemes:t,keywords:a,contains:c}},qi=function(e){var t={variants:[e.COMMENT("--","$"),e.COMMENT("{-","-}",{contains:["self"]})]},a={className:"type",begin:"\\b[A-Z][\\w']*",relevance:0},n={begin:"\\(",end:"\\)",illegal:'"',contains:[{className:"type",begin:"\\b[A-Z][\\w]*(\\((\\.\\.|,|\\w+)\\))?"},t]};return{name:"Elm",keywords:"let in if then else case of where module import exposing type alias as infix infixl infixr port effect command subscription",contains:[{beginKeywords:"port effect module",end:"exposing",keywords:"port effect module where command subscription exposing",contains:[n,t],illegal:"\\W\\.|;"},{begin:"import",end:"$",keywords:"import as exposing",contains:[n,t],illegal:"\\W\\.|;"},{begin:"type",end:"$",keywords:"type alias",contains:[a,n,{begin:"{",end:"}",contains:n.contains},t]},{beginKeywords:"infix infixl infixr",end:"$",contains:[e.C_NUMBER_MODE,t]},{begin:"port",end:"$",keywords:"port",contains:[t]},{className:"string",begin:"'\\\\?.",end:"'",illegal:"."},e.QUOTE_STRING_MODE,e.C_NUMBER_MODE,a,e.inherit(e.TITLE_MODE,{begin:"^[_a-z][\\w']*"}),t,{begin:"->|<-"}],illegal:/;/}},zi=function(e){var t="[a-zA-Z_]\\w*[!?=]?|[-+~]\\@|<<|>>|=~|===?|<=>|[<>]=?|\\*\\*|[-/+%^&*~`|]|\\[\\]=?",a={keyword:"and then defined module in return redo if BEGIN retry end for self when next until do begin unless END rescue else break undef not super class case require yield alias while ensure elsif or include attr_reader attr_writer attr_accessor",literal:"true false nil"},n={className:"doctag",begin:"@[A-Za-z]+"},r={begin:"#<",end:">"},i=[e.COMMENT("#","$",{contains:[n]}),e.COMMENT("^\\=begin","^\\=end",{contains:[n],relevance:10}),e.COMMENT("^__END__","\\n$")],o={className:"subst",begin:"#\\{",end:"}",keywords:a},s={className:"string",contains:[e.BACKSLASH_ESCAPE,o],variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/`/,end:/`/},{begin:"%[qQwWx]?\\(",end:"\\)"},{begin:"%[qQwWx]?\\[",end:"\\]"},{begin:"%[qQwWx]?{",end:"}"},{begin:"%[qQwWx]?<",end:">"},{begin:"%[qQwWx]?/",end:"/"},{begin:"%[qQwWx]?%",end:"%"},{begin:"%[qQwWx]?-",end:"-"},{begin:"%[qQwWx]?\\|",end:"\\|"},{begin:/\B\?(\\\d{1,3}|\\x[A-Fa-f0-9]{1,2}|\\u[A-Fa-f0-9]{4}|\\?\S)\b/},{begin:/<<[-~]?'?(\w+)(?:.|\n)*?\n\s*\1\b/,returnBegin:!0,contains:[{begin:/<<[-~]?'?/},{begin:/\w+/,endSameAsBegin:!0,contains:[e.BACKSLASH_ESCAPE,o]}]}]},l={className:"params",begin:"\\(",end:"\\)",endsParent:!0,keywords:a},_=[s,r,{className:"class",beginKeywords:"class module",end:"$|;",illegal:/=/,contains:[e.inherit(e.TITLE_MODE,{begin:"[A-Za-z_]\\w*(::\\w+)*(\\?|\\!)?"}),{begin:"<\\s*",contains:[{begin:"("+e.IDENT_RE+"::)?"+e.IDENT_RE}]}].concat(i)},{className:"function",beginKeywords:"def",end:"$|;",contains:[e.inherit(e.TITLE_MODE,{begin:t}),l].concat(i)},{begin:e.IDENT_RE+"::"},{className:"symbol",begin:e.UNDERSCORE_IDENT_RE+"(\\!|\\?)?:",relevance:0},{className:"symbol",begin:":(?!\\s)",contains:[s,{begin:t}],relevance:0},{className:"number",begin:"(\\b0[0-7_]+)|(\\b0x[0-9a-fA-F_]+)|(\\b[1-9][0-9_]*(\\.[0-9_]+)?)|[0_]\\b",relevance:0},{begin:"(\\$\\W)|((\\$|\\@\\@?)(\\w+))"},{className:"params",begin:/\|/,end:/\|/,keywords:a},{begin:"("+e.RE_STARTERS_RE+"|unless)\\s*",keywords:"unless",contains:[r,{className:"regexp",contains:[e.BACKSLASH_ESCAPE,o],illegal:/\n/,variants:[{begin:"/",end:"/[a-z]*"},{begin:"%r{",end:"}[a-z]*"},{begin:"%r\\(",end:"\\)[a-z]*"},{begin:"%r!",end:"![a-z]*"},{begin:"%r\\[",end:"\\][a-z]*"}]}].concat(i),relevance:0}].concat(i);o.contains=_,l.contains=_;var c=[{begin:/^\s*=>/,starts:{end:"$",contains:_}},{className:"meta",begin:"^([>?]>|[\\w#]+\\(\\w+\\):\\d+:\\d+>|(\\w+-)?\\d+\\.\\d+\\.\\d(p\\d+)?[^>]+>)",starts:{end:"$",contains:_}}];return{name:"Ruby",aliases:["rb","gemspec","podspec","thor","irb"],keywords:a,illegal:/\/\*/,contains:i.concat(c).concat(_)}},Wi=function(e){return{name:"ERB",subLanguage:"xml",contains:[e.COMMENT("<%#","%>"),{begin:"<%[%=-]?",end:"[%-]?%>",subLanguage:"ruby",excludeBegin:!0,excludeEnd:!0}]}},Qi=function(e){return{name:"Erlang REPL",keywords:{built_in:"spawn spawn_link self",keyword:"after and andalso|10 band begin bnot bor bsl bsr bxor case catch cond div end fun if let not of or orelse|10 query receive rem try when xor"},contains:[{className:"meta",begin:"^[0-9]+> ",relevance:10},e.COMMENT("%","$"),{className:"number",begin:"\\b(\\d+#[a-fA-F0-9]+|\\d+(\\.\\d+)?([eE][-+]?\\d+)?)",relevance:0},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{begin:"\\?(::)?([A-Z]\\w*(::)?)+"},{begin:"->"},{begin:"ok"},{begin:"!"},{begin:"(\\b[a-z'][a-zA-Z0-9_']*:[a-z'][a-zA-Z0-9_']*)|(\\b[a-z'][a-zA-Z0-9_']*)",relevance:0},{begin:"[A-Z][a-zA-Z0-9_']*",relevance:0}]}},$i=function(e){var t="[a-z'][a-zA-Z0-9_']*",a="("+t+":"+t+"|"+t+")",n={keyword:"after and andalso|10 band begin bnot bor bsl bzr bxor case catch cond div end fun if let not of orelse|10 query receive rem try when xor",literal:"false true"},r=e.COMMENT("%","$"),i={className:"number",begin:"\\b(\\d+#[a-fA-F0-9]+|\\d+(\\.\\d+)?([eE][-+]?\\d+)?)",relevance:0},o={begin:"fun\\s+"+t+"/\\d+"},s={begin:a+"\\(",end:"\\)",returnBegin:!0,relevance:0,contains:[{begin:a,relevance:0},{begin:"\\(",end:"\\)",endsWithParent:!0,returnEnd:!0,relevance:0}]},l={begin:"{",end:"}",relevance:0},_={begin:"\\b_([A-Z][A-Za-z0-9_]*)?",relevance:0},c={begin:"[A-Z][a-zA-Z0-9_]*",relevance:0},d={begin:"#"+e.UNDERSCORE_IDENT_RE,relevance:0,returnBegin:!0,contains:[{begin:"#"+e.UNDERSCORE_IDENT_RE,relevance:0},{begin:"{",end:"}",relevance:0}]},u={beginKeywords:"fun receive if try case",end:"end",keywords:n};u.contains=[r,o,e.inherit(e.APOS_STRING_MODE,{className:""}),u,s,e.QUOTE_STRING_MODE,i,l,_,c,d];var m=[r,o,u,s,e.QUOTE_STRING_MODE,i,l,_,c,d];s.contains[1].contains=m,l.contains=m,d.contains[1].contains=m;var p={className:"params",begin:"\\(",end:"\\)",contains:m};return{name:"Erlang",aliases:["erl"],keywords:n,illegal:"(",returnBegin:!0,illegal:"\\(|#|//|/\\*|\\\\|:|;",contains:[p,e.inherit(e.TITLE_MODE,{begin:t})],starts:{end:";|\\.",keywords:n,contains:m}},r,{begin:"^-",end:"\\.",relevance:0,excludeEnd:!0,returnBegin:!0,lexemes:"-"+e.IDENT_RE,keywords:"-module -record -undef -export -ifdef -ifndef -author -copyright -doc -vsn -import -include -include_lib -compile -define -else -endif -file -behaviour -behavior -spec",contains:[p]},i,e.QUOTE_STRING_MODE,d,_,c,l,{begin:/\.$/}]}},Ki=function(e){return{name:"Excel formulae",aliases:["xlsx","xls"],case_insensitive:!0,lexemes:/[a-zA-Z][\w\.]*/,keywords:{built_in:"ABS ACCRINT ACCRINTM ACOS ACOSH ACOT ACOTH AGGREGATE ADDRESS AMORDEGRC AMORLINC AND ARABIC AREAS ASC ASIN ASINH ATAN ATAN2 ATANH AVEDEV AVERAGE AVERAGEA AVERAGEIF AVERAGEIFS BAHTTEXT BASE BESSELI BESSELJ BESSELK BESSELY BETADIST BETA.DIST BETAINV BETA.INV BIN2DEC BIN2HEX BIN2OCT BINOMDIST BINOM.DIST BINOM.DIST.RANGE BINOM.INV BITAND BITLSHIFT BITOR BITRSHIFT BITXOR CALL CEILING CEILING.MATH CEILING.PRECISE CELL CHAR CHIDIST CHIINV CHITEST CHISQ.DIST CHISQ.DIST.RT CHISQ.INV CHISQ.INV.RT CHISQ.TEST CHOOSE CLEAN CODE COLUMN COLUMNS COMBIN COMBINA COMPLEX CONCAT CONCATENATE CONFIDENCE CONFIDENCE.NORM CONFIDENCE.T CONVERT CORREL COS COSH COT COTH COUNT COUNTA COUNTBLANK COUNTIF COUNTIFS COUPDAYBS COUPDAYS COUPDAYSNC COUPNCD COUPNUM COUPPCD COVAR COVARIANCE.P COVARIANCE.S CRITBINOM CSC CSCH CUBEKPIMEMBER CUBEMEMBER CUBEMEMBERPROPERTY CUBERANKEDMEMBER CUBESET CUBESETCOUNT CUBEVALUE CUMIPMT CUMPRINC DATE DATEDIF DATEVALUE DAVERAGE DAY DAYS DAYS360 DB DBCS DCOUNT DCOUNTA DDB DEC2BIN DEC2HEX DEC2OCT DECIMAL DEGREES DELTA DEVSQ DGET DISC DMAX DMIN DOLLAR DOLLARDE DOLLARFR DPRODUCT DSTDEV DSTDEVP DSUM DURATION DVAR DVARP EDATE EFFECT ENCODEURL EOMONTH ERF ERF.PRECISE ERFC ERFC.PRECISE ERROR.TYPE EUROCONVERT EVEN EXACT EXP EXPON.DIST EXPONDIST FACT FACTDOUBLE FALSE|0 F.DIST FDIST F.DIST.RT FILTERXML FIND FINDB F.INV F.INV.RT FINV FISHER FISHERINV FIXED FLOOR FLOOR.MATH FLOOR.PRECISE FORECAST FORECAST.ETS FORECAST.ETS.CONFINT FORECAST.ETS.SEASONALITY FORECAST.ETS.STAT FORECAST.LINEAR FORMULATEXT FREQUENCY F.TEST FTEST FV FVSCHEDULE GAMMA GAMMA.DIST GAMMADIST GAMMA.INV GAMMAINV GAMMALN GAMMALN.PRECISE GAUSS GCD GEOMEAN GESTEP GETPIVOTDATA GROWTH HARMEAN HEX2BIN HEX2DEC HEX2OCT HLOOKUP HOUR HYPERLINK HYPGEOM.DIST HYPGEOMDIST IF IFERROR IFNA IFS IMABS IMAGINARY IMARGUMENT IMCONJUGATE IMCOS IMCOSH IMCOT IMCSC IMCSCH IMDIV IMEXP IMLN IMLOG10 IMLOG2 IMPOWER IMPRODUCT IMREAL IMSEC IMSECH IMSIN IMSINH IMSQRT IMSUB IMSUM IMTAN INDEX INDIRECT INFO INT INTERCEPT INTRATE IPMT IRR ISBLANK ISERR ISERROR ISEVEN ISFORMULA ISLOGICAL ISNA ISNONTEXT ISNUMBER ISODD ISREF ISTEXT ISO.CEILING ISOWEEKNUM ISPMT JIS KURT LARGE LCM LEFT LEFTB LEN LENB LINEST LN LOG LOG10 LOGEST LOGINV LOGNORM.DIST LOGNORMDIST LOGNORM.INV LOOKUP LOWER MATCH MAX MAXA MAXIFS MDETERM MDURATION MEDIAN MID MIDBs MIN MINIFS MINA MINUTE MINVERSE MIRR MMULT MOD MODE MODE.MULT MODE.SNGL MONTH MROUND MULTINOMIAL MUNIT N NA NEGBINOM.DIST NEGBINOMDIST NETWORKDAYS NETWORKDAYS.INTL NOMINAL NORM.DIST NORMDIST NORMINV NORM.INV NORM.S.DIST NORMSDIST NORM.S.INV NORMSINV NOT NOW NPER NPV NUMBERVALUE OCT2BIN OCT2DEC OCT2HEX ODD ODDFPRICE ODDFYIELD ODDLPRICE ODDLYIELD OFFSET OR PDURATION PEARSON PERCENTILE.EXC PERCENTILE.INC PERCENTILE PERCENTRANK.EXC PERCENTRANK.INC PERCENTRANK PERMUT PERMUTATIONA PHI PHONETIC PI PMT POISSON.DIST POISSON POWER PPMT PRICE PRICEDISC PRICEMAT PROB PRODUCT PROPER PV QUARTILE QUARTILE.EXC QUARTILE.INC QUOTIENT RADIANS RAND RANDBETWEEN RANK.AVG RANK.EQ RANK RATE RECEIVED REGISTER.ID REPLACE REPLACEB REPT RIGHT RIGHTB ROMAN ROUND ROUNDDOWN ROUNDUP ROW ROWS RRI RSQ RTD SEARCH SEARCHB SEC SECH SECOND SERIESSUM SHEET SHEETS SIGN SIN SINH SKEW SKEW.P SLN SLOPE SMALL SQL.REQUEST SQRT SQRTPI STANDARDIZE STDEV STDEV.P STDEV.S STDEVA STDEVP STDEVPA STEYX SUBSTITUTE SUBTOTAL SUM SUMIF SUMIFS SUMPRODUCT SUMSQ SUMX2MY2 SUMX2PY2 SUMXMY2 SWITCH SYD T TAN TANH TBILLEQ TBILLPRICE TBILLYIELD T.DIST T.DIST.2T T.DIST.RT TDIST TEXT TEXTJOIN TIME TIMEVALUE T.INV T.INV.2T TINV TODAY TRANSPOSE TREND TRIM TRIMMEAN TRUE|0 TRUNC T.TEST TTEST TYPE UNICHAR UNICODE UPPER VALUE VAR VAR.P VAR.S VARA VARP VARPA VDB VLOOKUP WEBSERVICE WEEKDAY WEEKNUM WEIBULL WEIBULL.DIST WORKDAY WORKDAY.INTL XIRR XNPV XOR YEAR YEARFRAC YIELD YIELDDISC YIELDMAT Z.TEST ZTEST"},contains:[{begin:/^=/,end:/[^=]/,returnEnd:!0,illegal:/=/,relevance:10},{className:"symbol",begin:/\b[A-Z]{1,2}\d+\b/,end:/[^\d]/,excludeEnd:!0,relevance:0},{className:"symbol",begin:/[A-Z]{0,2}\d*:[A-Z]{0,2}\d*/,relevance:0},e.BACKSLASH_ESCAPE,e.QUOTE_STRING_MODE,{className:"number",begin:e.NUMBER_RE+"(%)?",relevance:0},e.COMMENT(/\bN\(/,/\)/,{excludeBegin:!0,excludeEnd:!0,illegal:/\n/})]}},ji=function(){return{name:"FIX",contains:[{begin:/[^\u2401\u0001]+/,end:/[\u2401\u0001]/,excludeEnd:!0,returnBegin:!0,returnEnd:!1,contains:[{begin:/([^\u2401\u0001=]+)/,end:/=([^\u2401\u0001=]+)/,returnEnd:!0,returnBegin:!1,className:"attr"},{begin:/=/,end:/([\u2401\u0001])/,excludeEnd:!0,excludeBegin:!0,className:"string"}]}],case_insensitive:!0}},Xi=function(e){var t={className:"function",beginKeywords:"def",end:/[:={\[(\n;]/,excludeEnd:!0,contains:[{className:"title",begin:/[^0-9\n\t "'(),.`{}\[\]:;][^\n\t "'(),.`{}\[\]:;]+|[^0-9\n\t "'(),.`{}\[\]:;=]/}]};return{name:"Flix",keywords:{literal:"true false",keyword:"case class def else enum if impl import in lat rel index let match namespace switch type yield with"},contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{className:"string",begin:/'(.|\\[xXuU][a-zA-Z0-9]+)'/},{className:"string",variants:[{begin:'"',end:'"'}]},t,e.C_NUMBER_MODE]}},Zi=function(e){var t={variants:[e.COMMENT("!","$",{relevance:0}),e.COMMENT("^C","$",{relevance:0})]},a={className:"function",beginKeywords:"subroutine function program",illegal:"[${=\\n]",contains:[e.UNDERSCORE_TITLE_MODE,{className:"params",begin:"\\(",end:"\\)"}]};return{name:"Fortran",case_insensitive:!0,aliases:["f90","f95"],keywords:{literal:".False. .True.",keyword:"kind do concurrent local shared while private call intrinsic where elsewhere type endtype endmodule endselect endinterface end enddo endif if forall endforall only contains default return stop then block endblock endassociate public subroutine|10 function program .and. .or. .not. .le. .eq. .ge. .gt. .lt. goto save else use module select case access blank direct exist file fmt form formatted iostat name named nextrec number opened rec recl sequential status unformatted unit continue format pause cycle exit c_null_char c_alert c_backspace c_form_feed flush wait decimal round iomsg synchronous nopass non_overridable pass protected volatile abstract extends import non_intrinsic value deferred generic final enumerator class associate bind enum c_int c_short c_long c_long_long c_signed_char c_size_t c_int8_t c_int16_t c_int32_t c_int64_t c_int_least8_t c_int_least16_t c_int_least32_t c_int_least64_t c_int_fast8_t c_int_fast16_t c_int_fast32_t c_int_fast64_t c_intmax_t C_intptr_t c_float c_double c_long_double c_float_complex c_double_complex c_long_double_complex c_bool c_char c_null_ptr c_null_funptr c_new_line c_carriage_return c_horizontal_tab c_vertical_tab iso_c_binding c_loc c_funloc c_associated c_f_pointer c_ptr c_funptr iso_fortran_env character_storage_size error_unit file_storage_size input_unit iostat_end iostat_eor numeric_storage_size output_unit c_f_procpointer ieee_arithmetic ieee_support_underflow_control ieee_get_underflow_mode ieee_set_underflow_mode newunit contiguous recursive pad position action delim readwrite eor advance nml interface procedure namelist include sequence elemental pure impure integer real character complex logical codimension dimension allocatable|10 parameter external implicit|10 none double precision assign intent optional pointer target in out common equivalence data",built_in:"alog alog10 amax0 amax1 amin0 amin1 amod cabs ccos cexp clog csin csqrt dabs dacos dasin datan datan2 dcos dcosh ddim dexp dint dlog dlog10 dmax1 dmin1 dmod dnint dsign dsin dsinh dsqrt dtan dtanh float iabs idim idint idnint ifix isign max0 max1 min0 min1 sngl algama cdabs cdcos cdexp cdlog cdsin cdsqrt cqabs cqcos cqexp cqlog cqsin cqsqrt dcmplx dconjg derf derfc dfloat dgamma dimag dlgama iqint qabs qacos qasin qatan qatan2 qcmplx qconjg qcos qcosh qdim qerf qerfc qexp qgamma qimag qlgama qlog qlog10 qmax1 qmin1 qmod qnint qsign qsin qsinh qsqrt qtan qtanh abs acos aimag aint anint asin atan atan2 char cmplx conjg cos cosh exp ichar index int log log10 max min nint sign sin sinh sqrt tan tanh print write dim lge lgt lle llt mod nullify allocate deallocate adjustl adjustr all allocated any associated bit_size btest ceiling count cshift date_and_time digits dot_product eoshift epsilon exponent floor fraction huge iand ibclr ibits ibset ieor ior ishft ishftc lbound len_trim matmul maxexponent maxloc maxval merge minexponent minloc minval modulo mvbits nearest pack present product radix random_number random_seed range repeat reshape rrspacing scale scan selected_int_kind selected_real_kind set_exponent shape size spacing spread sum system_clock tiny transpose trim ubound unpack verify achar iachar transfer dble entry dprod cpu_time command_argument_count get_command get_command_argument get_environment_variable is_iostat_end ieee_arithmetic ieee_support_underflow_control ieee_get_underflow_mode ieee_set_underflow_mode is_iostat_eor move_alloc new_line selected_char_kind same_type_as extends_type_of acosh asinh atanh bessel_j0 bessel_j1 bessel_jn bessel_y0 bessel_y1 bessel_yn erf erfc erfc_scaled gamma log_gamma hypot norm2 atomic_define atomic_ref execute_command_line leadz trailz storage_size merge_bits bge bgt ble blt dshiftl dshiftr findloc iall iany iparity image_index lcobound ucobound maskl maskr num_images parity popcnt poppar shifta shiftl shiftr this_image sync change team co_broadcast co_max co_min co_sum co_reduce"},illegal:/\/\*/,contains:[{className:"string",relevance:0,variants:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]},a,{begin:/^C\s*=(?!=)/,relevance:0},t,{className:"number",begin:"(?=\\b|\\+|\\-|\\.)(?:\\.|\\d+\\.?)\\d*([de][+-]?\\d+)?(_[a-z_\\d]+)?",relevance:0}]}},Ji=function(e){var t={begin:"<",end:">",contains:[e.inherit(e.TITLE_MODE,{begin:/'[a-zA-Z0-9_]+/})]};return{name:"F#",aliases:["fs"],keywords:"abstract and as assert base begin class default delegate do done downcast downto elif else end exception extern false finally for fun function global if in inherit inline interface internal lazy let match member module mutable namespace new null of open or override private public rec return sig static struct then to true try type upcast use val void when while with yield",illegal:/\/\*/,contains:[{className:"keyword",begin:/\b(yield|return|let|do)!/},{className:"string",begin:'@"',end:'"',contains:[{begin:'""'}]},{className:"string",begin:'"""',end:'"""'},e.COMMENT("\\(\\*","\\*\\)"),{className:"class",beginKeywords:"type",end:"\\(|=|$",excludeEnd:!0,contains:[e.UNDERSCORE_TITLE_MODE,t]},{className:"meta",begin:"\\[<",end:">\\]",relevance:10},{className:"symbol",begin:"\\B('[A-Za-z])\\b",contains:[e.BACKSLASH_ESCAPE]},e.C_LINE_COMMENT_MODE,e.inherit(e.QUOTE_STRING_MODE,{illegal:null}),e.C_NUMBER_MODE]}},eo=function(e){var t={keyword:"abort acronym acronyms alias all and assign binary card diag display else eq file files for free ge gt if integer le loop lt maximizing minimizing model models ne negative no not option options or ord positive prod put putpage puttl repeat sameas semicont semiint smax smin solve sos1 sos2 sum system table then until using while xor yes",literal:"eps inf na","built-in":"abs arccos arcsin arctan arctan2 Beta betaReg binomial ceil centropy cos cosh cvPower div div0 eDist entropy errorf execSeed exp fact floor frac gamma gammaReg log logBeta logGamma log10 log2 mapVal max min mod ncpCM ncpF ncpVUpow ncpVUsin normal pi poly power randBinomial randLinear randTriangle round rPower sigmoid sign signPower sin sinh slexp sllog10 slrec sqexp sqlog10 sqr sqrec sqrt tan tanh trunc uniform uniformInt vcPower bool_and bool_eqv bool_imp bool_not bool_or bool_xor ifThen rel_eq rel_ge rel_gt rel_le rel_lt rel_ne gday gdow ghour gleap gmillisec gminute gmonth gsecond gyear jdate jnow jstart jtime errorLevel execError gamsRelease gamsVersion handleCollect handleDelete handleStatus handleSubmit heapFree heapLimit heapSize jobHandle jobKill jobStatus jobTerminate licenseLevel licenseStatus maxExecError sleep timeClose timeComp timeElapsed timeExec timeStart"},a={className:"symbol",variants:[{begin:/\=[lgenxc]=/},{begin:/\$/}]},n={className:"comment",variants:[{begin:"'",end:"'"},{begin:'"',end:'"'}],illegal:"\\n", +contains:[e.BACKSLASH_ESCAPE]},r={begin:"/",end:"/",keywords:t,contains:[n,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,e.C_NUMBER_MODE]},i={begin:/[a-z][a-z0-9_]*(\([a-z0-9_, ]*\))?[ \t]+/,excludeBegin:!0,end:"$",endsWithParent:!0,contains:[n,r,{className:"comment",begin:/([ ]*[a-z0-9&#*=?@>\\<:\-,()$\[\]_.{}!+%^]+)+/,relevance:0}]};return{name:"GAMS",aliases:["gms"],case_insensitive:!0,keywords:t,contains:[e.COMMENT(/^\$ontext/,/^\$offtext/),{className:"meta",begin:"^\\$[a-z0-9]+",end:"$",returnBegin:!0,contains:[{className:"meta-keyword",begin:"^\\$[a-z0-9]+"}]},e.COMMENT("^\\*","$"),e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,{beginKeywords:"set sets parameter parameters variable variables scalar scalars equation equations",end:";",contains:[e.COMMENT("^\\*","$"),e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,r,i]},{beginKeywords:"table",end:";",returnBegin:!0,contains:[{beginKeywords:"table",end:"$",contains:[i]},e.COMMENT("^\\*","$"),e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,e.C_NUMBER_MODE]},{className:"function",begin:/^[a-z][a-z0-9_,\-+' ()$]+\.{2}/,returnBegin:!0,contains:[{className:"title",begin:/^[a-z0-9_]+/},{className:"params",begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0},a]},e.C_NUMBER_MODE,a]}},to=function(e){var t={keyword:"bool break call callexe checkinterrupt clear clearg closeall cls comlog compile continue create debug declare delete disable dlibrary dllcall do dos ed edit else elseif enable end endfor endif endp endo errorlog errorlogat expr external fn for format goto gosub graph if keyword let lib library line load loadarray loadexe loadf loadk loadm loadp loads loadx local locate loopnextindex lprint lpwidth lshow matrix msym ndpclex new open output outwidth plot plotsym pop prcsn print printdos proc push retp return rndcon rndmod rndmult rndseed run save saveall screen scroll setarray show sparse stop string struct system trace trap threadfor threadendfor threadbegin threadjoin threadstat threadend until use while winprint ne ge le gt lt and xor or not eq eqv",built_in:"abs acf aconcat aeye amax amean AmericanBinomCall AmericanBinomCall_Greeks AmericanBinomCall_ImpVol AmericanBinomPut AmericanBinomPut_Greeks AmericanBinomPut_ImpVol AmericanBSCall AmericanBSCall_Greeks AmericanBSCall_ImpVol AmericanBSPut AmericanBSPut_Greeks AmericanBSPut_ImpVol amin amult annotationGetDefaults annotationSetBkd annotationSetFont annotationSetLineColor annotationSetLineStyle annotationSetLineThickness annualTradingDays arccos arcsin areshape arrayalloc arrayindex arrayinit arraytomat asciiload asclabel astd astds asum atan atan2 atranspose axmargin balance band bandchol bandcholsol bandltsol bandrv bandsolpd bar base10 begwind besselj bessely beta box boxcox cdfBeta cdfBetaInv cdfBinomial cdfBinomialInv cdfBvn cdfBvn2 cdfBvn2e cdfCauchy cdfCauchyInv cdfChic cdfChii cdfChinc cdfChincInv cdfExp cdfExpInv cdfFc cdfFnc cdfFncInv cdfGam cdfGenPareto cdfHyperGeo cdfLaplace cdfLaplaceInv cdfLogistic cdfLogisticInv cdfmControlCreate cdfMvn cdfMvn2e cdfMvnce cdfMvne cdfMvt2e cdfMvtce cdfMvte cdfN cdfN2 cdfNc cdfNegBinomial cdfNegBinomialInv cdfNi cdfPoisson cdfPoissonInv cdfRayleigh cdfRayleighInv cdfTc cdfTci cdfTnc cdfTvn cdfWeibull cdfWeibullInv cdir ceil ChangeDir chdir chiBarSquare chol choldn cholsol cholup chrs close code cols colsf combinate combinated complex con cond conj cons ConScore contour conv convertsatostr convertstrtosa corrm corrms corrvc corrx corrxs cos cosh counts countwts crossprd crout croutp csrcol csrlin csvReadM csvReadSA cumprodc cumsumc curve cvtos datacreate datacreatecomplex datalist dataload dataloop dataopen datasave date datestr datestring datestrymd dayinyr dayofweek dbAddDatabase dbClose dbCommit dbCreateQuery dbExecQuery dbGetConnectOptions dbGetDatabaseName dbGetDriverName dbGetDrivers dbGetHostName dbGetLastErrorNum dbGetLastErrorText dbGetNumericalPrecPolicy dbGetPassword dbGetPort dbGetTableHeaders dbGetTables dbGetUserName dbHasFeature dbIsDriverAvailable dbIsOpen dbIsOpenError dbOpen dbQueryBindValue dbQueryClear dbQueryCols dbQueryExecPrepared dbQueryFetchAllM dbQueryFetchAllSA dbQueryFetchOneM dbQueryFetchOneSA dbQueryFinish dbQueryGetBoundValue dbQueryGetBoundValues dbQueryGetField dbQueryGetLastErrorNum dbQueryGetLastErrorText dbQueryGetLastInsertID dbQueryGetLastQuery dbQueryGetPosition dbQueryIsActive dbQueryIsForwardOnly dbQueryIsNull dbQueryIsSelect dbQueryIsValid dbQueryPrepare dbQueryRows dbQuerySeek dbQuerySeekFirst dbQuerySeekLast dbQuerySeekNext dbQuerySeekPrevious dbQuerySetForwardOnly dbRemoveDatabase dbRollback dbSetConnectOptions dbSetDatabaseName dbSetHostName dbSetNumericalPrecPolicy dbSetPort dbSetUserName dbTransaction DeleteFile delif delrows denseToSp denseToSpRE denToZero design det detl dfft dffti diag diagrv digamma doswin DOSWinCloseall DOSWinOpen dotfeq dotfeqmt dotfge dotfgemt dotfgt dotfgtmt dotfle dotflemt dotflt dotfltmt dotfne dotfnemt draw drop dsCreate dstat dstatmt dstatmtControlCreate dtdate dtday dttime dttodtv dttostr dttoutc dtvnormal dtvtodt dtvtoutc dummy dummybr dummydn eig eigh eighv eigv elapsedTradingDays endwind envget eof eqSolve eqSolvemt eqSolvemtControlCreate eqSolvemtOutCreate eqSolveset erf erfc erfccplx erfcplx error etdays ethsec etstr EuropeanBinomCall EuropeanBinomCall_Greeks EuropeanBinomCall_ImpVol EuropeanBinomPut EuropeanBinomPut_Greeks EuropeanBinomPut_ImpVol EuropeanBSCall EuropeanBSCall_Greeks EuropeanBSCall_ImpVol EuropeanBSPut EuropeanBSPut_Greeks EuropeanBSPut_ImpVol exctsmpl exec execbg exp extern eye fcheckerr fclearerr feq feqmt fflush fft ffti fftm fftmi fftn fge fgemt fgets fgetsa fgetsat fgetst fgt fgtmt fileinfo filesa fle flemt floor flt fltmt fmod fne fnemt fonts fopen formatcv formatnv fputs fputst fseek fstrerror ftell ftocv ftos ftostrC gamma gammacplx gammaii gausset gdaAppend gdaCreate gdaDStat gdaDStatMat gdaGetIndex gdaGetName gdaGetNames gdaGetOrders gdaGetType gdaGetTypes gdaGetVarInfo gdaIsCplx gdaLoad gdaPack gdaRead gdaReadByIndex gdaReadSome gdaReadSparse gdaReadStruct gdaReportVarInfo gdaSave gdaUpdate gdaUpdateAndPack gdaVars gdaWrite gdaWrite32 gdaWriteSome getarray getdims getf getGAUSShome getmatrix getmatrix4D getname getnamef getNextTradingDay getNextWeekDay getnr getorders getpath getPreviousTradingDay getPreviousWeekDay getRow getscalar3D getscalar4D getTrRow getwind glm gradcplx gradMT gradMTm gradMTT gradMTTm gradp graphprt graphset hasimag header headermt hess hessMT hessMTg hessMTgw hessMTm hessMTmw hessMTT hessMTTg hessMTTgw hessMTTm hessMTw hessp hist histf histp hsec imag indcv indexcat indices indices2 indicesf indicesfn indnv indsav integrate1d integrateControlCreate intgrat2 intgrat3 inthp1 inthp2 inthp3 inthp4 inthpControlCreate intquad1 intquad2 intquad3 intrleav intrleavsa intrsect intsimp inv invpd invswp iscplx iscplxf isden isinfnanmiss ismiss key keyav keyw lag lag1 lagn lapEighb lapEighi lapEighvb lapEighvi lapgEig lapgEigh lapgEighv lapgEigv lapgSchur lapgSvdcst lapgSvds lapgSvdst lapSvdcusv lapSvds lapSvdusv ldlp ldlsol linSolve listwise ln lncdfbvn lncdfbvn2 lncdfmvn lncdfn lncdfn2 lncdfnc lnfact lngammacplx lnpdfmvn lnpdfmvt lnpdfn lnpdft loadd loadstruct loadwind loess loessmt loessmtControlCreate log loglog logx logy lower lowmat lowmat1 ltrisol lu lusol machEpsilon make makevars makewind margin matalloc matinit mattoarray maxbytes maxc maxindc maxv maxvec mbesselei mbesselei0 mbesselei1 mbesseli mbesseli0 mbesseli1 meanc median mergeby mergevar minc minindc minv miss missex missrv moment momentd movingave movingaveExpwgt movingaveWgt nextindex nextn nextnevn nextwind ntos null null1 numCombinations ols olsmt olsmtControlCreate olsqr olsqr2 olsqrmt ones optn optnevn orth outtyp pacf packedToSp packr parse pause pdfCauchy pdfChi pdfExp pdfGenPareto pdfHyperGeo pdfLaplace pdfLogistic pdfn pdfPoisson pdfRayleigh pdfWeibull pi pinv pinvmt plotAddArrow plotAddBar plotAddBox plotAddHist plotAddHistF plotAddHistP plotAddPolar plotAddScatter plotAddShape plotAddTextbox plotAddTS plotAddXY plotArea plotBar plotBox plotClearLayout plotContour plotCustomLayout plotGetDefaults plotHist plotHistF plotHistP plotLayout plotLogLog plotLogX plotLogY plotOpenWindow plotPolar plotSave plotScatter plotSetAxesPen plotSetBar plotSetBarFill plotSetBarStacked plotSetBkdColor plotSetFill plotSetGrid plotSetLegend plotSetLineColor plotSetLineStyle plotSetLineSymbol plotSetLineThickness plotSetNewWindow plotSetTitle plotSetWhichYAxis plotSetXAxisShow plotSetXLabel plotSetXRange plotSetXTicInterval plotSetXTicLabel plotSetYAxisShow plotSetYLabel plotSetYRange plotSetZAxisShow plotSetZLabel plotSurface plotTS plotXY polar polychar polyeval polygamma polyint polymake polymat polymroot polymult polyroot pqgwin previousindex princomp printfm printfmt prodc psi putarray putf putvals pvCreate pvGetIndex pvGetParNames pvGetParVector pvLength pvList pvPack pvPacki pvPackm pvPackmi pvPacks pvPacksi pvPacksm pvPacksmi pvPutParVector pvTest pvUnpack QNewton QNewtonmt QNewtonmtControlCreate QNewtonmtOutCreate QNewtonSet QProg QProgmt QProgmtInCreate qqr qqre qqrep qr qre qrep qrsol qrtsol qtyr qtyre qtyrep quantile quantiled qyr qyre qyrep qz rank rankindx readr real reclassify reclassifyCuts recode recserar recsercp recserrc rerun rescale reshape rets rev rfft rffti rfftip rfftn rfftnp rfftp rndBernoulli rndBeta rndBinomial rndCauchy rndChiSquare rndCon rndCreateState rndExp rndGamma rndGeo rndGumbel rndHyperGeo rndi rndKMbeta rndKMgam rndKMi rndKMn rndKMnb rndKMp rndKMu rndKMvm rndLaplace rndLCbeta rndLCgam rndLCi rndLCn rndLCnb rndLCp rndLCu rndLCvm rndLogNorm rndMTu rndMVn rndMVt rndn rndnb rndNegBinomial rndp rndPoisson rndRayleigh rndStateSkip rndu rndvm rndWeibull rndWishart rotater round rows rowsf rref sampleData satostrC saved saveStruct savewind scale scale3d scalerr scalinfnanmiss scalmiss schtoc schur searchsourcepath seekr select selif seqa seqm setdif setdifsa setvars setvwrmode setwind shell shiftr sin singleindex sinh sleep solpd sortc sortcc sortd sorthc sorthcc sortind sortindc sortmc sortr sortrc spBiconjGradSol spChol spConjGradSol spCreate spDenseSubmat spDiagRvMat spEigv spEye spLDL spline spLU spNumNZE spOnes spreadSheetReadM spreadSheetReadSA spreadSheetWrite spScale spSubmat spToDense spTrTDense spTScalar spZeros sqpSolve sqpSolveMT sqpSolveMTControlCreate sqpSolveMTlagrangeCreate sqpSolveMToutCreate sqpSolveSet sqrt statements stdc stdsc stocv stof strcombine strindx strlen strput strrindx strsect strsplit strsplitPad strtodt strtof strtofcplx strtriml strtrimr strtrunc strtruncl strtruncpad strtruncr submat subscat substute subvec sumc sumr surface svd svd1 svd2 svdcusv svds svdusv sysstate tab tan tanh tempname time timedt timestr timeutc title tkf2eps tkf2ps tocart todaydt toeplitz token topolar trapchk trigamma trimr trunc type typecv typef union unionsa uniqindx uniqindxsa unique uniquesa upmat upmat1 upper utctodt utctodtv utrisol vals varCovMS varCovXS varget vargetl varmall varmares varput varputl vartypef vcm vcms vcx vcxs vec vech vecr vector vget view viewxyz vlist vnamecv volume vput vread vtypecv wait waitc walkindex where window writer xlabel xlsGetSheetCount xlsGetSheetSize xlsGetSheetTypes xlsMakeRange xlsReadM xlsReadSA xlsWrite xlsWriteM xlsWriteSA xpnd xtics xy xyz ylabel ytics zeros zeta zlabel ztics cdfEmpirical dot h5create h5open h5read h5readAttribute h5write h5writeAttribute ldl plotAddErrorBar plotAddSurface plotCDFEmpirical plotSetColormap plotSetContourLabels plotSetLegendFont plotSetTextInterpreter plotSetXTicCount plotSetYTicCount plotSetZLevels powerm strjoin sylvester strtrim",literal:"DB_AFTER_LAST_ROW DB_ALL_TABLES DB_BATCH_OPERATIONS DB_BEFORE_FIRST_ROW DB_BLOB DB_EVENT_NOTIFICATIONS DB_FINISH_QUERY DB_HIGH_PRECISION DB_LAST_INSERT_ID DB_LOW_PRECISION_DOUBLE DB_LOW_PRECISION_INT32 DB_LOW_PRECISION_INT64 DB_LOW_PRECISION_NUMBERS DB_MULTIPLE_RESULT_SETS DB_NAMED_PLACEHOLDERS DB_POSITIONAL_PLACEHOLDERS DB_PREPARED_QUERIES DB_QUERY_SIZE DB_SIMPLE_LOCKING DB_SYSTEM_TABLES DB_TABLES DB_TRANSACTIONS DB_UNICODE DB_VIEWS __STDIN __STDOUT __STDERR __FILE_DIR"},a=e.COMMENT("@","@"),n={className:"meta",begin:"#",end:"$",keywords:{"meta-keyword":"define definecs|10 undef ifdef ifndef iflight ifdllcall ifmac ifos2win ifunix else endif lineson linesoff srcfile srcline"},contains:[{begin:/\\\n/,relevance:0},{beginKeywords:"include",end:"$",keywords:{"meta-keyword":"include"},contains:[{className:"meta-string",begin:'"',end:'"',illegal:"\\n"}]},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,a]},r={begin:/\bstruct\s+/,end:/\s/,keywords:"struct",contains:[{className:"type",begin:e.UNDERSCORE_IDENT_RE,relevance:0}]},i=[{className:"params",begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,endsWithParent:!0,relevance:0,contains:[{className:"literal",begin:/\.\.\./},e.C_NUMBER_MODE,e.C_BLOCK_COMMENT_MODE,a,r]}],o={className:"title",begin:e.UNDERSCORE_IDENT_RE,relevance:0},s=function(t,n,r){var s=e.inherit({className:"function",beginKeywords:t,end:n,excludeEnd:!0,contains:[].concat(i)},r||{});return s.contains.push(o),s.contains.push(e.C_NUMBER_MODE),s.contains.push(e.C_BLOCK_COMMENT_MODE),s.contains.push(a),s},l={className:"built_in",begin:"\\b("+t.built_in.split(" ").join("|")+")\\b"},_={className:"string",begin:'"',end:'"',contains:[e.BACKSLASH_ESCAPE],relevance:0},c={begin:e.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,keywords:t,relevance:0,contains:[{beginKeywords:t.keyword},l,{className:"built_in",begin:e.UNDERSCORE_IDENT_RE,relevance:0}]},d={begin:/\(/,end:/\)/,relevance:0,keywords:{built_in:t.built_in,literal:t.literal},contains:[e.C_NUMBER_MODE,e.C_BLOCK_COMMENT_MODE,a,l,c,_,"self"]};return c.contains.push(d),{name:"GAUSS",aliases:["gss"],case_insensitive:!0,keywords:t,illegal:/(\{[%#]|[%#]\}| <- )/,contains:[e.C_NUMBER_MODE,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,a,_,n,{className:"keyword",begin:/\bexternal (matrix|string|array|sparse matrix|struct|proc|keyword|fn)/},s("proc keyword",";"),s("fn","="),{beginKeywords:"for threadfor",end:/;/,relevance:0,contains:[e.C_BLOCK_COMMENT_MODE,a,d]},{variants:[{begin:e.UNDERSCORE_IDENT_RE+"\\."+e.UNDERSCORE_IDENT_RE},{begin:e.UNDERSCORE_IDENT_RE+"\\s*="}],relevance:0},c,r]}},ao=function(e){var t=[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.COMMENT(/\(/,/\)/),e.inherit(e.C_NUMBER_MODE,{begin:"([-+]?([0-9]*\\.?[0-9]+\\.?))|"+e.C_NUMBER_RE}),e.inherit(e.APOS_STRING_MODE,{illegal:null}),e.inherit(e.QUOTE_STRING_MODE,{illegal:null}),{className:"name",begin:"([G])([0-9]+\\.?[0-9]?)"},{className:"name",begin:"([M])([0-9]+\\.?[0-9]?)"},{className:"attr",begin:"(VC|VS|#)",end:"(\\d+)"},{className:"attr",begin:"(VZOFX|VZOFY|VZOFZ)"},{className:"built_in",begin:"(ATAN|ABS|ACOS|ASIN|SIN|COS|EXP|FIX|FUP|ROUND|LN|TAN)(\\[)",end:"([-+]?([0-9]*\\.?[0-9]+\\.?))(\\])"},{className:"symbol",variants:[{begin:"N",end:"\\d+",illegal:"\\W"}]}];return{name:"G-code (ISO 6983)",aliases:["nc"],case_insensitive:!0,lexemes:"[A-Z_][A-Z0-9_.]*",keywords:"IF DO WHILE ENDWHILE CALL ENDIF SUB ENDSUB GOTO REPEAT ENDREPEAT EQ LT GT NE GE LE OR XOR",contains:[{className:"meta",begin:"\\%"},{className:"meta",begin:"([O])([0-9]+)"}].concat(t)}},no=function(e){return{name:"Gherkin",aliases:["feature"],keywords:"Feature Background Ability Business Need Scenario Scenarios Scenario Outline Scenario Template Examples Given And Then But When",contains:[{className:"symbol",begin:"\\*",relevance:0},{className:"meta",begin:"@[^@\\s]+"},{begin:"\\|",end:"\\|\\w*$",contains:[{className:"string",begin:"[^|]+"}]},{className:"variable",begin:"<",end:">"},e.HASH_COMMENT_MODE,{className:"string",begin:'"""',end:'"""'},e.QUOTE_STRING_MODE]}},ro=function(e){return{name:"GLSL",keywords:{keyword:"break continue discard do else for if return while switch case default attribute binding buffer ccw centroid centroid varying coherent column_major const cw depth_any depth_greater depth_less depth_unchanged early_fragment_tests equal_spacing flat fractional_even_spacing fractional_odd_spacing highp in index inout invariant invocations isolines layout line_strip lines lines_adjacency local_size_x local_size_y local_size_z location lowp max_vertices mediump noperspective offset origin_upper_left out packed patch pixel_center_integer point_mode points precise precision quads r11f_g11f_b10f r16 r16_snorm r16f r16i r16ui r32f r32i r32ui r8 r8_snorm r8i r8ui readonly restrict rg16 rg16_snorm rg16f rg16i rg16ui rg32f rg32i rg32ui rg8 rg8_snorm rg8i rg8ui rgb10_a2 rgb10_a2ui rgba16 rgba16_snorm rgba16f rgba16i rgba16ui rgba32f rgba32i rgba32ui rgba8 rgba8_snorm rgba8i rgba8ui row_major sample shared smooth std140 std430 stream triangle_strip triangles triangles_adjacency uniform varying vertices volatile writeonly",type:"atomic_uint bool bvec2 bvec3 bvec4 dmat2 dmat2x2 dmat2x3 dmat2x4 dmat3 dmat3x2 dmat3x3 dmat3x4 dmat4 dmat4x2 dmat4x3 dmat4x4 double dvec2 dvec3 dvec4 float iimage1D iimage1DArray iimage2D iimage2DArray iimage2DMS iimage2DMSArray iimage2DRect iimage3D iimageBuffer iimageCube iimageCubeArray image1D image1DArray image2D image2DArray image2DMS image2DMSArray image2DRect image3D imageBuffer imageCube imageCubeArray int isampler1D isampler1DArray isampler2D isampler2DArray isampler2DMS isampler2DMSArray isampler2DRect isampler3D isamplerBuffer isamplerCube isamplerCubeArray ivec2 ivec3 ivec4 mat2 mat2x2 mat2x3 mat2x4 mat3 mat3x2 mat3x3 mat3x4 mat4 mat4x2 mat4x3 mat4x4 sampler1D sampler1DArray sampler1DArrayShadow sampler1DShadow sampler2D sampler2DArray sampler2DArrayShadow sampler2DMS sampler2DMSArray sampler2DRect sampler2DRectShadow sampler2DShadow sampler3D samplerBuffer samplerCube samplerCubeArray samplerCubeArrayShadow samplerCubeShadow image1D uimage1DArray uimage2D uimage2DArray uimage2DMS uimage2DMSArray uimage2DRect uimage3D uimageBuffer uimageCube uimageCubeArray uint usampler1D usampler1DArray usampler2D usampler2DArray usampler2DMS usampler2DMSArray usampler2DRect usampler3D samplerBuffer usamplerCube usamplerCubeArray uvec2 uvec3 uvec4 vec2 vec3 vec4 void",built_in:"gl_MaxAtomicCounterBindings gl_MaxAtomicCounterBufferSize gl_MaxClipDistances gl_MaxClipPlanes gl_MaxCombinedAtomicCounterBuffers gl_MaxCombinedAtomicCounters gl_MaxCombinedImageUniforms gl_MaxCombinedImageUnitsAndFragmentOutputs gl_MaxCombinedTextureImageUnits gl_MaxComputeAtomicCounterBuffers gl_MaxComputeAtomicCounters gl_MaxComputeImageUniforms gl_MaxComputeTextureImageUnits gl_MaxComputeUniformComponents gl_MaxComputeWorkGroupCount gl_MaxComputeWorkGroupSize gl_MaxDrawBuffers gl_MaxFragmentAtomicCounterBuffers gl_MaxFragmentAtomicCounters gl_MaxFragmentImageUniforms gl_MaxFragmentInputComponents gl_MaxFragmentInputVectors gl_MaxFragmentUniformComponents gl_MaxFragmentUniformVectors gl_MaxGeometryAtomicCounterBuffers gl_MaxGeometryAtomicCounters gl_MaxGeometryImageUniforms gl_MaxGeometryInputComponents gl_MaxGeometryOutputComponents gl_MaxGeometryOutputVertices gl_MaxGeometryTextureImageUnits gl_MaxGeometryTotalOutputComponents gl_MaxGeometryUniformComponents gl_MaxGeometryVaryingComponents gl_MaxImageSamples gl_MaxImageUnits gl_MaxLights gl_MaxPatchVertices gl_MaxProgramTexelOffset gl_MaxTessControlAtomicCounterBuffers gl_MaxTessControlAtomicCounters gl_MaxTessControlImageUniforms gl_MaxTessControlInputComponents gl_MaxTessControlOutputComponents gl_MaxTessControlTextureImageUnits gl_MaxTessControlTotalOutputComponents gl_MaxTessControlUniformComponents gl_MaxTessEvaluationAtomicCounterBuffers gl_MaxTessEvaluationAtomicCounters gl_MaxTessEvaluationImageUniforms gl_MaxTessEvaluationInputComponents gl_MaxTessEvaluationOutputComponents gl_MaxTessEvaluationTextureImageUnits gl_MaxTessEvaluationUniformComponents gl_MaxTessGenLevel gl_MaxTessPatchComponents gl_MaxTextureCoords gl_MaxTextureImageUnits gl_MaxTextureUnits gl_MaxVaryingComponents gl_MaxVaryingFloats gl_MaxVaryingVectors gl_MaxVertexAtomicCounterBuffers gl_MaxVertexAtomicCounters gl_MaxVertexAttribs gl_MaxVertexImageUniforms gl_MaxVertexOutputComponents gl_MaxVertexOutputVectors gl_MaxVertexTextureImageUnits gl_MaxVertexUniformComponents gl_MaxVertexUniformVectors gl_MaxViewports gl_MinProgramTexelOffset gl_BackColor gl_BackLightModelProduct gl_BackLightProduct gl_BackMaterial gl_BackSecondaryColor gl_ClipDistance gl_ClipPlane gl_ClipVertex gl_Color gl_DepthRange gl_EyePlaneQ gl_EyePlaneR gl_EyePlaneS gl_EyePlaneT gl_Fog gl_FogCoord gl_FogFragCoord gl_FragColor gl_FragCoord gl_FragData gl_FragDepth gl_FrontColor gl_FrontFacing gl_FrontLightModelProduct gl_FrontLightProduct gl_FrontMaterial gl_FrontSecondaryColor gl_GlobalInvocationID gl_InstanceID gl_InvocationID gl_Layer gl_LightModel gl_LightSource gl_LocalInvocationID gl_LocalInvocationIndex gl_ModelViewMatrix gl_ModelViewMatrixInverse gl_ModelViewMatrixInverseTranspose gl_ModelViewMatrixTranspose gl_ModelViewProjectionMatrix gl_ModelViewProjectionMatrixInverse gl_ModelViewProjectionMatrixInverseTranspose gl_ModelViewProjectionMatrixTranspose gl_MultiTexCoord0 gl_MultiTexCoord1 gl_MultiTexCoord2 gl_MultiTexCoord3 gl_MultiTexCoord4 gl_MultiTexCoord5 gl_MultiTexCoord6 gl_MultiTexCoord7 gl_Normal gl_NormalMatrix gl_NormalScale gl_NumSamples gl_NumWorkGroups gl_ObjectPlaneQ gl_ObjectPlaneR gl_ObjectPlaneS gl_ObjectPlaneT gl_PatchVerticesIn gl_Point gl_PointCoord gl_PointSize gl_Position gl_PrimitiveID gl_PrimitiveIDIn gl_ProjectionMatrix gl_ProjectionMatrixInverse gl_ProjectionMatrixInverseTranspose gl_ProjectionMatrixTranspose gl_SampleID gl_SampleMask gl_SampleMaskIn gl_SamplePosition gl_SecondaryColor gl_TessCoord gl_TessLevelInner gl_TessLevelOuter gl_TexCoord gl_TextureEnvColor gl_TextureMatrix gl_TextureMatrixInverse gl_TextureMatrixInverseTranspose gl_TextureMatrixTranspose gl_Vertex gl_VertexID gl_ViewportIndex gl_WorkGroupID gl_WorkGroupSize gl_in gl_out EmitStreamVertex EmitVertex EndPrimitive EndStreamPrimitive abs acos acosh all any asin asinh atan atanh atomicAdd atomicAnd atomicCompSwap atomicCounter atomicCounterDecrement atomicCounterIncrement atomicExchange atomicMax atomicMin atomicOr atomicXor barrier bitCount bitfieldExtract bitfieldInsert bitfieldReverse ceil clamp cos cosh cross dFdx dFdy degrees determinant distance dot equal exp exp2 faceforward findLSB findMSB floatBitsToInt floatBitsToUint floor fma fract frexp ftransform fwidth greaterThan greaterThanEqual groupMemoryBarrier imageAtomicAdd imageAtomicAnd imageAtomicCompSwap imageAtomicExchange imageAtomicMax imageAtomicMin imageAtomicOr imageAtomicXor imageLoad imageSize imageStore imulExtended intBitsToFloat interpolateAtCentroid interpolateAtOffset interpolateAtSample inverse inversesqrt isinf isnan ldexp length lessThan lessThanEqual log log2 matrixCompMult max memoryBarrier memoryBarrierAtomicCounter memoryBarrierBuffer memoryBarrierImage memoryBarrierShared min mix mod modf noise1 noise2 noise3 noise4 normalize not notEqual outerProduct packDouble2x32 packHalf2x16 packSnorm2x16 packSnorm4x8 packUnorm2x16 packUnorm4x8 pow radians reflect refract round roundEven shadow1D shadow1DLod shadow1DProj shadow1DProjLod shadow2D shadow2DLod shadow2DProj shadow2DProjLod sign sin sinh smoothstep sqrt step tan tanh texelFetch texelFetchOffset texture texture1D texture1DLod texture1DProj texture1DProjLod texture2D texture2DLod texture2DProj texture2DProjLod texture3D texture3DLod texture3DProj texture3DProjLod textureCube textureCubeLod textureGather textureGatherOffset textureGatherOffsets textureGrad textureGradOffset textureLod textureLodOffset textureOffset textureProj textureProjGrad textureProjGradOffset textureProjLod textureProjLodOffset textureProjOffset textureQueryLevels textureQueryLod textureSize transpose trunc uaddCarry uintBitsToFloat umulExtended unpackDouble2x32 unpackHalf2x16 unpackSnorm2x16 unpackSnorm4x8 unpackUnorm2x16 unpackUnorm4x8 usubBorrow",literal:"true false"},illegal:'"',contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.C_NUMBER_MODE,{className:"meta",begin:"#",end:"$"}]}},io=function(e){return{name:"GML",aliases:["gml","GML"],case_insensitive:!1,keywords:{keyword:"begin end if then else while do for break continue with until repeat exit and or xor not return mod div switch case default var globalvar enum #macro #region #endregion", +built_in:"is_real is_string is_array is_undefined is_int32 is_int64 is_ptr is_vec3 is_vec4 is_matrix is_bool typeof variable_global_exists variable_global_get variable_global_set variable_instance_exists variable_instance_get variable_instance_set variable_instance_get_names array_length_1d array_length_2d array_height_2d array_equals array_create array_copy random random_range irandom irandom_range random_set_seed random_get_seed randomize randomise choose abs round floor ceil sign frac sqrt sqr exp ln log2 log10 sin cos tan arcsin arccos arctan arctan2 dsin dcos dtan darcsin darccos darctan darctan2 degtorad radtodeg power logn min max mean median clamp lerp dot_product dot_product_3d dot_product_normalised dot_product_3d_normalised dot_product_normalized dot_product_3d_normalized math_set_epsilon math_get_epsilon angle_difference point_distance_3d point_distance point_direction lengthdir_x lengthdir_y real string int64 ptr string_format chr ansi_char ord string_length string_byte_length string_pos string_copy string_char_at string_ord_at string_byte_at string_set_byte_at string_delete string_insert string_lower string_upper string_repeat string_letters string_digits string_lettersdigits string_replace string_replace_all string_count string_hash_to_newline clipboard_has_text clipboard_set_text clipboard_get_text date_current_datetime date_create_datetime date_valid_datetime date_inc_year date_inc_month date_inc_week date_inc_day date_inc_hour date_inc_minute date_inc_second date_get_year date_get_month date_get_week date_get_day date_get_hour date_get_minute date_get_second date_get_weekday date_get_day_of_year date_get_hour_of_year date_get_minute_of_year date_get_second_of_year date_year_span date_month_span date_week_span date_day_span date_hour_span date_minute_span date_second_span date_compare_datetime date_compare_date date_compare_time date_date_of date_time_of date_datetime_string date_date_string date_time_string date_days_in_month date_days_in_year date_leap_year date_is_today date_set_timezone date_get_timezone game_set_speed game_get_speed motion_set motion_add place_free place_empty place_meeting place_snapped move_random move_snap move_towards_point move_contact_solid move_contact_all move_outside_solid move_outside_all move_bounce_solid move_bounce_all move_wrap distance_to_point distance_to_object position_empty position_meeting path_start path_end mp_linear_step mp_potential_step mp_linear_step_object mp_potential_step_object mp_potential_settings mp_linear_path mp_potential_path mp_linear_path_object mp_potential_path_object mp_grid_create mp_grid_destroy mp_grid_clear_all mp_grid_clear_cell mp_grid_clear_rectangle mp_grid_add_cell mp_grid_get_cell mp_grid_add_rectangle mp_grid_add_instances mp_grid_path mp_grid_draw mp_grid_to_ds_grid collision_point collision_rectangle collision_circle collision_ellipse collision_line collision_point_list collision_rectangle_list collision_circle_list collision_ellipse_list collision_line_list instance_position_list instance_place_list point_in_rectangle point_in_triangle point_in_circle rectangle_in_rectangle rectangle_in_triangle rectangle_in_circle instance_find instance_exists instance_number instance_position instance_nearest instance_furthest instance_place instance_create_depth instance_create_layer instance_copy instance_change instance_destroy position_destroy position_change instance_id_get instance_deactivate_all instance_deactivate_object instance_deactivate_region instance_activate_all instance_activate_object instance_activate_region room_goto room_goto_previous room_goto_next room_previous room_next room_restart game_end game_restart game_load game_save game_save_buffer game_load_buffer event_perform event_user event_perform_object event_inherited show_debug_message show_debug_overlay debug_event debug_get_callstack alarm_get alarm_set font_texture_page_size keyboard_set_map keyboard_get_map keyboard_unset_map keyboard_check keyboard_check_pressed keyboard_check_released keyboard_check_direct keyboard_get_numlock keyboard_set_numlock keyboard_key_press keyboard_key_release keyboard_clear io_clear mouse_check_button mouse_check_button_pressed mouse_check_button_released mouse_wheel_up mouse_wheel_down mouse_clear draw_self draw_sprite draw_sprite_pos draw_sprite_ext draw_sprite_stretched draw_sprite_stretched_ext draw_sprite_tiled draw_sprite_tiled_ext draw_sprite_part draw_sprite_part_ext draw_sprite_general draw_clear draw_clear_alpha draw_point draw_line draw_line_width draw_rectangle draw_roundrect draw_roundrect_ext draw_triangle draw_circle draw_ellipse draw_set_circle_precision draw_arrow draw_button draw_path draw_healthbar draw_getpixel draw_getpixel_ext draw_set_colour draw_set_color draw_set_alpha draw_get_colour draw_get_color draw_get_alpha merge_colour make_colour_rgb make_colour_hsv colour_get_red colour_get_green colour_get_blue colour_get_hue colour_get_saturation colour_get_value merge_color make_color_rgb make_color_hsv color_get_red color_get_green color_get_blue color_get_hue color_get_saturation color_get_value merge_color screen_save screen_save_part draw_set_font draw_set_halign draw_set_valign draw_text draw_text_ext string_width string_height string_width_ext string_height_ext draw_text_transformed draw_text_ext_transformed draw_text_colour draw_text_ext_colour draw_text_transformed_colour draw_text_ext_transformed_colour draw_text_color draw_text_ext_color draw_text_transformed_color draw_text_ext_transformed_color draw_point_colour draw_line_colour draw_line_width_colour draw_rectangle_colour draw_roundrect_colour draw_roundrect_colour_ext draw_triangle_colour draw_circle_colour draw_ellipse_colour draw_point_color draw_line_color draw_line_width_color draw_rectangle_color draw_roundrect_color draw_roundrect_color_ext draw_triangle_color draw_circle_color draw_ellipse_color draw_primitive_begin draw_vertex draw_vertex_colour draw_vertex_color draw_primitive_end sprite_get_uvs font_get_uvs sprite_get_texture font_get_texture texture_get_width texture_get_height texture_get_uvs draw_primitive_begin_texture draw_vertex_texture draw_vertex_texture_colour draw_vertex_texture_color texture_global_scale surface_create surface_create_ext surface_resize surface_free surface_exists surface_get_width surface_get_height surface_get_texture surface_set_target surface_set_target_ext surface_reset_target surface_depth_disable surface_get_depth_disable draw_surface draw_surface_stretched draw_surface_tiled draw_surface_part draw_surface_ext draw_surface_stretched_ext draw_surface_tiled_ext draw_surface_part_ext draw_surface_general surface_getpixel surface_getpixel_ext surface_save surface_save_part surface_copy surface_copy_part application_surface_draw_enable application_get_position application_surface_enable application_surface_is_enabled display_get_width display_get_height display_get_orientation display_get_gui_width display_get_gui_height display_reset display_mouse_get_x display_mouse_get_y display_mouse_set display_set_ui_visibility window_set_fullscreen window_get_fullscreen window_set_caption window_set_min_width window_set_max_width window_set_min_height window_set_max_height window_get_visible_rects window_get_caption window_set_cursor window_get_cursor window_set_colour window_get_colour window_set_color window_get_color window_set_position window_set_size window_set_rectangle window_center window_get_x window_get_y window_get_width window_get_height window_mouse_get_x window_mouse_get_y window_mouse_set window_view_mouse_get_x window_view_mouse_get_y window_views_mouse_get_x window_views_mouse_get_y audio_listener_position audio_listener_velocity audio_listener_orientation audio_emitter_position audio_emitter_create audio_emitter_free audio_emitter_exists audio_emitter_pitch audio_emitter_velocity audio_emitter_falloff audio_emitter_gain audio_play_sound audio_play_sound_on audio_play_sound_at audio_stop_sound audio_resume_music audio_music_is_playing audio_resume_sound audio_pause_sound audio_pause_music audio_channel_num audio_sound_length audio_get_type audio_falloff_set_model audio_play_music audio_stop_music audio_master_gain audio_music_gain audio_sound_gain audio_sound_pitch audio_stop_all audio_resume_all audio_pause_all audio_is_playing audio_is_paused audio_exists audio_sound_set_track_position audio_sound_get_track_position audio_emitter_get_gain audio_emitter_get_pitch audio_emitter_get_x audio_emitter_get_y audio_emitter_get_z audio_emitter_get_vx audio_emitter_get_vy audio_emitter_get_vz audio_listener_set_position audio_listener_set_velocity audio_listener_set_orientation audio_listener_get_data audio_set_master_gain audio_get_master_gain audio_sound_get_gain audio_sound_get_pitch audio_get_name audio_sound_set_track_position audio_sound_get_track_position audio_create_stream audio_destroy_stream audio_create_sync_group audio_destroy_sync_group audio_play_in_sync_group audio_start_sync_group audio_stop_sync_group audio_pause_sync_group audio_resume_sync_group audio_sync_group_get_track_pos audio_sync_group_debug audio_sync_group_is_playing audio_debug audio_group_load audio_group_unload audio_group_is_loaded audio_group_load_progress audio_group_name audio_group_stop_all audio_group_set_gain audio_create_buffer_sound audio_free_buffer_sound audio_create_play_queue audio_free_play_queue audio_queue_sound audio_get_recorder_count audio_get_recorder_info audio_start_recording audio_stop_recording audio_sound_get_listener_mask audio_emitter_get_listener_mask audio_get_listener_mask audio_sound_set_listener_mask audio_emitter_set_listener_mask audio_set_listener_mask audio_get_listener_count audio_get_listener_info audio_system show_message show_message_async clickable_add clickable_add_ext clickable_change clickable_change_ext clickable_delete clickable_exists clickable_set_style show_question show_question_async get_integer get_string get_integer_async get_string_async get_login_async get_open_filename get_save_filename get_open_filename_ext get_save_filename_ext show_error highscore_clear highscore_add highscore_value highscore_name draw_highscore sprite_exists sprite_get_name sprite_get_number sprite_get_width sprite_get_height sprite_get_xoffset sprite_get_yoffset sprite_get_bbox_left sprite_get_bbox_right sprite_get_bbox_top sprite_get_bbox_bottom sprite_save sprite_save_strip sprite_set_cache_size sprite_set_cache_size_ext sprite_get_tpe sprite_prefetch sprite_prefetch_multi sprite_flush sprite_flush_multi sprite_set_speed sprite_get_speed_type sprite_get_speed font_exists font_get_name font_get_fontname font_get_bold font_get_italic font_get_first font_get_last font_get_size font_set_cache_size path_exists path_get_name path_get_length path_get_time path_get_kind path_get_closed path_get_precision path_get_number path_get_point_x path_get_point_y path_get_point_speed path_get_x path_get_y path_get_speed script_exists script_get_name timeline_add timeline_delete timeline_clear timeline_exists timeline_get_name timeline_moment_clear timeline_moment_add_script timeline_size timeline_max_moment object_exists object_get_name object_get_sprite object_get_solid object_get_visible object_get_persistent object_get_mask object_get_parent object_get_physics object_is_ancestor room_exists room_get_name sprite_set_offset sprite_duplicate sprite_assign sprite_merge sprite_add sprite_replace sprite_create_from_surface sprite_add_from_surface sprite_delete sprite_set_alpha_from_sprite sprite_collision_mask font_add_enable_aa font_add_get_enable_aa font_add font_add_sprite font_add_sprite_ext font_replace font_replace_sprite font_replace_sprite_ext font_delete path_set_kind path_set_closed path_set_precision path_add path_assign path_duplicate path_append path_delete path_add_point path_insert_point path_change_point path_delete_point path_clear_points path_reverse path_mirror path_flip path_rotate path_rescale path_shift script_execute object_set_sprite object_set_solid object_set_visible object_set_persistent object_set_mask room_set_width room_set_height room_set_persistent room_set_background_colour room_set_background_color room_set_view room_set_viewport room_get_viewport room_set_view_enabled room_add room_duplicate room_assign room_instance_add room_instance_clear room_get_camera room_set_camera asset_get_index asset_get_type file_text_open_from_string file_text_open_read file_text_open_write file_text_open_append file_text_close file_text_write_string file_text_write_real file_text_writeln file_text_read_string file_text_read_real file_text_readln file_text_eof file_text_eoln file_exists file_delete file_rename file_copy directory_exists directory_create directory_destroy file_find_first file_find_next file_find_close file_attributes filename_name filename_path filename_dir filename_drive filename_ext filename_change_ext file_bin_open file_bin_rewrite file_bin_close file_bin_position file_bin_size file_bin_seek file_bin_write_byte file_bin_read_byte parameter_count parameter_string environment_get_variable ini_open_from_string ini_open ini_close ini_read_string ini_read_real ini_write_string ini_write_real ini_key_exists ini_section_exists ini_key_delete ini_section_delete ds_set_precision ds_exists ds_stack_create ds_stack_destroy ds_stack_clear ds_stack_copy ds_stack_size ds_stack_empty ds_stack_push ds_stack_pop ds_stack_top ds_stack_write ds_stack_read ds_queue_create ds_queue_destroy ds_queue_clear ds_queue_copy ds_queue_size ds_queue_empty ds_queue_enqueue ds_queue_dequeue ds_queue_head ds_queue_tail ds_queue_write ds_queue_read ds_list_create ds_list_destroy ds_list_clear ds_list_copy ds_list_size ds_list_empty ds_list_add ds_list_insert ds_list_replace ds_list_delete ds_list_find_index ds_list_find_value ds_list_mark_as_list ds_list_mark_as_map ds_list_sort ds_list_shuffle ds_list_write ds_list_read ds_list_set ds_map_create ds_map_destroy ds_map_clear ds_map_copy ds_map_size ds_map_empty ds_map_add ds_map_add_list ds_map_add_map ds_map_replace ds_map_replace_map ds_map_replace_list ds_map_delete ds_map_exists ds_map_find_value ds_map_find_previous ds_map_find_next ds_map_find_first ds_map_find_last ds_map_write ds_map_read ds_map_secure_save ds_map_secure_load ds_map_secure_load_buffer ds_map_secure_save_buffer ds_map_set ds_priority_create ds_priority_destroy ds_priority_clear ds_priority_copy ds_priority_size ds_priority_empty ds_priority_add ds_priority_change_priority ds_priority_find_priority ds_priority_delete_value ds_priority_delete_min ds_priority_find_min ds_priority_delete_max ds_priority_find_max ds_priority_write ds_priority_read ds_grid_create ds_grid_destroy ds_grid_copy ds_grid_resize ds_grid_width ds_grid_height ds_grid_clear ds_grid_set ds_grid_add ds_grid_multiply ds_grid_set_region ds_grid_add_region ds_grid_multiply_region ds_grid_set_disk ds_grid_add_disk ds_grid_multiply_disk ds_grid_set_grid_region ds_grid_add_grid_region ds_grid_multiply_grid_region ds_grid_get ds_grid_get_sum ds_grid_get_max ds_grid_get_min ds_grid_get_mean ds_grid_get_disk_sum ds_grid_get_disk_min ds_grid_get_disk_max ds_grid_get_disk_mean ds_grid_value_exists ds_grid_value_x ds_grid_value_y ds_grid_value_disk_exists ds_grid_value_disk_x ds_grid_value_disk_y ds_grid_shuffle ds_grid_write ds_grid_read ds_grid_sort ds_grid_set ds_grid_get effect_create_below effect_create_above effect_clear part_type_create part_type_destroy part_type_exists part_type_clear part_type_shape part_type_sprite part_type_size part_type_scale part_type_orientation part_type_life part_type_step part_type_death part_type_speed part_type_direction part_type_gravity part_type_colour1 part_type_colour2 part_type_colour3 part_type_colour_mix part_type_colour_rgb part_type_colour_hsv part_type_color1 part_type_color2 part_type_color3 part_type_color_mix part_type_color_rgb part_type_color_hsv part_type_alpha1 part_type_alpha2 part_type_alpha3 part_type_blend part_system_create part_system_create_layer part_system_destroy part_system_exists part_system_clear part_system_draw_order part_system_depth part_system_position part_system_automatic_update part_system_automatic_draw part_system_update part_system_drawit part_system_get_layer part_system_layer part_particles_create part_particles_create_colour part_particles_create_color part_particles_clear part_particles_count part_emitter_create part_emitter_destroy part_emitter_destroy_all part_emitter_exists part_emitter_clear part_emitter_region part_emitter_burst part_emitter_stream external_call external_define external_free window_handle window_device matrix_get matrix_set matrix_build_identity matrix_build matrix_build_lookat matrix_build_projection_ortho matrix_build_projection_perspective matrix_build_projection_perspective_fov matrix_multiply matrix_transform_vertex matrix_stack_push matrix_stack_pop matrix_stack_multiply matrix_stack_set matrix_stack_clear matrix_stack_top matrix_stack_is_empty browser_input_capture os_get_config os_get_info os_get_language os_get_region os_lock_orientation display_get_dpi_x display_get_dpi_y display_set_gui_size display_set_gui_maximise display_set_gui_maximize device_mouse_dbclick_enable display_set_timing_method display_get_timing_method display_set_sleep_margin display_get_sleep_margin virtual_key_add virtual_key_hide virtual_key_delete virtual_key_show draw_enable_drawevent draw_enable_swf_aa draw_set_swf_aa_level draw_get_swf_aa_level draw_texture_flush draw_flush gpu_set_blendenable gpu_set_ztestenable gpu_set_zfunc gpu_set_zwriteenable gpu_set_lightingenable gpu_set_fog gpu_set_cullmode gpu_set_blendmode gpu_set_blendmode_ext gpu_set_blendmode_ext_sepalpha gpu_set_colorwriteenable gpu_set_colourwriteenable gpu_set_alphatestenable gpu_set_alphatestref gpu_set_alphatestfunc gpu_set_texfilter gpu_set_texfilter_ext gpu_set_texrepeat gpu_set_texrepeat_ext gpu_set_tex_filter gpu_set_tex_filter_ext gpu_set_tex_repeat gpu_set_tex_repeat_ext gpu_set_tex_mip_filter gpu_set_tex_mip_filter_ext gpu_set_tex_mip_bias gpu_set_tex_mip_bias_ext gpu_set_tex_min_mip gpu_set_tex_min_mip_ext gpu_set_tex_max_mip gpu_set_tex_max_mip_ext gpu_set_tex_max_aniso gpu_set_tex_max_aniso_ext gpu_set_tex_mip_enable gpu_set_tex_mip_enable_ext gpu_get_blendenable gpu_get_ztestenable gpu_get_zfunc gpu_get_zwriteenable gpu_get_lightingenable gpu_get_fog gpu_get_cullmode gpu_get_blendmode gpu_get_blendmode_ext gpu_get_blendmode_ext_sepalpha gpu_get_blendmode_src gpu_get_blendmode_dest gpu_get_blendmode_srcalpha gpu_get_blendmode_destalpha gpu_get_colorwriteenable gpu_get_colourwriteenable gpu_get_alphatestenable gpu_get_alphatestref gpu_get_alphatestfunc gpu_get_texfilter gpu_get_texfilter_ext gpu_get_texrepeat gpu_get_texrepeat_ext gpu_get_tex_filter gpu_get_tex_filter_ext gpu_get_tex_repeat gpu_get_tex_repeat_ext gpu_get_tex_mip_filter gpu_get_tex_mip_filter_ext gpu_get_tex_mip_bias gpu_get_tex_mip_bias_ext gpu_get_tex_min_mip gpu_get_tex_min_mip_ext gpu_get_tex_max_mip gpu_get_tex_max_mip_ext gpu_get_tex_max_aniso gpu_get_tex_max_aniso_ext gpu_get_tex_mip_enable gpu_get_tex_mip_enable_ext gpu_push_state gpu_pop_state gpu_get_state gpu_set_state draw_light_define_ambient draw_light_define_direction draw_light_define_point draw_light_enable draw_set_lighting draw_light_get_ambient draw_light_get draw_get_lighting shop_leave_rating url_get_domain url_open url_open_ext url_open_full get_timer achievement_login achievement_logout achievement_post achievement_increment achievement_post_score achievement_available achievement_show_achievements achievement_show_leaderboards achievement_load_friends achievement_load_leaderboard achievement_send_challenge achievement_load_progress achievement_reset achievement_login_status achievement_get_pic achievement_show_challenge_notifications achievement_get_challenges achievement_event achievement_show achievement_get_info cloud_file_save cloud_string_save cloud_synchronise ads_enable ads_disable ads_setup ads_engagement_launch ads_engagement_available ads_engagement_active ads_event ads_event_preload ads_set_reward_callback ads_get_display_height ads_get_display_width ads_move ads_interstitial_available ads_interstitial_display device_get_tilt_x device_get_tilt_y device_get_tilt_z device_is_keypad_open device_mouse_check_button device_mouse_check_button_pressed device_mouse_check_button_released device_mouse_x device_mouse_y device_mouse_raw_x device_mouse_raw_y device_mouse_x_to_gui device_mouse_y_to_gui iap_activate iap_status iap_enumerate_products iap_restore_all iap_acquire iap_consume iap_product_details iap_purchase_details facebook_init facebook_login facebook_status facebook_graph_request facebook_dialog facebook_logout facebook_launch_offerwall facebook_post_message facebook_send_invite facebook_user_id facebook_accesstoken facebook_check_permission facebook_request_read_permissions facebook_request_publish_permissions gamepad_is_supported gamepad_get_device_count gamepad_is_connected gamepad_get_description gamepad_get_button_threshold gamepad_set_button_threshold gamepad_get_axis_deadzone gamepad_set_axis_deadzone gamepad_button_count gamepad_button_check gamepad_button_check_pressed gamepad_button_check_released gamepad_button_value gamepad_axis_count gamepad_axis_value gamepad_set_vibration gamepad_set_colour gamepad_set_color os_is_paused window_has_focus code_is_compiled http_get http_get_file http_post_string http_request json_encode json_decode zip_unzip load_csv base64_encode base64_decode md5_string_unicode md5_string_utf8 md5_file os_is_network_connected sha1_string_unicode sha1_string_utf8 sha1_file os_powersave_enable analytics_event analytics_event_ext win8_livetile_tile_notification win8_livetile_tile_clear win8_livetile_badge_notification win8_livetile_badge_clear win8_livetile_queue_enable win8_secondarytile_pin win8_secondarytile_badge_notification win8_secondarytile_delete win8_livetile_notification_begin win8_livetile_notification_secondary_begin win8_livetile_notification_expiry win8_livetile_notification_tag win8_livetile_notification_text_add win8_livetile_notification_image_add win8_livetile_notification_end win8_appbar_enable win8_appbar_add_element win8_appbar_remove_element win8_settingscharm_add_entry win8_settingscharm_add_html_entry win8_settingscharm_add_xaml_entry win8_settingscharm_set_xaml_property win8_settingscharm_get_xaml_property win8_settingscharm_remove_entry win8_share_image win8_share_screenshot win8_share_file win8_share_url win8_share_text win8_search_enable win8_search_disable win8_search_add_suggestions win8_device_touchscreen_available win8_license_initialize_sandbox win8_license_trial_version winphone_license_trial_version winphone_tile_title winphone_tile_count winphone_tile_back_title winphone_tile_back_content winphone_tile_back_content_wide winphone_tile_front_image winphone_tile_front_image_small winphone_tile_front_image_wide winphone_tile_back_image winphone_tile_back_image_wide winphone_tile_background_colour winphone_tile_background_color winphone_tile_icon_image winphone_tile_small_icon_image winphone_tile_wide_content winphone_tile_cycle_images winphone_tile_small_background_image physics_world_create physics_world_gravity physics_world_update_speed physics_world_update_iterations physics_world_draw_debug physics_pause_enable physics_fixture_create physics_fixture_set_kinematic physics_fixture_set_density physics_fixture_set_awake physics_fixture_set_restitution physics_fixture_set_friction physics_fixture_set_collision_group physics_fixture_set_sensor physics_fixture_set_linear_damping physics_fixture_set_angular_damping physics_fixture_set_circle_shape physics_fixture_set_box_shape physics_fixture_set_edge_shape physics_fixture_set_polygon_shape physics_fixture_set_chain_shape physics_fixture_add_point physics_fixture_bind physics_fixture_bind_ext physics_fixture_delete physics_apply_force physics_apply_impulse physics_apply_angular_impulse physics_apply_local_force physics_apply_local_impulse physics_apply_torque physics_mass_properties physics_draw_debug physics_test_overlap physics_remove_fixture physics_set_friction physics_set_density physics_set_restitution physics_get_friction physics_get_density physics_get_restitution physics_joint_distance_create physics_joint_rope_create physics_joint_revolute_create physics_joint_prismatic_create physics_joint_pulley_create physics_joint_wheel_create physics_joint_weld_create physics_joint_friction_create physics_joint_gear_create physics_joint_enable_motor physics_joint_get_value physics_joint_set_value physics_joint_delete physics_particle_create physics_particle_delete physics_particle_delete_region_circle physics_particle_delete_region_box physics_particle_delete_region_poly physics_particle_set_flags physics_particle_set_category_flags physics_particle_draw physics_particle_draw_ext physics_particle_count physics_particle_get_data physics_particle_get_data_particle physics_particle_group_begin physics_particle_group_circle physics_particle_group_box physics_particle_group_polygon physics_particle_group_add_point physics_particle_group_end physics_particle_group_join physics_particle_group_delete physics_particle_group_count physics_particle_group_get_data physics_particle_group_get_mass physics_particle_group_get_inertia physics_particle_group_get_centre_x physics_particle_group_get_centre_y physics_particle_group_get_vel_x physics_particle_group_get_vel_y physics_particle_group_get_ang_vel physics_particle_group_get_x physics_particle_group_get_y physics_particle_group_get_angle physics_particle_set_group_flags physics_particle_get_group_flags physics_particle_get_max_count physics_particle_get_radius physics_particle_get_density physics_particle_get_damping physics_particle_get_gravity_scale physics_particle_set_max_count physics_particle_set_radius physics_particle_set_density physics_particle_set_damping physics_particle_set_gravity_scale network_create_socket network_create_socket_ext network_create_server network_create_server_raw network_connect network_connect_raw network_send_packet network_send_raw network_send_broadcast network_send_udp network_send_udp_raw network_set_timeout network_set_config network_resolve network_destroy buffer_create buffer_write buffer_read buffer_seek buffer_get_surface buffer_set_surface buffer_delete buffer_exists buffer_get_type buffer_get_alignment buffer_poke buffer_peek buffer_save buffer_save_ext buffer_load buffer_load_ext buffer_load_partial buffer_copy buffer_fill buffer_get_size buffer_tell buffer_resize buffer_md5 buffer_sha1 buffer_base64_encode buffer_base64_decode buffer_base64_decode_ext buffer_sizeof buffer_get_address buffer_create_from_vertex_buffer buffer_create_from_vertex_buffer_ext buffer_copy_from_vertex_buffer buffer_async_group_begin buffer_async_group_option buffer_async_group_end buffer_load_async buffer_save_async gml_release_mode gml_pragma steam_activate_overlay steam_is_overlay_enabled steam_is_overlay_activated steam_get_persona_name steam_initialised steam_is_cloud_enabled_for_app steam_is_cloud_enabled_for_account steam_file_persisted steam_get_quota_total steam_get_quota_free steam_file_write steam_file_write_file steam_file_read steam_file_delete steam_file_exists steam_file_size steam_file_share steam_is_screenshot_requested steam_send_screenshot steam_is_user_logged_on steam_get_user_steam_id steam_user_owns_dlc steam_user_installed_dlc steam_set_achievement steam_get_achievement steam_clear_achievement steam_set_stat_int steam_set_stat_float steam_set_stat_avg_rate steam_get_stat_int steam_get_stat_float steam_get_stat_avg_rate steam_reset_all_stats steam_reset_all_stats_achievements steam_stats_ready steam_create_leaderboard steam_upload_score steam_upload_score_ext steam_download_scores_around_user steam_download_scores steam_download_friends_scores steam_upload_score_buffer steam_upload_score_buffer_ext steam_current_game_language steam_available_languages steam_activate_overlay_browser steam_activate_overlay_user steam_activate_overlay_store steam_get_user_persona_name steam_get_app_id steam_get_user_account_id steam_ugc_download steam_ugc_create_item steam_ugc_start_item_update steam_ugc_set_item_title steam_ugc_set_item_description steam_ugc_set_item_visibility steam_ugc_set_item_tags steam_ugc_set_item_content steam_ugc_set_item_preview steam_ugc_submit_item_update steam_ugc_get_item_update_progress steam_ugc_subscribe_item steam_ugc_unsubscribe_item steam_ugc_num_subscribed_items steam_ugc_get_subscribed_items steam_ugc_get_item_install_info steam_ugc_get_item_update_info steam_ugc_request_item_details steam_ugc_create_query_user steam_ugc_create_query_user_ex steam_ugc_create_query_all steam_ugc_create_query_all_ex steam_ugc_query_set_cloud_filename_filter steam_ugc_query_set_match_any_tag steam_ugc_query_set_search_text steam_ugc_query_set_ranked_by_trend_days steam_ugc_query_add_required_tag steam_ugc_query_add_excluded_tag steam_ugc_query_set_return_long_description steam_ugc_query_set_return_total_only steam_ugc_query_set_allow_cached_response steam_ugc_send_query shader_set shader_get_name shader_reset shader_current shader_is_compiled shader_get_sampler_index shader_get_uniform shader_set_uniform_i shader_set_uniform_i_array shader_set_uniform_f shader_set_uniform_f_array shader_set_uniform_matrix shader_set_uniform_matrix_array shader_enable_corner_id texture_set_stage texture_get_texel_width texture_get_texel_height shaders_are_supported vertex_format_begin vertex_format_end vertex_format_delete vertex_format_add_position vertex_format_add_position_3d vertex_format_add_colour vertex_format_add_color vertex_format_add_normal vertex_format_add_texcoord vertex_format_add_textcoord vertex_format_add_custom vertex_create_buffer vertex_create_buffer_ext vertex_delete_buffer vertex_begin vertex_end vertex_position vertex_position_3d vertex_colour vertex_color vertex_argb vertex_texcoord vertex_normal vertex_float1 vertex_float2 vertex_float3 vertex_float4 vertex_ubyte4 vertex_submit vertex_freeze vertex_get_number vertex_get_buffer_size vertex_create_buffer_from_buffer vertex_create_buffer_from_buffer_ext push_local_notification push_get_first_local_notification push_get_next_local_notification push_cancel_local_notification skeleton_animation_set skeleton_animation_get skeleton_animation_mix skeleton_animation_set_ext skeleton_animation_get_ext skeleton_animation_get_duration skeleton_animation_get_frames skeleton_animation_clear skeleton_skin_set skeleton_skin_get skeleton_attachment_set skeleton_attachment_get skeleton_attachment_create skeleton_collision_draw_set skeleton_bone_data_get skeleton_bone_data_set skeleton_bone_state_get skeleton_bone_state_set skeleton_get_minmax skeleton_get_num_bounds skeleton_get_bounds skeleton_animation_get_frame skeleton_animation_set_frame draw_skeleton draw_skeleton_time draw_skeleton_instance draw_skeleton_collision skeleton_animation_list skeleton_skin_list skeleton_slot_data layer_get_id layer_get_id_at_depth layer_get_depth layer_create layer_destroy layer_destroy_instances layer_add_instance layer_has_instance layer_set_visible layer_get_visible layer_exists layer_x layer_y layer_get_x layer_get_y layer_hspeed layer_vspeed layer_get_hspeed layer_get_vspeed layer_script_begin layer_script_end layer_shader layer_get_script_begin layer_get_script_end layer_get_shader layer_set_target_room layer_get_target_room layer_reset_target_room layer_get_all layer_get_all_elements layer_get_name layer_depth layer_get_element_layer layer_get_element_type layer_element_move layer_force_draw_depth layer_is_draw_depth_forced layer_get_forced_depth layer_background_get_id layer_background_exists layer_background_create layer_background_destroy layer_background_visible layer_background_change layer_background_sprite layer_background_htiled layer_background_vtiled layer_background_stretch layer_background_yscale layer_background_xscale layer_background_blend layer_background_alpha layer_background_index layer_background_speed layer_background_get_visible layer_background_get_sprite layer_background_get_htiled layer_background_get_vtiled layer_background_get_stretch layer_background_get_yscale layer_background_get_xscale layer_background_get_blend layer_background_get_alpha layer_background_get_index layer_background_get_speed layer_sprite_get_id layer_sprite_exists layer_sprite_create layer_sprite_destroy layer_sprite_change layer_sprite_index layer_sprite_speed layer_sprite_xscale layer_sprite_yscale layer_sprite_angle layer_sprite_blend layer_sprite_alpha layer_sprite_x layer_sprite_y layer_sprite_get_sprite layer_sprite_get_index layer_sprite_get_speed layer_sprite_get_xscale layer_sprite_get_yscale layer_sprite_get_angle layer_sprite_get_blend layer_sprite_get_alpha layer_sprite_get_x layer_sprite_get_y layer_tilemap_get_id layer_tilemap_exists layer_tilemap_create layer_tilemap_destroy tilemap_tileset tilemap_x tilemap_y tilemap_set tilemap_set_at_pixel tilemap_get_tileset tilemap_get_tile_width tilemap_get_tile_height tilemap_get_width tilemap_get_height tilemap_get_x tilemap_get_y tilemap_get tilemap_get_at_pixel tilemap_get_cell_x_at_pixel tilemap_get_cell_y_at_pixel tilemap_clear draw_tilemap draw_tile tilemap_set_global_mask tilemap_get_global_mask tilemap_set_mask tilemap_get_mask tilemap_get_frame tile_set_empty tile_set_index tile_set_flip tile_set_mirror tile_set_rotate tile_get_empty tile_get_index tile_get_flip tile_get_mirror tile_get_rotate layer_tile_exists layer_tile_create layer_tile_destroy layer_tile_change layer_tile_xscale layer_tile_yscale layer_tile_blend layer_tile_alpha layer_tile_x layer_tile_y layer_tile_region layer_tile_visible layer_tile_get_sprite layer_tile_get_xscale layer_tile_get_yscale layer_tile_get_blend layer_tile_get_alpha layer_tile_get_x layer_tile_get_y layer_tile_get_region layer_tile_get_visible layer_instance_get_instance instance_activate_layer instance_deactivate_layer camera_create camera_create_view camera_destroy camera_apply camera_get_active camera_get_default camera_set_default camera_set_view_mat camera_set_proj_mat camera_set_update_script camera_set_begin_script camera_set_end_script camera_set_view_pos camera_set_view_size camera_set_view_speed camera_set_view_border camera_set_view_angle camera_set_view_target camera_get_view_mat camera_get_proj_mat camera_get_update_script camera_get_begin_script camera_get_end_script camera_get_view_x camera_get_view_y camera_get_view_width camera_get_view_height camera_get_view_speed_x camera_get_view_speed_y camera_get_view_border_x camera_get_view_border_y camera_get_view_angle camera_get_view_target view_get_camera view_get_visible view_get_xport view_get_yport view_get_wport view_get_hport view_get_surface_id view_set_camera view_set_visible view_set_xport view_set_yport view_set_wport view_set_hport view_set_surface_id gesture_drag_time gesture_drag_distance gesture_flick_speed gesture_double_tap_time gesture_double_tap_distance gesture_pinch_distance gesture_pinch_angle_towards gesture_pinch_angle_away gesture_rotate_time gesture_rotate_angle gesture_tap_count gesture_get_drag_time gesture_get_drag_distance gesture_get_flick_speed gesture_get_double_tap_time gesture_get_double_tap_distance gesture_get_pinch_distance gesture_get_pinch_angle_towards gesture_get_pinch_angle_away gesture_get_rotate_time gesture_get_rotate_angle gesture_get_tap_count keyboard_virtual_show keyboard_virtual_hide keyboard_virtual_status keyboard_virtual_height", +literal:"self other all noone global local undefined pointer_invalid pointer_null path_action_stop path_action_restart path_action_continue path_action_reverse true false pi GM_build_date GM_version GM_runtime_version timezone_local timezone_utc gamespeed_fps gamespeed_microseconds ev_create ev_destroy ev_step ev_alarm ev_keyboard ev_mouse ev_collision ev_other ev_draw ev_draw_begin ev_draw_end ev_draw_pre ev_draw_post ev_keypress ev_keyrelease ev_trigger ev_left_button ev_right_button ev_middle_button ev_no_button ev_left_press ev_right_press ev_middle_press ev_left_release ev_right_release ev_middle_release ev_mouse_enter ev_mouse_leave ev_mouse_wheel_up ev_mouse_wheel_down ev_global_left_button ev_global_right_button ev_global_middle_button ev_global_left_press ev_global_right_press ev_global_middle_press ev_global_left_release ev_global_right_release ev_global_middle_release ev_joystick1_left ev_joystick1_right ev_joystick1_up ev_joystick1_down ev_joystick1_button1 ev_joystick1_button2 ev_joystick1_button3 ev_joystick1_button4 ev_joystick1_button5 ev_joystick1_button6 ev_joystick1_button7 ev_joystick1_button8 ev_joystick2_left ev_joystick2_right ev_joystick2_up ev_joystick2_down ev_joystick2_button1 ev_joystick2_button2 ev_joystick2_button3 ev_joystick2_button4 ev_joystick2_button5 ev_joystick2_button6 ev_joystick2_button7 ev_joystick2_button8 ev_outside ev_boundary ev_game_start ev_game_end ev_room_start ev_room_end ev_no_more_lives ev_animation_end ev_end_of_path ev_no_more_health ev_close_button ev_user0 ev_user1 ev_user2 ev_user3 ev_user4 ev_user5 ev_user6 ev_user7 ev_user8 ev_user9 ev_user10 ev_user11 ev_user12 ev_user13 ev_user14 ev_user15 ev_step_normal ev_step_begin ev_step_end ev_gui ev_gui_begin ev_gui_end ev_cleanup ev_gesture ev_gesture_tap ev_gesture_double_tap ev_gesture_drag_start ev_gesture_dragging ev_gesture_drag_end ev_gesture_flick ev_gesture_pinch_start ev_gesture_pinch_in ev_gesture_pinch_out ev_gesture_pinch_end ev_gesture_rotate_start ev_gesture_rotating ev_gesture_rotate_end ev_global_gesture_tap ev_global_gesture_double_tap ev_global_gesture_drag_start ev_global_gesture_dragging ev_global_gesture_drag_end ev_global_gesture_flick ev_global_gesture_pinch_start ev_global_gesture_pinch_in ev_global_gesture_pinch_out ev_global_gesture_pinch_end ev_global_gesture_rotate_start ev_global_gesture_rotating ev_global_gesture_rotate_end vk_nokey vk_anykey vk_enter vk_return vk_shift vk_control vk_alt vk_escape vk_space vk_backspace vk_tab vk_pause vk_printscreen vk_left vk_right vk_up vk_down vk_home vk_end vk_delete vk_insert vk_pageup vk_pagedown vk_f1 vk_f2 vk_f3 vk_f4 vk_f5 vk_f6 vk_f7 vk_f8 vk_f9 vk_f10 vk_f11 vk_f12 vk_numpad0 vk_numpad1 vk_numpad2 vk_numpad3 vk_numpad4 vk_numpad5 vk_numpad6 vk_numpad7 vk_numpad8 vk_numpad9 vk_divide vk_multiply vk_subtract vk_add vk_decimal vk_lshift vk_lcontrol vk_lalt vk_rshift vk_rcontrol vk_ralt mb_any mb_none mb_left mb_right mb_middle c_aqua c_black c_blue c_dkgray c_fuchsia c_gray c_green c_lime c_ltgray c_maroon c_navy c_olive c_purple c_red c_silver c_teal c_white c_yellow c_orange fa_left fa_center fa_right fa_top fa_middle fa_bottom pr_pointlist pr_linelist pr_linestrip pr_trianglelist pr_trianglestrip pr_trianglefan bm_complex bm_normal bm_add bm_max bm_subtract bm_zero bm_one bm_src_colour bm_inv_src_colour bm_src_color bm_inv_src_color bm_src_alpha bm_inv_src_alpha bm_dest_alpha bm_inv_dest_alpha bm_dest_colour bm_inv_dest_colour bm_dest_color bm_inv_dest_color bm_src_alpha_sat tf_point tf_linear tf_anisotropic mip_off mip_on mip_markedonly audio_falloff_none audio_falloff_inverse_distance audio_falloff_inverse_distance_clamped audio_falloff_linear_distance audio_falloff_linear_distance_clamped audio_falloff_exponent_distance audio_falloff_exponent_distance_clamped audio_old_system audio_new_system audio_mono audio_stereo audio_3d cr_default cr_none cr_arrow cr_cross cr_beam cr_size_nesw cr_size_ns cr_size_nwse cr_size_we cr_uparrow cr_hourglass cr_drag cr_appstart cr_handpoint cr_size_all spritespeed_framespersecond spritespeed_framespergameframe asset_object asset_unknown asset_sprite asset_sound asset_room asset_path asset_script asset_font asset_timeline asset_tiles asset_shader fa_readonly fa_hidden fa_sysfile fa_volumeid fa_directory fa_archive ds_type_map ds_type_list ds_type_stack ds_type_queue ds_type_grid ds_type_priority ef_explosion ef_ring ef_ellipse ef_firework ef_smoke ef_smokeup ef_star ef_spark ef_flare ef_cloud ef_rain ef_snow pt_shape_pixel pt_shape_disk pt_shape_square pt_shape_line pt_shape_star pt_shape_circle pt_shape_ring pt_shape_sphere pt_shape_flare pt_shape_spark pt_shape_explosion pt_shape_cloud pt_shape_smoke pt_shape_snow ps_distr_linear ps_distr_gaussian ps_distr_invgaussian ps_shape_rectangle ps_shape_ellipse ps_shape_diamond ps_shape_line ty_real ty_string dll_cdecl dll_stdcall matrix_view matrix_projection matrix_world os_win32 os_windows os_macosx os_ios os_android os_symbian os_linux os_unknown os_winphone os_tizen os_win8native os_wiiu os_3ds os_psvita os_bb10 os_ps4 os_xboxone os_ps3 os_xbox360 os_uwp os_tvos os_switch browser_not_a_browser browser_unknown browser_ie browser_firefox browser_chrome browser_safari browser_safari_mobile browser_opera browser_tizen browser_edge browser_windows_store browser_ie_mobile device_ios_unknown device_ios_iphone device_ios_iphone_retina device_ios_ipad device_ios_ipad_retina device_ios_iphone5 device_ios_iphone6 device_ios_iphone6plus device_emulator device_tablet display_landscape display_landscape_flipped display_portrait display_portrait_flipped tm_sleep tm_countvsyncs of_challenge_win of_challen ge_lose of_challenge_tie leaderboard_type_number leaderboard_type_time_mins_secs cmpfunc_never cmpfunc_less cmpfunc_equal cmpfunc_lessequal cmpfunc_greater cmpfunc_notequal cmpfunc_greaterequal cmpfunc_always cull_noculling cull_clockwise cull_counterclockwise lighttype_dir lighttype_point iap_ev_storeload iap_ev_product iap_ev_purchase iap_ev_consume iap_ev_restore iap_storeload_ok iap_storeload_failed iap_status_uninitialised iap_status_unavailable iap_status_loading iap_status_available iap_status_processing iap_status_restoring iap_failed iap_unavailable iap_available iap_purchased iap_canceled iap_refunded fb_login_default fb_login_fallback_to_webview fb_login_no_fallback_to_webview fb_login_forcing_webview fb_login_use_system_account fb_login_forcing_safari phy_joint_anchor_1_x phy_joint_anchor_1_y phy_joint_anchor_2_x phy_joint_anchor_2_y phy_joint_reaction_force_x phy_joint_reaction_force_y phy_joint_reaction_torque phy_joint_motor_speed phy_joint_angle phy_joint_motor_torque phy_joint_max_motor_torque phy_joint_translation phy_joint_speed phy_joint_motor_force phy_joint_max_motor_force phy_joint_length_1 phy_joint_length_2 phy_joint_damping_ratio phy_joint_frequency phy_joint_lower_angle_limit phy_joint_upper_angle_limit phy_joint_angle_limits phy_joint_max_length phy_joint_max_torque phy_joint_max_force phy_debug_render_aabb phy_debug_render_collision_pairs phy_debug_render_coms phy_debug_render_core_shapes phy_debug_render_joints phy_debug_render_obb phy_debug_render_shapes phy_particle_flag_water phy_particle_flag_zombie phy_particle_flag_wall phy_particle_flag_spring phy_particle_flag_elastic phy_particle_flag_viscous phy_particle_flag_powder phy_particle_flag_tensile phy_particle_flag_colourmixing phy_particle_flag_colormixing phy_particle_group_flag_solid phy_particle_group_flag_rigid phy_particle_data_flag_typeflags phy_particle_data_flag_position phy_particle_data_flag_velocity phy_particle_data_flag_colour phy_particle_data_flag_color phy_particle_data_flag_category achievement_our_info achievement_friends_info achievement_leaderboard_info achievement_achievement_info achievement_filter_all_players achievement_filter_friends_only achievement_filter_favorites_only achievement_type_achievement_challenge achievement_type_score_challenge achievement_pic_loaded achievement_show_ui achievement_show_profile achievement_show_leaderboard achievement_show_achievement achievement_show_bank achievement_show_friend_picker achievement_show_purchase_prompt network_socket_tcp network_socket_udp network_socket_bluetooth network_type_connect network_type_disconnect network_type_data network_type_non_blocking_connect network_config_connect_timeout network_config_use_non_blocking_socket network_config_enable_reliable_udp network_config_disable_reliable_udp buffer_fixed buffer_grow buffer_wrap buffer_fast buffer_vbuffer buffer_network buffer_u8 buffer_s8 buffer_u16 buffer_s16 buffer_u32 buffer_s32 buffer_u64 buffer_f16 buffer_f32 buffer_f64 buffer_bool buffer_text buffer_string buffer_surface_copy buffer_seek_start buffer_seek_relative buffer_seek_end buffer_generalerror buffer_outofspace buffer_outofbounds buffer_invalidtype text_type button_type input_type ANSI_CHARSET DEFAULT_CHARSET EASTEUROPE_CHARSET RUSSIAN_CHARSET SYMBOL_CHARSET SHIFTJIS_CHARSET HANGEUL_CHARSET GB2312_CHARSET CHINESEBIG5_CHARSET JOHAB_CHARSET HEBREW_CHARSET ARABIC_CHARSET GREEK_CHARSET TURKISH_CHARSET VIETNAMESE_CHARSET THAI_CHARSET MAC_CHARSET BALTIC_CHARSET OEM_CHARSET gp_face1 gp_face2 gp_face3 gp_face4 gp_shoulderl gp_shoulderr gp_shoulderlb gp_shoulderrb gp_select gp_start gp_stickl gp_stickr gp_padu gp_padd gp_padl gp_padr gp_axislh gp_axislv gp_axisrh gp_axisrv ov_friends ov_community ov_players ov_settings ov_gamegroup ov_achievements lb_sort_none lb_sort_ascending lb_sort_descending lb_disp_none lb_disp_numeric lb_disp_time_sec lb_disp_time_ms ugc_result_success ugc_filetype_community ugc_filetype_microtrans ugc_visibility_public ugc_visibility_friends_only ugc_visibility_private ugc_query_RankedByVote ugc_query_RankedByPublicationDate ugc_query_AcceptedForGameRankedByAcceptanceDate ugc_query_RankedByTrend ugc_query_FavoritedByFriendsRankedByPublicationDate ugc_query_CreatedByFriendsRankedByPublicationDate ugc_query_RankedByNumTimesReported ugc_query_CreatedByFollowedUsersRankedByPublicationDate ugc_query_NotYetRated ugc_query_RankedByTotalVotesAsc ugc_query_RankedByVotesUp ugc_query_RankedByTextSearch ugc_sortorder_CreationOrderDesc ugc_sortorder_CreationOrderAsc ugc_sortorder_TitleAsc ugc_sortorder_LastUpdatedDesc ugc_sortorder_SubscriptionDateDesc ugc_sortorder_VoteScoreDesc ugc_sortorder_ForModeration ugc_list_Published ugc_list_VotedOn ugc_list_VotedUp ugc_list_VotedDown ugc_list_WillVoteLater ugc_list_Favorited ugc_list_Subscribed ugc_list_UsedOrPlayed ugc_list_Followed ugc_match_Items ugc_match_Items_Mtx ugc_match_Items_ReadyToUse ugc_match_Collections ugc_match_Artwork ugc_match_Videos ugc_match_Screenshots ugc_match_AllGuides ugc_match_WebGuides ugc_match_IntegratedGuides ugc_match_UsableInGame ugc_match_ControllerBindings vertex_usage_position vertex_usage_colour vertex_usage_color vertex_usage_normal vertex_usage_texcoord vertex_usage_textcoord vertex_usage_blendweight vertex_usage_blendindices vertex_usage_psize vertex_usage_tangent vertex_usage_binormal vertex_usage_fog vertex_usage_depth vertex_usage_sample vertex_type_float1 vertex_type_float2 vertex_type_float3 vertex_type_float4 vertex_type_colour vertex_type_color vertex_type_ubyte4 layerelementtype_undefined layerelementtype_background layerelementtype_instance layerelementtype_oldtilemap layerelementtype_sprite layerelementtype_tilemap layerelementtype_particlesystem layerelementtype_tile tile_rotate tile_flip tile_mirror tile_index_mask kbv_type_default kbv_type_ascii kbv_type_url kbv_type_email kbv_type_numbers kbv_type_phone kbv_type_phone_name kbv_returnkey_default kbv_returnkey_go kbv_returnkey_google kbv_returnkey_join kbv_returnkey_next kbv_returnkey_route kbv_returnkey_search kbv_returnkey_send kbv_returnkey_yahoo kbv_returnkey_done kbv_returnkey_continue kbv_returnkey_emergency kbv_autocapitalize_none kbv_autocapitalize_words kbv_autocapitalize_sentences kbv_autocapitalize_characters",symbol:"argument_relative argument argument0 argument1 argument2 argument3 argument4 argument5 argument6 argument7 argument8 argument9 argument10 argument11 argument12 argument13 argument14 argument15 argument_count x y xprevious yprevious xstart ystart hspeed vspeed direction speed friction gravity gravity_direction path_index path_position path_positionprevious path_speed path_scale path_orientation path_endaction object_index id solid persistent mask_index instance_count instance_id room_speed fps fps_real current_time current_year current_month current_day current_weekday current_hour current_minute current_second alarm timeline_index timeline_position timeline_speed timeline_running timeline_loop room room_first room_last room_width room_height room_caption room_persistent score lives health show_score show_lives show_health caption_score caption_lives caption_health event_type event_number event_object event_action application_surface gamemaker_pro gamemaker_registered gamemaker_version error_occurred error_last debug_mode keyboard_key keyboard_lastkey keyboard_lastchar keyboard_string mouse_x mouse_y mouse_button mouse_lastbutton cursor_sprite visible sprite_index sprite_width sprite_height sprite_xoffset sprite_yoffset image_number image_index image_speed depth image_xscale image_yscale image_angle image_alpha image_blend bbox_left bbox_right bbox_top bbox_bottom layer background_colour background_showcolour background_color background_showcolor view_enabled view_current view_visible view_xview view_yview view_wview view_hview view_xport view_yport view_wport view_hport view_angle view_hborder view_vborder view_hspeed view_vspeed view_object view_surface_id view_camera game_id game_display_name game_project_name game_save_id working_directory temp_directory program_directory browser_width browser_height os_type os_device os_browser os_version display_aa async_load delta_time webgl_enabled event_data iap_data phy_rotation phy_position_x phy_position_y phy_angular_velocity phy_linear_velocity_x phy_linear_velocity_y phy_speed_x phy_speed_y phy_speed phy_angular_damping phy_linear_damping phy_bullet phy_fixed_rotation phy_active phy_mass phy_inertia phy_com_x phy_com_y phy_dynamic phy_kinematic phy_sleeping phy_collision_points phy_collision_x phy_collision_y phy_col_normal_x phy_col_normal_y phy_position_xprevious phy_position_yprevious"},contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,e.C_NUMBER_MODE]}},oo=function(e){var t={keyword:"break default func interface select case map struct chan else goto package switch const fallthrough if range type continue for import return var go defer bool byte complex64 complex128 float32 float64 int8 int16 int32 int64 string uint8 uint16 uint32 uint64 int uint uintptr rune",literal:"true false iota nil",built_in:"append cap close complex copy imag len make new panic print println real recover delete"};return{name:"Go",aliases:["golang"],keywords:t,illegal:"",end:",\\s+",returnBegin:!0,endsWithParent:!0,contains:[{className:"attr",begin:":\\w+"},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{begin:"\\w+",relevance:0}]}]},{begin:"\\(\\s*",end:"\\s*\\)",excludeEnd:!0,contains:[{begin:"\\w+\\s*=",end:"\\s+",returnBegin:!0,endsWithParent:!0,contains:[{className:"attr",begin:"\\w+",relevance:0},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{begin:"\\w+",relevance:0}]}]}]},{begin:"^\\s*[=~]\\s*"},{begin:"#{",starts:{end:"}",subLanguage:"ruby"}}]}},uo=function(e){var t={"builtin-name":"each in with if else unless bindattr action collection debugger log outlet template unbound view yield lookup"},a={begin:/".*?"|'.*?'|\[.*?\]|\w+/},n=e.inherit(a,{keywords:t,starts:{endsWithParent:!0,relevance:0,contains:[e.inherit(a,{relevance:0})]}}),r=e.inherit(n,{className:"name"}),i=e.inherit(n,{relevance:0});return{name:"Handlebars",aliases:["hbs","html.hbs","html.handlebars"],case_insensitive:!0,subLanguage:"xml",contains:[{begin:/\\\{\{/,skip:!0},{begin:/\\\\(?=\{\{)/,skip:!0},e.COMMENT(/\{\{!--/,/--\}\}/),e.COMMENT(/\{\{!/,/\}\}/),{className:"template-tag",begin:/\{\{\{\{(?!\/)/,end:/\}\}\}\}/,contains:[r],starts:{end:/\{\{\{\{\//,returnEnd:!0,subLanguage:"xml"}},{className:"template-tag",begin:/\{\{\{\{\//,end:/\}\}\}\}/,contains:[r]},{className:"template-tag",begin:/\{\{[#\/]/,end:/\}\}/,contains:[r]},{className:"template-variable",begin:/\{\{\{/,end:/\}\}\}/,keywords:t,contains:[i]},{className:"template-variable",begin:/\{\{/,end:/\}\}/,keywords:t,contains:[i]}]}},mo=function(e){var t={variants:[e.COMMENT("--","$"),e.COMMENT("{-","-}",{contains:["self"]})]},a={className:"meta",begin:"{-#",end:"#-}"},n={className:"meta",begin:"^#",end:"$"},r={className:"type",begin:"\\b[A-Z][\\w']*",relevance:0},i={begin:"\\(",end:"\\)",illegal:'"',contains:[a,n,{className:"type",begin:"\\b[A-Z][\\w]*(\\((\\.\\.|,|\\w+)\\))?"},e.inherit(e.TITLE_MODE,{begin:"[_a-z][\\w']*"}),t]};return{name:"Haskell",aliases:["hs"],keywords:"let in if then else case of where do module import hiding qualified type data newtype deriving class instance as default infix infixl infixr foreign export ccall stdcall cplusplus jvm dotnet safe unsafe family forall mdo proc rec",contains:[{beginKeywords:"module",end:"where",keywords:"module where",contains:[i,t],illegal:"\\W\\.|;"},{begin:"\\bimport\\b",end:"$",keywords:"import qualified as hiding",contains:[i,t],illegal:"\\W\\.|;"},{className:"class",begin:"^(\\s*)?(class|instance)\\b",end:"where",keywords:"class family instance where",contains:[r,i,t]},{className:"class",begin:"\\b(data|(new)?type)\\b",end:"$",keywords:"data family type newtype deriving",contains:[a,r,i,{begin:"{",end:"}",contains:i.contains},t]},{beginKeywords:"default",end:"$",contains:[r,i,t]},{beginKeywords:"infix infixl infixr",end:"$",contains:[e.C_NUMBER_MODE,t]},{begin:"\\bforeign\\b",end:"$",keywords:"foreign import export ccall stdcall cplusplus jvm dotnet safe unsafe",contains:[r,e.QUOTE_STRING_MODE,t]},{className:"meta",begin:"#!\\/usr\\/bin\\/env runhaskell",end:"$"},a,n,e.QUOTE_STRING_MODE,e.C_NUMBER_MODE,r,e.inherit(e.TITLE_MODE,{begin:"^[_a-z][\\w']*"}),t,{begin:"->|<-"}]}},po=function(e){return{name:"Haxe",aliases:["hx"],keywords:{keyword:"break case cast catch continue default do dynamic else enum extern for function here if import in inline never new override package private get set public return static super switch this throw trace try typedef untyped using var while Int Float String Bool Dynamic Void Array ",built_in:"trace this",literal:"true false null _"},contains:[{className:"string",begin:"'",end:"'",contains:[e.BACKSLASH_ESCAPE,{className:"subst",begin:"\\$\\{",end:"\\}"},{className:"subst",begin:"\\$",end:"\\W}"}]},e.QUOTE_STRING_MODE,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.C_NUMBER_MODE,{className:"meta",begin:"@:",end:"$"},{className:"meta",begin:"#",end:"$",keywords:{"meta-keyword":"if else elseif end error"}},{className:"type",begin:":[ \t]*",end:"[^A-Za-z0-9_ \t\\->]",excludeBegin:!0,excludeEnd:!0,relevance:0},{className:"type",begin:":[ \t]*",end:"\\W",excludeBegin:!0,excludeEnd:!0},{className:"type",begin:"new *",end:"\\W",excludeBegin:!0,excludeEnd:!0},{className:"class",beginKeywords:"enum",end:"\\{",contains:[e.TITLE_MODE]},{className:"class",beginKeywords:"abstract",end:"[\\{$]",contains:[{className:"type",begin:"\\(",end:"\\)",excludeBegin:!0,excludeEnd:!0},{className:"type",begin:"from +",end:"\\W",excludeBegin:!0,excludeEnd:!0},{className:"type",begin:"to +",end:"\\W",excludeBegin:!0,excludeEnd:!0},e.TITLE_MODE],keywords:{keyword:"abstract from to"}},{className:"class",begin:"\\b(class|interface) +",end:"[\\{$]",excludeEnd:!0,keywords:"class interface",contains:[{className:"keyword",begin:"\\b(extends|implements) +",keywords:"extends implements",contains:[{className:"type",begin:e.IDENT_RE,relevance:0}]},e.TITLE_MODE]},{className:"function",beginKeywords:"function",end:"\\(",excludeEnd:!0,illegal:"\\S",contains:[e.TITLE_MODE]}],illegal:/<\//}},Eo=function(e){return{name:"HSP",case_insensitive:!0,lexemes:/[\w\._]+/,keywords:"goto gosub return break repeat loop continue wait await dim sdim foreach dimtype dup dupptr end stop newmod delmod mref run exgoto on mcall assert logmes newlab resume yield onexit onerror onkey onclick oncmd exist delete mkdir chdir dirlist bload bsave bcopy memfile if else poke wpoke lpoke getstr chdpm memexpand memcpy memset notesel noteadd notedel noteload notesave randomize noteunsel noteget split strrep setease button chgdisp exec dialog mmload mmplay mmstop mci pset pget syscolor mes print title pos circle cls font sysfont objsize picload color palcolor palette redraw width gsel gcopy gzoom gmode bmpsave hsvcolor getkey listbox chkbox combox input mesbox buffer screen bgscr mouse objsel groll line clrobj boxf objprm objmode stick grect grotate gsquare gradf objimage objskip objenable celload celdiv celput newcom querycom delcom cnvstow comres axobj winobj sendmsg comevent comevarg sarrayconv callfunc cnvwtos comevdisp libptr system hspstat hspver stat cnt err strsize looplev sublev iparam wparam lparam refstr refdval int rnd strlen length length2 length3 length4 vartype gettime peek wpeek lpeek varptr varuse noteinfo instr abs limit getease str strmid strf getpath strtrim sin cos tan atan sqrt double absf expf logf limitf powf geteasef mousex mousey mousew hwnd hinstance hdc ginfo objinfo dirinfo sysinfo thismod __hspver__ __hsp30__ __date__ __time__ __line__ __file__ _debug __hspdef__ and or xor not screen_normal screen_palette screen_hide screen_fixedsize screen_tool screen_frame gmode_gdi gmode_mem gmode_rgb0 gmode_alpha gmode_rgb0alpha gmode_add gmode_sub gmode_pixela ginfo_mx ginfo_my ginfo_act ginfo_sel ginfo_wx1 ginfo_wy1 ginfo_wx2 ginfo_wy2 ginfo_vx ginfo_vy ginfo_sizex ginfo_sizey ginfo_winx ginfo_winy ginfo_mesx ginfo_mesy ginfo_r ginfo_g ginfo_b ginfo_paluse ginfo_dispx ginfo_dispy ginfo_cx ginfo_cy ginfo_intid ginfo_newid ginfo_sx ginfo_sy objinfo_mode objinfo_bmscr objinfo_hwnd notemax notesize dir_cur dir_exe dir_win dir_sys dir_cmdline dir_desktop dir_mydoc dir_tv font_normal font_bold font_italic font_underline font_strikeout font_antialias objmode_normal objmode_guifont objmode_usefont gsquare_grad msgothic msmincho do until while wend for next _break _continue switch case default swbreak swend ddim ldim alloc m_pi rad2deg deg2rad ease_linear ease_quad_in ease_quad_out ease_quad_inout ease_cubic_in ease_cubic_out ease_cubic_inout ease_quartic_in ease_quartic_out ease_quartic_inout ease_bounce_in ease_bounce_out ease_bounce_inout ease_shake_in ease_shake_out ease_shake_inout ease_loop",contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,{className:"string",begin:'{"',end:'"}',contains:[e.BACKSLASH_ESCAPE]},e.COMMENT(";","$",{relevance:0}),{className:"meta",begin:"#",end:"$",keywords:{"meta-keyword":"addion cfunc cmd cmpopt comfunc const defcfunc deffunc define else endif enum epack func global if ifdef ifndef include modcfunc modfunc modinit modterm module pack packopt regcmd runtime undef usecom uselib"},contains:[e.inherit(e.QUOTE_STRING_MODE,{className:"meta-string"}),e.NUMBER_MODE,e.C_NUMBER_MODE,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{className:"symbol",begin:"^\\*(\\w+|@)"},e.NUMBER_MODE,e.C_NUMBER_MODE]}},go=function(e){e.requireLanguage("handlebars");var t="action collection component concat debugger each each-in else get hash if input link-to loc log mut outlet partial query-params render textarea unbound unless with yield view",a=(e.QUOTE_STRING_MODE,{endsWithParent:!0,relevance:0,keywords:{keyword:"as",built_in:t},contains:[e.QUOTE_STRING_MODE,{illegal:/\}\}/,begin:/[a-zA-Z0-9_]+=/,returnBegin:!0,relevance:0,contains:[{className:"attr",begin:/[a-zA-Z0-9_]+/}]},e.NUMBER_MODE]});return{name:"HTMLBars",case_insensitive:!0,subLanguage:"xml",contains:[e.COMMENT("{{!(--)?","(--)?}}"),{className:"template-tag",begin:/\{\{[#\/]/,end:/\}\}/,contains:[{className:"name",begin:/[a-zA-Z\.\-]+/,keywords:{"builtin-name":t},starts:a}]},{className:"template-variable",begin:/\{\{[a-zA-Z][a-zA-Z\-]+/,end:/\}\}/,keywords:{keyword:"as",built_in:t},contains:[e.QUOTE_STRING_MODE]}]}},So=function(){return{name:"HTTP",aliases:["https"],illegal:"\\S",contains:[{begin:"^HTTP/[0-9\\.]+",end:"$",contains:[{className:"number",begin:"\\b\\d{3}\\b"}]},{begin:"^[A-Z]+ (.*?) HTTP/[0-9\\.]+$",returnBegin:!0,end:"$",contains:[{className:"string",begin:" ",end:" ",excludeBegin:!0,excludeEnd:!0},{begin:"HTTP/[0-9\\.]+"},{className:"keyword",begin:"[A-Z]+"}]},{className:"attribute",begin:"^\\w",end:": ",excludeEnd:!0,illegal:"\\n|\\s|=",starts:{end:"$",relevance:0}},{begin:"\\n\\n",starts:{subLanguage:[],endsWithParent:!0}}]}},To=function(e){var t="[a-zA-Z_\\-!.?+*=<>&#'][a-zA-Z_\\-!.?+*=<>&#'0-9/;:]*",a={begin:t,relevance:0},n={className:"number",begin:"[-+]?\\d+(\\.\\d+)?",relevance:0},r=e.inherit(e.QUOTE_STRING_MODE,{illegal:null}),i=e.COMMENT(";","$",{relevance:0}),o={className:"literal",begin:/\b([Tt]rue|[Ff]alse|nil|None)\b/},s={begin:"[\\[\\{]",end:"[\\]\\}]"},l={className:"comment",begin:"\\^"+t},_=e.COMMENT("\\^\\{","\\}"),c={className:"symbol",begin:"[:]{1,2}"+t},d={begin:"\\(",end:"\\)"},u={endsWithParent:!0,relevance:0},m={keywords:{"builtin-name":"!= % %= & &= * ** **= *= *map + += , --build-class-- --import-- -= . / // //= /= < << <<= <= = > >= >> >>= @ @= ^ ^= abs accumulate all and any ap-compose ap-dotimes ap-each ap-each-while ap-filter ap-first ap-if ap-last ap-map ap-map-when ap-pipe ap-reduce ap-reject apply as-> ascii assert assoc bin break butlast callable calling-module-name car case cdr chain chr coll? combinations compile compress cond cons cons? continue count curry cut cycle dec def default-method defclass defmacro defmacro-alias defmacro/g! defmain defmethod defmulti defn defn-alias defnc defnr defreader defseq del delattr delete-route dict-comp dir disassemble dispatch-reader-macro distinct divmod do doto drop drop-last drop-while empty? end-sequence eval eval-and-compile eval-when-compile even? every? except exec filter first flatten float? fn fnc fnr for for* format fraction genexpr gensym get getattr global globals group-by hasattr hash hex id identity if if* if-not if-python2 import in inc input instance? integer integer-char? integer? interleave interpose is is-coll is-cons is-empty is-even is-every is-float is-instance is-integer is-integer-char is-iterable is-iterator is-keyword is-neg is-none is-not is-numeric is-odd is-pos is-string is-symbol is-zero isinstance islice issubclass iter iterable? iterate iterator? keyword keyword? lambda last len let lif lif-not list* list-comp locals loop macro-error macroexpand macroexpand-1 macroexpand-all map max merge-with method-decorator min multi-decorator multicombinations name neg? next none? nonlocal not not-in not? nth numeric? oct odd? open or ord partition permutations pos? post-route postwalk pow prewalk print product profile/calls profile/cpu put-route quasiquote quote raise range read read-str recursive-replace reduce remove repeat repeatedly repr require rest round route route-with-methods rwm second seq set-comp setattr setv some sorted string string? sum switch symbol? take take-nth take-while tee try unless unquote unquote-splicing vars walk when while with with* with-decorator with-gensyms xi xor yield yield-from zero? zip zip-longest | |= ~"},lexemes:t,className:"name",begin:t,starts:u},p=[d,r,l,_,i,c,s,n,o,a];return d.contains=[e.COMMENT("comment",""),m,u],u.contains=p,s.contains=p,{name:"Hy",aliases:["hylang"],illegal:/\S/,contains:[{className:"meta",begin:"^#!",end:"$"},d,r,l,_,i,c,s,n,o]}},bo=function(){return{name:"Inform 7",aliases:["i7"],case_insensitive:!0,keywords:{keyword:"thing room person man woman animal container supporter backdrop door scenery open closed locked inside gender is are say understand kind of rule"},contains:[{className:"string",begin:'"',end:'"',relevance:0,contains:[{className:"subst",begin:"\\[",end:"\\]"}]},{className:"section",begin:/^(Volume|Book|Part|Chapter|Section|Table)\b/,end:"$"},{begin:/^(Check|Carry out|Report|Instead of|To|Rule|When|Before|After)\b/,end:":",contains:[{begin:"\\(This",end:"\\)"}]},{className:"comment",begin:"\\[",end:"\\]",contains:["self"]}]}},fo=function(e){var t={className:"number",relevance:0,variants:[{begin:/([\+\-]+)?[\d]+_[\d_]+/},{begin:e.NUMBER_RE}]},a=e.COMMENT();a.variants=[{begin:/;/,end:/$/},{begin:/#/,end:/$/}];var n={className:"variable",variants:[{begin:/\$[\w\d"][\w\d_]*/},{begin:/\$\{(.*?)}/}]},r={className:"literal",begin:/\bon|off|true|false|yes|no\b/},i={className:"string", +contains:[e.BACKSLASH_ESCAPE],variants:[{begin:"'''",end:"'''",relevance:10},{begin:'"""',end:'"""',relevance:10},{begin:'"',end:'"'},{begin:"'",end:"'"}]};return{name:"TOML, also INI",aliases:["toml"],case_insensitive:!0,illegal:/\S/,contains:[a,{className:"section",begin:/\[+/,end:/\]+/},{begin:/^[a-z0-9\[\]_\.-]+(?=\s*=\s*)/,className:"attr",starts:{end:/$/,contains:[a,{begin:/\[/,end:/\]/,contains:[a,r,n,i,t,"self"],relevance:0},r,n,i,t]}}]}},Co=function(e){return{name:"IRPF90",case_insensitive:!0,keywords:{literal:".False. .True.",keyword:"kind do while private call intrinsic where elsewhere type endtype endmodule endselect endinterface end enddo endif if forall endforall only contains default return stop then public subroutine|10 function program .and. .or. .not. .le. .eq. .ge. .gt. .lt. goto save else use module select case access blank direct exist file fmt form formatted iostat name named nextrec number opened rec recl sequential status unformatted unit continue format pause cycle exit c_null_char c_alert c_backspace c_form_feed flush wait decimal round iomsg synchronous nopass non_overridable pass protected volatile abstract extends import non_intrinsic value deferred generic final enumerator class associate bind enum c_int c_short c_long c_long_long c_signed_char c_size_t c_int8_t c_int16_t c_int32_t c_int64_t c_int_least8_t c_int_least16_t c_int_least32_t c_int_least64_t c_int_fast8_t c_int_fast16_t c_int_fast32_t c_int_fast64_t c_intmax_t C_intptr_t c_float c_double c_long_double c_float_complex c_double_complex c_long_double_complex c_bool c_char c_null_ptr c_null_funptr c_new_line c_carriage_return c_horizontal_tab c_vertical_tab iso_c_binding c_loc c_funloc c_associated c_f_pointer c_ptr c_funptr iso_fortran_env character_storage_size error_unit file_storage_size input_unit iostat_end iostat_eor numeric_storage_size output_unit c_f_procpointer ieee_arithmetic ieee_support_underflow_control ieee_get_underflow_mode ieee_set_underflow_mode newunit contiguous recursive pad position action delim readwrite eor advance nml interface procedure namelist include sequence elemental pure integer real character complex logical dimension allocatable|10 parameter external implicit|10 none double precision assign intent optional pointer target in out common equivalence data begin_provider &begin_provider end_provider begin_shell end_shell begin_template end_template subst assert touch soft_touch provide no_dep free irp_if irp_else irp_endif irp_write irp_read",built_in:"alog alog10 amax0 amax1 amin0 amin1 amod cabs ccos cexp clog csin csqrt dabs dacos dasin datan datan2 dcos dcosh ddim dexp dint dlog dlog10 dmax1 dmin1 dmod dnint dsign dsin dsinh dsqrt dtan dtanh float iabs idim idint idnint ifix isign max0 max1 min0 min1 sngl algama cdabs cdcos cdexp cdlog cdsin cdsqrt cqabs cqcos cqexp cqlog cqsin cqsqrt dcmplx dconjg derf derfc dfloat dgamma dimag dlgama iqint qabs qacos qasin qatan qatan2 qcmplx qconjg qcos qcosh qdim qerf qerfc qexp qgamma qimag qlgama qlog qlog10 qmax1 qmin1 qmod qnint qsign qsin qsinh qsqrt qtan qtanh abs acos aimag aint anint asin atan atan2 char cmplx conjg cos cosh exp ichar index int log log10 max min nint sign sin sinh sqrt tan tanh print write dim lge lgt lle llt mod nullify allocate deallocate adjustl adjustr all allocated any associated bit_size btest ceiling count cshift date_and_time digits dot_product eoshift epsilon exponent floor fraction huge iand ibclr ibits ibset ieor ior ishft ishftc lbound len_trim matmul maxexponent maxloc maxval merge minexponent minloc minval modulo mvbits nearest pack present product radix random_number random_seed range repeat reshape rrspacing scale scan selected_int_kind selected_real_kind set_exponent shape size spacing spread sum system_clock tiny transpose trim ubound unpack verify achar iachar transfer dble entry dprod cpu_time command_argument_count get_command get_command_argument get_environment_variable is_iostat_end ieee_arithmetic ieee_support_underflow_control ieee_get_underflow_mode ieee_set_underflow_mode is_iostat_eor move_alloc new_line selected_char_kind same_type_as extends_type_of acosh asinh atanh bessel_j0 bessel_j1 bessel_jn bessel_y0 bessel_y1 bessel_yn erf erfc erfc_scaled gamma log_gamma hypot norm2 atomic_define atomic_ref execute_command_line leadz trailz storage_size merge_bits bge bgt ble blt dshiftl dshiftr findloc iall iany iparity image_index lcobound ucobound maskl maskr num_images parity popcnt poppar shifta shiftl shiftr this_image IRP_ALIGN irp_here"},illegal:/\/\*/,contains:[e.inherit(e.APOS_STRING_MODE,{className:"string",relevance:0}),e.inherit(e.QUOTE_STRING_MODE,{className:"string",relevance:0}),{className:"function",beginKeywords:"subroutine function program",illegal:"[${=\\n]",contains:[e.UNDERSCORE_TITLE_MODE,{className:"params",begin:"\\(",end:"\\)"}]},e.COMMENT("!","$",{relevance:0}),e.COMMENT("begin_doc","end_doc",{relevance:10}),{className:"number",begin:"(?=\\b|\\+|\\-|\\.)(?:\\.|\\d+\\.?)\\d*([de][+-]?\\d+)?(_[a-z_\\d]+)?",relevance:0}]}},Ro=function(e){var t="[A-Za-z\u0410-\u042f\u0430-\u044f\u0451\u0401_!][A-Za-z\u0410-\u042f\u0430-\u044f\u0451\u0401_0-9]*",a={className:"number",begin:e.NUMBER_RE,relevance:0},n={className:"string",variants:[{begin:'"',end:'"'},{begin:"'",end:"'"}]},r={className:"doctag",begin:"\\b(?:TODO|DONE|BEGIN|END|STUB|CHG|FIXME|NOTE|BUG|XXX)\\b",relevance:0},i={variants:[{className:"comment",begin:"//",end:"$",relevance:0,contains:[e.PHRASAL_WORDS_MODE,r]},{className:"comment",begin:"/\\*",end:"\\*/",relevance:0,contains:[e.PHRASAL_WORDS_MODE,r]}]},o={keyword:"and \u0438 else \u0438\u043d\u0430\u0447\u0435 endexcept endfinally endforeach \u043a\u043e\u043d\u0435\u0446\u0432\u0441\u0435 endif \u043a\u043e\u043d\u0435\u0446\u0435\u0441\u043b\u0438 endwhile \u043a\u043e\u043d\u0435\u0446\u043f\u043e\u043a\u0430 except exitfor finally foreach \u0432\u0441\u0435 if \u0435\u0441\u043b\u0438 in \u0432 not \u043d\u0435 or \u0438\u043b\u0438 try while \u043f\u043e\u043a\u0430 ", +built_in:"SYSRES_CONST_ACCES_RIGHT_TYPE_EDIT SYSRES_CONST_ACCES_RIGHT_TYPE_FULL SYSRES_CONST_ACCES_RIGHT_TYPE_VIEW SYSRES_CONST_ACCESS_MODE_REQUISITE_CODE SYSRES_CONST_ACCESS_NO_ACCESS_VIEW SYSRES_CONST_ACCESS_NO_ACCESS_VIEW_CODE SYSRES_CONST_ACCESS_RIGHTS_ADD_REQUISITE_CODE SYSRES_CONST_ACCESS_RIGHTS_ADD_REQUISITE_YES_CODE SYSRES_CONST_ACCESS_RIGHTS_CHANGE_REQUISITE_CODE SYSRES_CONST_ACCESS_RIGHTS_CHANGE_REQUISITE_YES_CODE SYSRES_CONST_ACCESS_RIGHTS_DELETE_REQUISITE_CODE SYSRES_CONST_ACCESS_RIGHTS_DELETE_REQUISITE_YES_CODE SYSRES_CONST_ACCESS_RIGHTS_EXECUTE_REQUISITE_CODE SYSRES_CONST_ACCESS_RIGHTS_EXECUTE_REQUISITE_YES_CODE SYSRES_CONST_ACCESS_RIGHTS_NO_ACCESS_REQUISITE_CODE SYSRES_CONST_ACCESS_RIGHTS_NO_ACCESS_REQUISITE_YES_CODE SYSRES_CONST_ACCESS_RIGHTS_RATIFY_REQUISITE_CODE SYSRES_CONST_ACCESS_RIGHTS_RATIFY_REQUISITE_YES_CODE SYSRES_CONST_ACCESS_RIGHTS_REQUISITE_CODE SYSRES_CONST_ACCESS_RIGHTS_VIEW SYSRES_CONST_ACCESS_RIGHTS_VIEW_CODE SYSRES_CONST_ACCESS_RIGHTS_VIEW_REQUISITE_CODE SYSRES_CONST_ACCESS_RIGHTS_VIEW_REQUISITE_YES_CODE SYSRES_CONST_ACCESS_TYPE_CHANGE SYSRES_CONST_ACCESS_TYPE_CHANGE_CODE SYSRES_CONST_ACCESS_TYPE_EXISTS SYSRES_CONST_ACCESS_TYPE_EXISTS_CODE SYSRES_CONST_ACCESS_TYPE_FULL SYSRES_CONST_ACCESS_TYPE_FULL_CODE SYSRES_CONST_ACCESS_TYPE_VIEW SYSRES_CONST_ACCESS_TYPE_VIEW_CODE SYSRES_CONST_ACTION_TYPE_ABORT SYSRES_CONST_ACTION_TYPE_ACCEPT SYSRES_CONST_ACTION_TYPE_ACCESS_RIGHTS SYSRES_CONST_ACTION_TYPE_ADD_ATTACHMENT SYSRES_CONST_ACTION_TYPE_CHANGE_CARD SYSRES_CONST_ACTION_TYPE_CHANGE_KIND SYSRES_CONST_ACTION_TYPE_CHANGE_STORAGE SYSRES_CONST_ACTION_TYPE_CONTINUE SYSRES_CONST_ACTION_TYPE_COPY SYSRES_CONST_ACTION_TYPE_CREATE SYSRES_CONST_ACTION_TYPE_CREATE_VERSION SYSRES_CONST_ACTION_TYPE_DELETE SYSRES_CONST_ACTION_TYPE_DELETE_ATTACHMENT SYSRES_CONST_ACTION_TYPE_DELETE_VERSION SYSRES_CONST_ACTION_TYPE_DISABLE_DELEGATE_ACCESS_RIGHTS SYSRES_CONST_ACTION_TYPE_ENABLE_DELEGATE_ACCESS_RIGHTS SYSRES_CONST_ACTION_TYPE_ENCRYPTION_BY_CERTIFICATE SYSRES_CONST_ACTION_TYPE_ENCRYPTION_BY_CERTIFICATE_AND_PASSWORD SYSRES_CONST_ACTION_TYPE_ENCRYPTION_BY_PASSWORD SYSRES_CONST_ACTION_TYPE_EXPORT_WITH_LOCK SYSRES_CONST_ACTION_TYPE_EXPORT_WITHOUT_LOCK SYSRES_CONST_ACTION_TYPE_IMPORT_WITH_UNLOCK SYSRES_CONST_ACTION_TYPE_IMPORT_WITHOUT_UNLOCK SYSRES_CONST_ACTION_TYPE_LIFE_CYCLE_STAGE SYSRES_CONST_ACTION_TYPE_LOCK SYSRES_CONST_ACTION_TYPE_LOCK_FOR_SERVER SYSRES_CONST_ACTION_TYPE_LOCK_MODIFY SYSRES_CONST_ACTION_TYPE_MARK_AS_READED SYSRES_CONST_ACTION_TYPE_MARK_AS_UNREADED SYSRES_CONST_ACTION_TYPE_MODIFY SYSRES_CONST_ACTION_TYPE_MODIFY_CARD SYSRES_CONST_ACTION_TYPE_MOVE_TO_ARCHIVE SYSRES_CONST_ACTION_TYPE_OFF_ENCRYPTION SYSRES_CONST_ACTION_TYPE_PASSWORD_CHANGE SYSRES_CONST_ACTION_TYPE_PERFORM SYSRES_CONST_ACTION_TYPE_RECOVER_FROM_LOCAL_COPY SYSRES_CONST_ACTION_TYPE_RESTART SYSRES_CONST_ACTION_TYPE_RESTORE_FROM_ARCHIVE SYSRES_CONST_ACTION_TYPE_REVISION SYSRES_CONST_ACTION_TYPE_SEND_BY_MAIL SYSRES_CONST_ACTION_TYPE_SIGN SYSRES_CONST_ACTION_TYPE_START SYSRES_CONST_ACTION_TYPE_UNLOCK SYSRES_CONST_ACTION_TYPE_UNLOCK_FROM_SERVER SYSRES_CONST_ACTION_TYPE_VERSION_STATE SYSRES_CONST_ACTION_TYPE_VERSION_VISIBILITY SYSRES_CONST_ACTION_TYPE_VIEW SYSRES_CONST_ACTION_TYPE_VIEW_SHADOW_COPY SYSRES_CONST_ACTION_TYPE_WORKFLOW_DESCRIPTION_MODIFY SYSRES_CONST_ACTION_TYPE_WRITE_HISTORY SYSRES_CONST_ACTIVE_VERSION_STATE_PICK_VALUE SYSRES_CONST_ADD_REFERENCE_MODE_NAME SYSRES_CONST_ADDITION_REQUISITE_CODE SYSRES_CONST_ADDITIONAL_PARAMS_REQUISITE_CODE SYSRES_CONST_ADITIONAL_JOB_END_DATE_REQUISITE_NAME SYSRES_CONST_ADITIONAL_JOB_READ_REQUISITE_NAME SYSRES_CONST_ADITIONAL_JOB_START_DATE_REQUISITE_NAME SYSRES_CONST_ADITIONAL_JOB_STATE_REQUISITE_NAME SYSRES_CONST_ADMINISTRATION_HISTORY_ADDING_USER_TO_GROUP_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_ADDING_USER_TO_GROUP_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_CREATION_COMP_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_CREATION_COMP_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_CREATION_GROUP_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_CREATION_GROUP_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_CREATION_USER_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_CREATION_USER_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_DATABASE_USER_CREATION SYSRES_CONST_ADMINISTRATION_HISTORY_DATABASE_USER_CREATION_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_DATABASE_USER_DELETION SYSRES_CONST_ADMINISTRATION_HISTORY_DATABASE_USER_DELETION_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_DELETION_COMP_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_DELETION_COMP_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_DELETION_GROUP_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_DELETION_GROUP_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_DELETION_USER_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_DELETION_USER_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_DELETION_USER_FROM_GROUP_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_DELETION_USER_FROM_GROUP_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_GRANTING_FILTERER_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_GRANTING_FILTERER_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_GRANTING_FILTERER_RESTRICTION_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_GRANTING_FILTERER_RESTRICTION_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_GRANTING_PRIVILEGE_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_GRANTING_PRIVILEGE_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_GRANTING_RIGHTS_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_GRANTING_RIGHTS_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_IS_MAIN_SERVER_CHANGED_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_IS_MAIN_SERVER_CHANGED_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_IS_PUBLIC_CHANGED_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_IS_PUBLIC_CHANGED_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_REMOVING_FILTERER_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_REMOVING_FILTERER_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_REMOVING_FILTERER_RESTRICTION_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_REMOVING_FILTERER_RESTRICTION_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_REMOVING_PRIVILEGE_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_REMOVING_PRIVILEGE_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_REMOVING_RIGHTS_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_REMOVING_RIGHTS_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_SERVER_LOGIN_CREATION SYSRES_CONST_ADMINISTRATION_HISTORY_SERVER_LOGIN_CREATION_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_SERVER_LOGIN_DELETION SYSRES_CONST_ADMINISTRATION_HISTORY_SERVER_LOGIN_DELETION_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_CATEGORY_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_CATEGORY_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_COMP_TITLE_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_COMP_TITLE_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_FULL_NAME_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_FULL_NAME_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_GROUP_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_GROUP_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_PARENT_GROUP_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_PARENT_GROUP_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_USER_AUTH_TYPE_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_USER_AUTH_TYPE_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_USER_LOGIN_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_USER_LOGIN_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_USER_STATUS_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_USER_STATUS_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_USER_PASSWORD_CHANGE SYSRES_CONST_ADMINISTRATION_HISTORY_USER_PASSWORD_CHANGE_ACTION SYSRES_CONST_ALL_ACCEPT_CONDITION_RUS SYSRES_CONST_ALL_USERS_GROUP SYSRES_CONST_ALL_USERS_GROUP_NAME SYSRES_CONST_ALL_USERS_SERVER_GROUP_NAME SYSRES_CONST_ALLOWED_ACCESS_TYPE_CODE SYSRES_CONST_ALLOWED_ACCESS_TYPE_NAME SYSRES_CONST_APP_VIEWER_TYPE_REQUISITE_CODE SYSRES_CONST_APPROVING_SIGNATURE_NAME SYSRES_CONST_APPROVING_SIGNATURE_REQUISITE_CODE SYSRES_CONST_ASSISTANT_SUBSTITUE_TYPE SYSRES_CONST_ASSISTANT_SUBSTITUE_TYPE_CODE SYSRES_CONST_ATTACH_TYPE_COMPONENT_TOKEN SYSRES_CONST_ATTACH_TYPE_DOC SYSRES_CONST_ATTACH_TYPE_EDOC SYSRES_CONST_ATTACH_TYPE_FOLDER SYSRES_CONST_ATTACH_TYPE_JOB SYSRES_CONST_ATTACH_TYPE_REFERENCE SYSRES_CONST_ATTACH_TYPE_TASK SYSRES_CONST_AUTH_ENCODED_PASSWORD SYSRES_CONST_AUTH_ENCODED_PASSWORD_CODE SYSRES_CONST_AUTH_NOVELL SYSRES_CONST_AUTH_PASSWORD SYSRES_CONST_AUTH_PASSWORD_CODE SYSRES_CONST_AUTH_WINDOWS SYSRES_CONST_AUTHENTICATING_SIGNATURE_NAME SYSRES_CONST_AUTHENTICATING_SIGNATURE_REQUISITE_CODE SYSRES_CONST_AUTO_ENUM_METHOD_FLAG SYSRES_CONST_AUTO_NUMERATION_CODE SYSRES_CONST_AUTO_STRONG_ENUM_METHOD_FLAG SYSRES_CONST_AUTOTEXT_NAME_REQUISITE_CODE SYSRES_CONST_AUTOTEXT_TEXT_REQUISITE_CODE SYSRES_CONST_AUTOTEXT_USAGE_ALL SYSRES_CONST_AUTOTEXT_USAGE_ALL_CODE SYSRES_CONST_AUTOTEXT_USAGE_SIGN SYSRES_CONST_AUTOTEXT_USAGE_SIGN_CODE SYSRES_CONST_AUTOTEXT_USAGE_WORK SYSRES_CONST_AUTOTEXT_USAGE_WORK_CODE SYSRES_CONST_AUTOTEXT_USE_ANYWHERE_CODE SYSRES_CONST_AUTOTEXT_USE_ON_SIGNING_CODE SYSRES_CONST_AUTOTEXT_USE_ON_WORK_CODE SYSRES_CONST_BEGIN_DATE_REQUISITE_CODE SYSRES_CONST_BLACK_LIFE_CYCLE_STAGE_FONT_COLOR SYSRES_CONST_BLUE_LIFE_CYCLE_STAGE_FONT_COLOR SYSRES_CONST_BTN_PART SYSRES_CONST_CALCULATED_ROLE_TYPE_CODE SYSRES_CONST_CALL_TYPE_VARIABLE_BUTTON_VALUE SYSRES_CONST_CALL_TYPE_VARIABLE_PROGRAM_VALUE SYSRES_CONST_CANCEL_MESSAGE_FUNCTION_RESULT SYSRES_CONST_CARD_PART SYSRES_CONST_CARD_REFERENCE_MODE_NAME SYSRES_CONST_CERTIFICATE_TYPE_REQUISITE_ENCRYPT_VALUE SYSRES_CONST_CERTIFICATE_TYPE_REQUISITE_SIGN_AND_ENCRYPT_VALUE SYSRES_CONST_CERTIFICATE_TYPE_REQUISITE_SIGN_VALUE SYSRES_CONST_CHECK_PARAM_VALUE_DATE_PARAM_TYPE SYSRES_CONST_CHECK_PARAM_VALUE_FLOAT_PARAM_TYPE SYSRES_CONST_CHECK_PARAM_VALUE_INTEGER_PARAM_TYPE SYSRES_CONST_CHECK_PARAM_VALUE_PICK_PARAM_TYPE SYSRES_CONST_CHECK_PARAM_VALUE_REEFRENCE_PARAM_TYPE SYSRES_CONST_CLOSED_RECORD_FLAG_VALUE_FEMININE SYSRES_CONST_CLOSED_RECORD_FLAG_VALUE_MASCULINE SYSRES_CONST_CODE_COMPONENT_TYPE_ADMIN SYSRES_CONST_CODE_COMPONENT_TYPE_DEVELOPER SYSRES_CONST_CODE_COMPONENT_TYPE_DOCS SYSRES_CONST_CODE_COMPONENT_TYPE_EDOC_CARDS SYSRES_CONST_CODE_COMPONENT_TYPE_EXTERNAL_EXECUTABLE SYSRES_CONST_CODE_COMPONENT_TYPE_OTHER SYSRES_CONST_CODE_COMPONENT_TYPE_REFERENCE SYSRES_CONST_CODE_COMPONENT_TYPE_REPORT SYSRES_CONST_CODE_COMPONENT_TYPE_SCRIPT SYSRES_CONST_CODE_COMPONENT_TYPE_URL SYSRES_CONST_CODE_REQUISITE_ACCESS SYSRES_CONST_CODE_REQUISITE_CODE SYSRES_CONST_CODE_REQUISITE_COMPONENT SYSRES_CONST_CODE_REQUISITE_DESCRIPTION SYSRES_CONST_CODE_REQUISITE_EXCLUDE_COMPONENT SYSRES_CONST_CODE_REQUISITE_RECORD SYSRES_CONST_COMMENT_REQ_CODE SYSRES_CONST_COMMON_SETTINGS_REQUISITE_CODE SYSRES_CONST_COMP_CODE_GRD SYSRES_CONST_COMPONENT_GROUP_TYPE_REQUISITE_CODE SYSRES_CONST_COMPONENT_TYPE_ADMIN_COMPONENTS SYSRES_CONST_COMPONENT_TYPE_DEVELOPER_COMPONENTS SYSRES_CONST_COMPONENT_TYPE_DOCS SYSRES_CONST_COMPONENT_TYPE_EDOC_CARDS SYSRES_CONST_COMPONENT_TYPE_EDOCS SYSRES_CONST_COMPONENT_TYPE_EXTERNAL_EXECUTABLE SYSRES_CONST_COMPONENT_TYPE_OTHER SYSRES_CONST_COMPONENT_TYPE_REFERENCE_TYPES SYSRES_CONST_COMPONENT_TYPE_REFERENCES SYSRES_CONST_COMPONENT_TYPE_REPORTS SYSRES_CONST_COMPONENT_TYPE_SCRIPTS SYSRES_CONST_COMPONENT_TYPE_URL SYSRES_CONST_COMPONENTS_REMOTE_SERVERS_VIEW_CODE SYSRES_CONST_CONDITION_BLOCK_DESCRIPTION SYSRES_CONST_CONST_FIRM_STATUS_COMMON SYSRES_CONST_CONST_FIRM_STATUS_INDIVIDUAL SYSRES_CONST_CONST_NEGATIVE_VALUE SYSRES_CONST_CONST_POSITIVE_VALUE SYSRES_CONST_CONST_SERVER_STATUS_DONT_REPLICATE SYSRES_CONST_CONST_SERVER_STATUS_REPLICATE SYSRES_CONST_CONTENTS_REQUISITE_CODE SYSRES_CONST_DATA_TYPE_BOOLEAN SYSRES_CONST_DATA_TYPE_DATE SYSRES_CONST_DATA_TYPE_FLOAT SYSRES_CONST_DATA_TYPE_INTEGER SYSRES_CONST_DATA_TYPE_PICK SYSRES_CONST_DATA_TYPE_REFERENCE SYSRES_CONST_DATA_TYPE_STRING SYSRES_CONST_DATA_TYPE_TEXT SYSRES_CONST_DATA_TYPE_VARIANT SYSRES_CONST_DATE_CLOSE_REQ_CODE SYSRES_CONST_DATE_FORMAT_DATE_ONLY_CHAR SYSRES_CONST_DATE_OPEN_REQ_CODE SYSRES_CONST_DATE_REQUISITE SYSRES_CONST_DATE_REQUISITE_CODE SYSRES_CONST_DATE_REQUISITE_NAME SYSRES_CONST_DATE_REQUISITE_TYPE SYSRES_CONST_DATE_TYPE_CHAR SYSRES_CONST_DATETIME_FORMAT_VALUE SYSRES_CONST_DEA_ACCESS_RIGHTS_ACTION_CODE SYSRES_CONST_DESCRIPTION_LOCALIZE_ID_REQUISITE_CODE SYSRES_CONST_DESCRIPTION_REQUISITE_CODE SYSRES_CONST_DET1_PART SYSRES_CONST_DET2_PART SYSRES_CONST_DET3_PART SYSRES_CONST_DET4_PART SYSRES_CONST_DET5_PART SYSRES_CONST_DET6_PART SYSRES_CONST_DETAIL_DATASET_KEY_REQUISITE_CODE SYSRES_CONST_DETAIL_PICK_REQUISITE_CODE SYSRES_CONST_DETAIL_REQ_CODE SYSRES_CONST_DO_NOT_USE_ACCESS_TYPE_CODE SYSRES_CONST_DO_NOT_USE_ACCESS_TYPE_NAME SYSRES_CONST_DO_NOT_USE_ON_VIEW_ACCESS_TYPE_CODE SYSRES_CONST_DO_NOT_USE_ON_VIEW_ACCESS_TYPE_NAME SYSRES_CONST_DOCUMENT_STORAGES_CODE SYSRES_CONST_DOCUMENT_TEMPLATES_TYPE_NAME SYSRES_CONST_DOUBLE_REQUISITE_CODE SYSRES_CONST_EDITOR_CLOSE_FILE_OBSERV_TYPE_CODE SYSRES_CONST_EDITOR_CLOSE_PROCESS_OBSERV_TYPE_CODE SYSRES_CONST_EDITOR_TYPE_REQUISITE_CODE SYSRES_CONST_EDITORS_APPLICATION_NAME_REQUISITE_CODE SYSRES_CONST_EDITORS_CREATE_SEVERAL_PROCESSES_REQUISITE_CODE SYSRES_CONST_EDITORS_EXTENSION_REQUISITE_CODE SYSRES_CONST_EDITORS_OBSERVER_BY_PROCESS_TYPE SYSRES_CONST_EDITORS_REFERENCE_CODE SYSRES_CONST_EDITORS_REPLACE_SPEC_CHARS_REQUISITE_CODE SYSRES_CONST_EDITORS_USE_PLUGINS_REQUISITE_CODE SYSRES_CONST_EDITORS_VIEW_DOCUMENT_OPENED_TO_EDIT_CODE SYSRES_CONST_EDOC_CARD_TYPE_REQUISITE_CODE SYSRES_CONST_EDOC_CARD_TYPES_LINK_REQUISITE_CODE SYSRES_CONST_EDOC_CERTIFICATE_AND_PASSWORD_ENCODE_CODE SYSRES_CONST_EDOC_CERTIFICATE_ENCODE_CODE SYSRES_CONST_EDOC_DATE_REQUISITE_CODE SYSRES_CONST_EDOC_KIND_REFERENCE_CODE SYSRES_CONST_EDOC_KINDS_BY_TEMPLATE_ACTION_CODE SYSRES_CONST_EDOC_MANAGE_ACCESS_CODE SYSRES_CONST_EDOC_NONE_ENCODE_CODE SYSRES_CONST_EDOC_NUMBER_REQUISITE_CODE SYSRES_CONST_EDOC_PASSWORD_ENCODE_CODE SYSRES_CONST_EDOC_READONLY_ACCESS_CODE SYSRES_CONST_EDOC_SHELL_LIFE_TYPE_VIEW_VALUE SYSRES_CONST_EDOC_SIZE_RESTRICTION_PRIORITY_REQUISITE_CODE SYSRES_CONST_EDOC_STORAGE_CHECK_ACCESS_RIGHTS_REQUISITE_CODE SYSRES_CONST_EDOC_STORAGE_COMPUTER_NAME_REQUISITE_CODE SYSRES_CONST_EDOC_STORAGE_DATABASE_NAME_REQUISITE_CODE SYSRES_CONST_EDOC_STORAGE_EDIT_IN_STORAGE_REQUISITE_CODE SYSRES_CONST_EDOC_STORAGE_LOCAL_PATH_REQUISITE_CODE SYSRES_CONST_EDOC_STORAGE_SHARED_SOURCE_NAME_REQUISITE_CODE SYSRES_CONST_EDOC_TEMPLATE_REQUISITE_CODE SYSRES_CONST_EDOC_TYPES_REFERENCE_CODE SYSRES_CONST_EDOC_VERSION_ACTIVE_STAGE_CODE SYSRES_CONST_EDOC_VERSION_DESIGN_STAGE_CODE SYSRES_CONST_EDOC_VERSION_OBSOLETE_STAGE_CODE SYSRES_CONST_EDOC_WRITE_ACCES_CODE SYSRES_CONST_EDOCUMENT_CARD_REQUISITES_REFERENCE_CODE_SELECTED_REQUISITE SYSRES_CONST_ENCODE_CERTIFICATE_TYPE_CODE SYSRES_CONST_END_DATE_REQUISITE_CODE SYSRES_CONST_ENUMERATION_TYPE_REQUISITE_CODE SYSRES_CONST_EXECUTE_ACCESS_RIGHTS_TYPE_CODE SYSRES_CONST_EXECUTIVE_FILE_STORAGE_TYPE SYSRES_CONST_EXIST_CONST SYSRES_CONST_EXIST_VALUE SYSRES_CONST_EXPORT_LOCK_TYPE_ASK SYSRES_CONST_EXPORT_LOCK_TYPE_WITH_LOCK SYSRES_CONST_EXPORT_LOCK_TYPE_WITHOUT_LOCK SYSRES_CONST_EXPORT_VERSION_TYPE_ASK SYSRES_CONST_EXPORT_VERSION_TYPE_LAST SYSRES_CONST_EXPORT_VERSION_TYPE_LAST_ACTIVE SYSRES_CONST_EXTENSION_REQUISITE_CODE SYSRES_CONST_FILTER_NAME_REQUISITE_CODE SYSRES_CONST_FILTER_REQUISITE_CODE SYSRES_CONST_FILTER_TYPE_COMMON_CODE SYSRES_CONST_FILTER_TYPE_COMMON_NAME SYSRES_CONST_FILTER_TYPE_USER_CODE SYSRES_CONST_FILTER_TYPE_USER_NAME SYSRES_CONST_FILTER_VALUE_REQUISITE_NAME SYSRES_CONST_FLOAT_NUMBER_FORMAT_CHAR SYSRES_CONST_FLOAT_REQUISITE_TYPE SYSRES_CONST_FOLDER_AUTHOR_VALUE SYSRES_CONST_FOLDER_KIND_ANY_OBJECTS SYSRES_CONST_FOLDER_KIND_COMPONENTS SYSRES_CONST_FOLDER_KIND_EDOCS SYSRES_CONST_FOLDER_KIND_JOBS SYSRES_CONST_FOLDER_KIND_TASKS SYSRES_CONST_FOLDER_TYPE_COMMON SYSRES_CONST_FOLDER_TYPE_COMPONENT SYSRES_CONST_FOLDER_TYPE_FAVORITES SYSRES_CONST_FOLDER_TYPE_INBOX SYSRES_CONST_FOLDER_TYPE_OUTBOX SYSRES_CONST_FOLDER_TYPE_QUICK_LAUNCH SYSRES_CONST_FOLDER_TYPE_SEARCH SYSRES_CONST_FOLDER_TYPE_SHORTCUTS SYSRES_CONST_FOLDER_TYPE_USER SYSRES_CONST_FROM_DICTIONARY_ENUM_METHOD_FLAG SYSRES_CONST_FULL_SUBSTITUTE_TYPE SYSRES_CONST_FULL_SUBSTITUTE_TYPE_CODE SYSRES_CONST_FUNCTION_CANCEL_RESULT SYSRES_CONST_FUNCTION_CATEGORY_SYSTEM SYSRES_CONST_FUNCTION_CATEGORY_USER SYSRES_CONST_FUNCTION_FAILURE_RESULT SYSRES_CONST_FUNCTION_SAVE_RESULT SYSRES_CONST_GENERATED_REQUISITE SYSRES_CONST_GREEN_LIFE_CYCLE_STAGE_FONT_COLOR SYSRES_CONST_GROUP_ACCOUNT_TYPE_VALUE_CODE SYSRES_CONST_GROUP_CATEGORY_NORMAL_CODE SYSRES_CONST_GROUP_CATEGORY_NORMAL_NAME SYSRES_CONST_GROUP_CATEGORY_SERVICE_CODE SYSRES_CONST_GROUP_CATEGORY_SERVICE_NAME SYSRES_CONST_GROUP_COMMON_CATEGORY_FIELD_VALUE SYSRES_CONST_GROUP_FULL_NAME_REQUISITE_CODE SYSRES_CONST_GROUP_NAME_REQUISITE_CODE SYSRES_CONST_GROUP_RIGHTS_T_REQUISITE_CODE SYSRES_CONST_GROUP_SERVER_CODES_REQUISITE_CODE SYSRES_CONST_GROUP_SERVER_NAME_REQUISITE_CODE SYSRES_CONST_GROUP_SERVICE_CATEGORY_FIELD_VALUE SYSRES_CONST_GROUP_USER_REQUISITE_CODE SYSRES_CONST_GROUPS_REFERENCE_CODE SYSRES_CONST_GROUPS_REQUISITE_CODE SYSRES_CONST_HIDDEN_MODE_NAME SYSRES_CONST_HIGH_LVL_REQUISITE_CODE SYSRES_CONST_HISTORY_ACTION_CREATE_CODE SYSRES_CONST_HISTORY_ACTION_DELETE_CODE SYSRES_CONST_HISTORY_ACTION_EDIT_CODE SYSRES_CONST_HOUR_CHAR SYSRES_CONST_ID_REQUISITE_CODE SYSRES_CONST_IDSPS_REQUISITE_CODE SYSRES_CONST_IMAGE_MODE_COLOR SYSRES_CONST_IMAGE_MODE_GREYSCALE SYSRES_CONST_IMAGE_MODE_MONOCHROME SYSRES_CONST_IMPORTANCE_HIGH SYSRES_CONST_IMPORTANCE_LOW SYSRES_CONST_IMPORTANCE_NORMAL SYSRES_CONST_IN_DESIGN_VERSION_STATE_PICK_VALUE SYSRES_CONST_INCOMING_WORK_RULE_TYPE_CODE SYSRES_CONST_INT_REQUISITE SYSRES_CONST_INT_REQUISITE_TYPE SYSRES_CONST_INTEGER_NUMBER_FORMAT_CHAR SYSRES_CONST_INTEGER_TYPE_CHAR SYSRES_CONST_IS_GENERATED_REQUISITE_NEGATIVE_VALUE SYSRES_CONST_IS_PUBLIC_ROLE_REQUISITE_CODE SYSRES_CONST_IS_REMOTE_USER_NEGATIVE_VALUE SYSRES_CONST_IS_REMOTE_USER_POSITIVE_VALUE SYSRES_CONST_IS_STORED_REQUISITE_NEGATIVE_VALUE SYSRES_CONST_IS_STORED_REQUISITE_STORED_VALUE SYSRES_CONST_ITALIC_LIFE_CYCLE_STAGE_DRAW_STYLE SYSRES_CONST_JOB_BLOCK_DESCRIPTION SYSRES_CONST_JOB_KIND_CONTROL_JOB SYSRES_CONST_JOB_KIND_JOB SYSRES_CONST_JOB_KIND_NOTICE SYSRES_CONST_JOB_STATE_ABORTED SYSRES_CONST_JOB_STATE_COMPLETE SYSRES_CONST_JOB_STATE_WORKING SYSRES_CONST_KIND_REQUISITE_CODE SYSRES_CONST_KIND_REQUISITE_NAME SYSRES_CONST_KINDS_CREATE_SHADOW_COPIES_REQUISITE_CODE SYSRES_CONST_KINDS_DEFAULT_EDOC_LIFE_STAGE_REQUISITE_CODE SYSRES_CONST_KINDS_EDOC_ALL_TEPLATES_ALLOWED_REQUISITE_CODE SYSRES_CONST_KINDS_EDOC_ALLOW_LIFE_CYCLE_STAGE_CHANGING_REQUISITE_CODE SYSRES_CONST_KINDS_EDOC_ALLOW_MULTIPLE_ACTIVE_VERSIONS_REQUISITE_CODE SYSRES_CONST_KINDS_EDOC_SHARE_ACCES_RIGHTS_BY_DEFAULT_CODE SYSRES_CONST_KINDS_EDOC_TEMPLATE_REQUISITE_CODE SYSRES_CONST_KINDS_EDOC_TYPE_REQUISITE_CODE SYSRES_CONST_KINDS_SIGNERS_REQUISITES_CODE SYSRES_CONST_KOD_INPUT_TYPE SYSRES_CONST_LAST_UPDATE_DATE_REQUISITE_CODE SYSRES_CONST_LIFE_CYCLE_START_STAGE_REQUISITE_CODE SYSRES_CONST_LILAC_LIFE_CYCLE_STAGE_FONT_COLOR SYSRES_CONST_LINK_OBJECT_KIND_COMPONENT SYSRES_CONST_LINK_OBJECT_KIND_DOCUMENT SYSRES_CONST_LINK_OBJECT_KIND_EDOC SYSRES_CONST_LINK_OBJECT_KIND_FOLDER SYSRES_CONST_LINK_OBJECT_KIND_JOB SYSRES_CONST_LINK_OBJECT_KIND_REFERENCE SYSRES_CONST_LINK_OBJECT_KIND_TASK SYSRES_CONST_LINK_REF_TYPE_REQUISITE_CODE SYSRES_CONST_LIST_REFERENCE_MODE_NAME SYSRES_CONST_LOCALIZATION_DICTIONARY_MAIN_VIEW_CODE SYSRES_CONST_MAIN_VIEW_CODE SYSRES_CONST_MANUAL_ENUM_METHOD_FLAG SYSRES_CONST_MASTER_COMP_TYPE_REQUISITE_CODE SYSRES_CONST_MASTER_TABLE_REC_ID_REQUISITE_CODE SYSRES_CONST_MAXIMIZED_MODE_NAME SYSRES_CONST_ME_VALUE SYSRES_CONST_MESSAGE_ATTENTION_CAPTION SYSRES_CONST_MESSAGE_CONFIRMATION_CAPTION SYSRES_CONST_MESSAGE_ERROR_CAPTION SYSRES_CONST_MESSAGE_INFORMATION_CAPTION SYSRES_CONST_MINIMIZED_MODE_NAME SYSRES_CONST_MINUTE_CHAR SYSRES_CONST_MODULE_REQUISITE_CODE SYSRES_CONST_MONITORING_BLOCK_DESCRIPTION SYSRES_CONST_MONTH_FORMAT_VALUE SYSRES_CONST_NAME_LOCALIZE_ID_REQUISITE_CODE SYSRES_CONST_NAME_REQUISITE_CODE SYSRES_CONST_NAME_SINGULAR_REQUISITE_CODE SYSRES_CONST_NAMEAN_INPUT_TYPE SYSRES_CONST_NEGATIVE_PICK_VALUE SYSRES_CONST_NEGATIVE_VALUE SYSRES_CONST_NO SYSRES_CONST_NO_PICK_VALUE SYSRES_CONST_NO_SIGNATURE_REQUISITE_CODE SYSRES_CONST_NO_VALUE SYSRES_CONST_NONE_ACCESS_RIGHTS_TYPE_CODE SYSRES_CONST_NONOPERATING_RECORD_FLAG_VALUE SYSRES_CONST_NONOPERATING_RECORD_FLAG_VALUE_MASCULINE SYSRES_CONST_NORMAL_ACCESS_RIGHTS_TYPE_CODE SYSRES_CONST_NORMAL_LIFE_CYCLE_STAGE_DRAW_STYLE SYSRES_CONST_NORMAL_MODE_NAME SYSRES_CONST_NOT_ALLOWED_ACCESS_TYPE_CODE SYSRES_CONST_NOT_ALLOWED_ACCESS_TYPE_NAME SYSRES_CONST_NOTE_REQUISITE_CODE SYSRES_CONST_NOTICE_BLOCK_DESCRIPTION SYSRES_CONST_NUM_REQUISITE SYSRES_CONST_NUM_STR_REQUISITE_CODE SYSRES_CONST_NUMERATION_AUTO_NOT_STRONG SYSRES_CONST_NUMERATION_AUTO_STRONG SYSRES_CONST_NUMERATION_FROM_DICTONARY SYSRES_CONST_NUMERATION_MANUAL SYSRES_CONST_NUMERIC_TYPE_CHAR SYSRES_CONST_NUMREQ_REQUISITE_CODE SYSRES_CONST_OBSOLETE_VERSION_STATE_PICK_VALUE SYSRES_CONST_OPERATING_RECORD_FLAG_VALUE SYSRES_CONST_OPERATING_RECORD_FLAG_VALUE_CODE SYSRES_CONST_OPERATING_RECORD_FLAG_VALUE_FEMININE SYSRES_CONST_OPERATING_RECORD_FLAG_VALUE_MASCULINE SYSRES_CONST_OPTIONAL_FORM_COMP_REQCODE_PREFIX SYSRES_CONST_ORANGE_LIFE_CYCLE_STAGE_FONT_COLOR SYSRES_CONST_ORIGINALREF_REQUISITE_CODE SYSRES_CONST_OURFIRM_REF_CODE SYSRES_CONST_OURFIRM_REQUISITE_CODE SYSRES_CONST_OURFIRM_VAR SYSRES_CONST_OUTGOING_WORK_RULE_TYPE_CODE SYSRES_CONST_PICK_NEGATIVE_RESULT SYSRES_CONST_PICK_POSITIVE_RESULT SYSRES_CONST_PICK_REQUISITE SYSRES_CONST_PICK_REQUISITE_TYPE SYSRES_CONST_PICK_TYPE_CHAR SYSRES_CONST_PLAN_STATUS_REQUISITE_CODE SYSRES_CONST_PLATFORM_VERSION_COMMENT SYSRES_CONST_PLUGINS_SETTINGS_DESCRIPTION_REQUISITE_CODE SYSRES_CONST_POSITIVE_PICK_VALUE SYSRES_CONST_POWER_TO_CREATE_ACTION_CODE SYSRES_CONST_POWER_TO_SIGN_ACTION_CODE SYSRES_CONST_PRIORITY_REQUISITE_CODE SYSRES_CONST_QUALIFIED_TASK_TYPE SYSRES_CONST_QUALIFIED_TASK_TYPE_CODE SYSRES_CONST_RECSTAT_REQUISITE_CODE SYSRES_CONST_RED_LIFE_CYCLE_STAGE_FONT_COLOR SYSRES_CONST_REF_ID_T_REF_TYPE_REQUISITE_CODE SYSRES_CONST_REF_REQUISITE SYSRES_CONST_REF_REQUISITE_TYPE SYSRES_CONST_REF_REQUISITES_REFERENCE_CODE_SELECTED_REQUISITE SYSRES_CONST_REFERENCE_RECORD_HISTORY_CREATE_ACTION_CODE SYSRES_CONST_REFERENCE_RECORD_HISTORY_DELETE_ACTION_CODE SYSRES_CONST_REFERENCE_RECORD_HISTORY_MODIFY_ACTION_CODE SYSRES_CONST_REFERENCE_TYPE_CHAR SYSRES_CONST_REFERENCE_TYPE_REQUISITE_NAME SYSRES_CONST_REFERENCES_ADD_PARAMS_REQUISITE_CODE SYSRES_CONST_REFERENCES_DISPLAY_REQUISITE_REQUISITE_CODE SYSRES_CONST_REMOTE_SERVER_STATUS_WORKING SYSRES_CONST_REMOTE_SERVER_TYPE_MAIN SYSRES_CONST_REMOTE_SERVER_TYPE_SECONDARY SYSRES_CONST_REMOTE_USER_FLAG_VALUE_CODE SYSRES_CONST_REPORT_APP_EDITOR_INTERNAL SYSRES_CONST_REPORT_BASE_REPORT_ID_REQUISITE_CODE SYSRES_CONST_REPORT_BASE_REPORT_REQUISITE_CODE SYSRES_CONST_REPORT_SCRIPT_REQUISITE_CODE SYSRES_CONST_REPORT_TEMPLATE_REQUISITE_CODE SYSRES_CONST_REPORT_VIEWER_CODE_REQUISITE_CODE SYSRES_CONST_REQ_ALLOW_COMPONENT_DEFAULT_VALUE SYSRES_CONST_REQ_ALLOW_RECORD_DEFAULT_VALUE SYSRES_CONST_REQ_ALLOW_SERVER_COMPONENT_DEFAULT_VALUE SYSRES_CONST_REQ_MODE_AVAILABLE_CODE SYSRES_CONST_REQ_MODE_EDIT_CODE SYSRES_CONST_REQ_MODE_HIDDEN_CODE SYSRES_CONST_REQ_MODE_NOT_AVAILABLE_CODE SYSRES_CONST_REQ_MODE_VIEW_CODE SYSRES_CONST_REQ_NUMBER_REQUISITE_CODE SYSRES_CONST_REQ_SECTION_VALUE SYSRES_CONST_REQ_TYPE_VALUE SYSRES_CONST_REQUISITE_FORMAT_BY_UNIT SYSRES_CONST_REQUISITE_FORMAT_DATE_FULL SYSRES_CONST_REQUISITE_FORMAT_DATE_TIME SYSRES_CONST_REQUISITE_FORMAT_LEFT SYSRES_CONST_REQUISITE_FORMAT_RIGHT SYSRES_CONST_REQUISITE_FORMAT_WITHOUT_UNIT SYSRES_CONST_REQUISITE_NUMBER_REQUISITE_CODE SYSRES_CONST_REQUISITE_SECTION_ACTIONS SYSRES_CONST_REQUISITE_SECTION_BUTTON SYSRES_CONST_REQUISITE_SECTION_BUTTONS SYSRES_CONST_REQUISITE_SECTION_CARD SYSRES_CONST_REQUISITE_SECTION_TABLE SYSRES_CONST_REQUISITE_SECTION_TABLE10 SYSRES_CONST_REQUISITE_SECTION_TABLE11 SYSRES_CONST_REQUISITE_SECTION_TABLE12 SYSRES_CONST_REQUISITE_SECTION_TABLE13 SYSRES_CONST_REQUISITE_SECTION_TABLE14 SYSRES_CONST_REQUISITE_SECTION_TABLE15 SYSRES_CONST_REQUISITE_SECTION_TABLE16 SYSRES_CONST_REQUISITE_SECTION_TABLE17 SYSRES_CONST_REQUISITE_SECTION_TABLE18 SYSRES_CONST_REQUISITE_SECTION_TABLE19 SYSRES_CONST_REQUISITE_SECTION_TABLE2 SYSRES_CONST_REQUISITE_SECTION_TABLE20 SYSRES_CONST_REQUISITE_SECTION_TABLE21 SYSRES_CONST_REQUISITE_SECTION_TABLE22 SYSRES_CONST_REQUISITE_SECTION_TABLE23 SYSRES_CONST_REQUISITE_SECTION_TABLE24 SYSRES_CONST_REQUISITE_SECTION_TABLE3 SYSRES_CONST_REQUISITE_SECTION_TABLE4 SYSRES_CONST_REQUISITE_SECTION_TABLE5 SYSRES_CONST_REQUISITE_SECTION_TABLE6 SYSRES_CONST_REQUISITE_SECTION_TABLE7 SYSRES_CONST_REQUISITE_SECTION_TABLE8 SYSRES_CONST_REQUISITE_SECTION_TABLE9 SYSRES_CONST_REQUISITES_PSEUDOREFERENCE_REQUISITE_NUMBER_REQUISITE_CODE SYSRES_CONST_RIGHT_ALIGNMENT_CODE SYSRES_CONST_ROLES_REFERENCE_CODE SYSRES_CONST_ROUTE_STEP_AFTER_RUS SYSRES_CONST_ROUTE_STEP_AND_CONDITION_RUS SYSRES_CONST_ROUTE_STEP_OR_CONDITION_RUS SYSRES_CONST_ROUTE_TYPE_COMPLEX SYSRES_CONST_ROUTE_TYPE_PARALLEL SYSRES_CONST_ROUTE_TYPE_SERIAL SYSRES_CONST_SBDATASETDESC_NEGATIVE_VALUE SYSRES_CONST_SBDATASETDESC_POSITIVE_VALUE SYSRES_CONST_SBVIEWSDESC_POSITIVE_VALUE SYSRES_CONST_SCRIPT_BLOCK_DESCRIPTION SYSRES_CONST_SEARCH_BY_TEXT_REQUISITE_CODE SYSRES_CONST_SEARCHES_COMPONENT_CONTENT SYSRES_CONST_SEARCHES_CRITERIA_ACTION_NAME SYSRES_CONST_SEARCHES_EDOC_CONTENT SYSRES_CONST_SEARCHES_FOLDER_CONTENT SYSRES_CONST_SEARCHES_JOB_CONTENT SYSRES_CONST_SEARCHES_REFERENCE_CODE SYSRES_CONST_SEARCHES_TASK_CONTENT SYSRES_CONST_SECOND_CHAR SYSRES_CONST_SECTION_REQUISITE_ACTIONS_VALUE SYSRES_CONST_SECTION_REQUISITE_CARD_VALUE SYSRES_CONST_SECTION_REQUISITE_CODE SYSRES_CONST_SECTION_REQUISITE_DETAIL_1_VALUE SYSRES_CONST_SECTION_REQUISITE_DETAIL_2_VALUE SYSRES_CONST_SECTION_REQUISITE_DETAIL_3_VALUE SYSRES_CONST_SECTION_REQUISITE_DETAIL_4_VALUE SYSRES_CONST_SECTION_REQUISITE_DETAIL_5_VALUE SYSRES_CONST_SECTION_REQUISITE_DETAIL_6_VALUE SYSRES_CONST_SELECT_REFERENCE_MODE_NAME SYSRES_CONST_SELECT_TYPE_SELECTABLE SYSRES_CONST_SELECT_TYPE_SELECTABLE_ONLY_CHILD SYSRES_CONST_SELECT_TYPE_SELECTABLE_WITH_CHILD SYSRES_CONST_SELECT_TYPE_UNSLECTABLE SYSRES_CONST_SERVER_TYPE_MAIN SYSRES_CONST_SERVICE_USER_CATEGORY_FIELD_VALUE SYSRES_CONST_SETTINGS_USER_REQUISITE_CODE SYSRES_CONST_SIGNATURE_AND_ENCODE_CERTIFICATE_TYPE_CODE SYSRES_CONST_SIGNATURE_CERTIFICATE_TYPE_CODE SYSRES_CONST_SINGULAR_TITLE_REQUISITE_CODE SYSRES_CONST_SQL_SERVER_AUTHENTIFICATION_FLAG_VALUE_CODE SYSRES_CONST_SQL_SERVER_ENCODE_AUTHENTIFICATION_FLAG_VALUE_CODE SYSRES_CONST_STANDART_ROUTE_REFERENCE_CODE SYSRES_CONST_STANDART_ROUTE_REFERENCE_COMMENT_REQUISITE_CODE SYSRES_CONST_STANDART_ROUTES_GROUPS_REFERENCE_CODE SYSRES_CONST_STATE_REQ_NAME SYSRES_CONST_STATE_REQUISITE_ACTIVE_VALUE SYSRES_CONST_STATE_REQUISITE_CLOSED_VALUE SYSRES_CONST_STATE_REQUISITE_CODE SYSRES_CONST_STATIC_ROLE_TYPE_CODE SYSRES_CONST_STATUS_PLAN_DEFAULT_VALUE SYSRES_CONST_STATUS_VALUE_AUTOCLEANING SYSRES_CONST_STATUS_VALUE_BLUE_SQUARE SYSRES_CONST_STATUS_VALUE_COMPLETE SYSRES_CONST_STATUS_VALUE_GREEN_SQUARE SYSRES_CONST_STATUS_VALUE_ORANGE_SQUARE SYSRES_CONST_STATUS_VALUE_PURPLE_SQUARE SYSRES_CONST_STATUS_VALUE_RED_SQUARE SYSRES_CONST_STATUS_VALUE_SUSPEND SYSRES_CONST_STATUS_VALUE_YELLOW_SQUARE SYSRES_CONST_STDROUTE_SHOW_TO_USERS_REQUISITE_CODE SYSRES_CONST_STORAGE_TYPE_FILE SYSRES_CONST_STORAGE_TYPE_SQL_SERVER SYSRES_CONST_STR_REQUISITE SYSRES_CONST_STRIKEOUT_LIFE_CYCLE_STAGE_DRAW_STYLE SYSRES_CONST_STRING_FORMAT_LEFT_ALIGN_CHAR SYSRES_CONST_STRING_FORMAT_RIGHT_ALIGN_CHAR SYSRES_CONST_STRING_REQUISITE_CODE SYSRES_CONST_STRING_REQUISITE_TYPE SYSRES_CONST_STRING_TYPE_CHAR SYSRES_CONST_SUBSTITUTES_PSEUDOREFERENCE_CODE SYSRES_CONST_SUBTASK_BLOCK_DESCRIPTION SYSRES_CONST_SYSTEM_SETTING_CURRENT_USER_PARAM_VALUE SYSRES_CONST_SYSTEM_SETTING_EMPTY_VALUE_PARAM_VALUE SYSRES_CONST_SYSTEM_VERSION_COMMENT SYSRES_CONST_TASK_ACCESS_TYPE_ALL SYSRES_CONST_TASK_ACCESS_TYPE_ALL_MEMBERS SYSRES_CONST_TASK_ACCESS_TYPE_MANUAL SYSRES_CONST_TASK_ENCODE_TYPE_CERTIFICATION SYSRES_CONST_TASK_ENCODE_TYPE_CERTIFICATION_AND_PASSWORD SYSRES_CONST_TASK_ENCODE_TYPE_NONE SYSRES_CONST_TASK_ENCODE_TYPE_PASSWORD SYSRES_CONST_TASK_ROUTE_ALL_CONDITION SYSRES_CONST_TASK_ROUTE_AND_CONDITION SYSRES_CONST_TASK_ROUTE_OR_CONDITION SYSRES_CONST_TASK_STATE_ABORTED SYSRES_CONST_TASK_STATE_COMPLETE SYSRES_CONST_TASK_STATE_CONTINUED SYSRES_CONST_TASK_STATE_CONTROL SYSRES_CONST_TASK_STATE_INIT SYSRES_CONST_TASK_STATE_WORKING SYSRES_CONST_TASK_TITLE SYSRES_CONST_TASK_TYPES_GROUPS_REFERENCE_CODE SYSRES_CONST_TASK_TYPES_REFERENCE_CODE SYSRES_CONST_TEMPLATES_REFERENCE_CODE SYSRES_CONST_TEST_DATE_REQUISITE_NAME SYSRES_CONST_TEST_DEV_DATABASE_NAME SYSRES_CONST_TEST_DEV_SYSTEM_CODE SYSRES_CONST_TEST_EDMS_DATABASE_NAME SYSRES_CONST_TEST_EDMS_MAIN_CODE SYSRES_CONST_TEST_EDMS_MAIN_DB_NAME SYSRES_CONST_TEST_EDMS_SECOND_CODE SYSRES_CONST_TEST_EDMS_SECOND_DB_NAME SYSRES_CONST_TEST_EDMS_SYSTEM_CODE SYSRES_CONST_TEST_NUMERIC_REQUISITE_NAME SYSRES_CONST_TEXT_REQUISITE SYSRES_CONST_TEXT_REQUISITE_CODE SYSRES_CONST_TEXT_REQUISITE_TYPE SYSRES_CONST_TEXT_TYPE_CHAR SYSRES_CONST_TYPE_CODE_REQUISITE_CODE SYSRES_CONST_TYPE_REQUISITE_CODE SYSRES_CONST_UNDEFINED_LIFE_CYCLE_STAGE_FONT_COLOR SYSRES_CONST_UNITS_SECTION_ID_REQUISITE_CODE SYSRES_CONST_UNITS_SECTION_REQUISITE_CODE SYSRES_CONST_UNOPERATING_RECORD_FLAG_VALUE_CODE SYSRES_CONST_UNSTORED_DATA_REQUISITE_CODE SYSRES_CONST_UNSTORED_DATA_REQUISITE_NAME SYSRES_CONST_USE_ACCESS_TYPE_CODE SYSRES_CONST_USE_ACCESS_TYPE_NAME SYSRES_CONST_USER_ACCOUNT_TYPE_VALUE_CODE SYSRES_CONST_USER_ADDITIONAL_INFORMATION_REQUISITE_CODE SYSRES_CONST_USER_AND_GROUP_ID_FROM_PSEUDOREFERENCE_REQUISITE_CODE SYSRES_CONST_USER_CATEGORY_NORMAL SYSRES_CONST_USER_CERTIFICATE_REQUISITE_CODE SYSRES_CONST_USER_CERTIFICATE_STATE_REQUISITE_CODE SYSRES_CONST_USER_CERTIFICATE_SUBJECT_NAME_REQUISITE_CODE SYSRES_CONST_USER_CERTIFICATE_THUMBPRINT_REQUISITE_CODE SYSRES_CONST_USER_COMMON_CATEGORY SYSRES_CONST_USER_COMMON_CATEGORY_CODE SYSRES_CONST_USER_FULL_NAME_REQUISITE_CODE SYSRES_CONST_USER_GROUP_TYPE_REQUISITE_CODE SYSRES_CONST_USER_LOGIN_REQUISITE_CODE SYSRES_CONST_USER_REMOTE_CONTROLLER_REQUISITE_CODE SYSRES_CONST_USER_REMOTE_SYSTEM_REQUISITE_CODE SYSRES_CONST_USER_RIGHTS_T_REQUISITE_CODE SYSRES_CONST_USER_SERVER_NAME_REQUISITE_CODE SYSRES_CONST_USER_SERVICE_CATEGORY SYSRES_CONST_USER_SERVICE_CATEGORY_CODE SYSRES_CONST_USER_STATUS_ADMINISTRATOR_CODE SYSRES_CONST_USER_STATUS_ADMINISTRATOR_NAME SYSRES_CONST_USER_STATUS_DEVELOPER_CODE SYSRES_CONST_USER_STATUS_DEVELOPER_NAME SYSRES_CONST_USER_STATUS_DISABLED_CODE SYSRES_CONST_USER_STATUS_DISABLED_NAME SYSRES_CONST_USER_STATUS_SYSTEM_DEVELOPER_CODE SYSRES_CONST_USER_STATUS_USER_CODE SYSRES_CONST_USER_STATUS_USER_NAME SYSRES_CONST_USER_STATUS_USER_NAME_DEPRECATED SYSRES_CONST_USER_TYPE_FIELD_VALUE_USER SYSRES_CONST_USER_TYPE_REQUISITE_CODE SYSRES_CONST_USERS_CONTROLLER_REQUISITE_CODE SYSRES_CONST_USERS_IS_MAIN_SERVER_REQUISITE_CODE SYSRES_CONST_USERS_REFERENCE_CODE SYSRES_CONST_USERS_REGISTRATION_CERTIFICATES_ACTION_NAME SYSRES_CONST_USERS_REQUISITE_CODE SYSRES_CONST_USERS_SYSTEM_REQUISITE_CODE SYSRES_CONST_USERS_USER_ACCESS_RIGHTS_TYPR_REQUISITE_CODE SYSRES_CONST_USERS_USER_AUTHENTICATION_REQUISITE_CODE SYSRES_CONST_USERS_USER_COMPONENT_REQUISITE_CODE SYSRES_CONST_USERS_USER_GROUP_REQUISITE_CODE SYSRES_CONST_USERS_VIEW_CERTIFICATES_ACTION_NAME SYSRES_CONST_VIEW_DEFAULT_CODE SYSRES_CONST_VIEW_DEFAULT_NAME SYSRES_CONST_VIEWER_REQUISITE_CODE SYSRES_CONST_WAITING_BLOCK_DESCRIPTION SYSRES_CONST_WIZARD_FORM_LABEL_TEST_STRING SYSRES_CONST_WIZARD_QUERY_PARAM_HEIGHT_ETALON_STRING SYSRES_CONST_WIZARD_REFERENCE_COMMENT_REQUISITE_CODE SYSRES_CONST_WORK_RULES_DESCRIPTION_REQUISITE_CODE SYSRES_CONST_WORK_TIME_CALENDAR_REFERENCE_CODE SYSRES_CONST_WORK_WORKFLOW_HARD_ROUTE_TYPE_VALUE SYSRES_CONST_WORK_WORKFLOW_HARD_ROUTE_TYPE_VALUE_CODE SYSRES_CONST_WORK_WORKFLOW_HARD_ROUTE_TYPE_VALUE_CODE_RUS SYSRES_CONST_WORK_WORKFLOW_SOFT_ROUTE_TYPE_VALUE_CODE_RUS SYSRES_CONST_WORKFLOW_ROUTE_TYPR_HARD SYSRES_CONST_WORKFLOW_ROUTE_TYPR_SOFT SYSRES_CONST_XML_ENCODING SYSRES_CONST_XREC_STAT_REQUISITE_CODE SYSRES_CONST_XRECID_FIELD_NAME SYSRES_CONST_YES SYSRES_CONST_YES_NO_2_REQUISITE_CODE SYSRES_CONST_YES_NO_REQUISITE_CODE SYSRES_CONST_YES_NO_T_REF_TYPE_REQUISITE_CODE SYSRES_CONST_YES_PICK_VALUE SYSRES_CONST_YES_VALUE CR FALSE nil NO_VALUE NULL TAB TRUE YES_VALUE ADMINISTRATORS_GROUP_NAME CUSTOMIZERS_GROUP_NAME DEVELOPERS_GROUP_NAME SERVICE_USERS_GROUP_NAME DECISION_BLOCK_FIRST_OPERAND_PROPERTY DECISION_BLOCK_NAME_PROPERTY DECISION_BLOCK_OPERATION_PROPERTY DECISION_BLOCK_RESULT_TYPE_PROPERTY DECISION_BLOCK_SECOND_OPERAND_PROPERTY ANY_FILE_EXTENTION COMPRESSED_DOCUMENT_EXTENSION EXTENDED_DOCUMENT_EXTENSION SHORT_COMPRESSED_DOCUMENT_EXTENSION SHORT_EXTENDED_DOCUMENT_EXTENSION JOB_BLOCK_ABORT_DEADLINE_PROPERTY JOB_BLOCK_AFTER_FINISH_EVENT JOB_BLOCK_AFTER_QUERY_PARAMETERS_EVENT JOB_BLOCK_ATTACHMENT_PROPERTY JOB_BLOCK_ATTACHMENTS_RIGHTS_GROUP_PROPERTY JOB_BLOCK_ATTACHMENTS_RIGHTS_TYPE_PROPERTY JOB_BLOCK_BEFORE_QUERY_PARAMETERS_EVENT JOB_BLOCK_BEFORE_START_EVENT JOB_BLOCK_CREATED_JOBS_PROPERTY JOB_BLOCK_DEADLINE_PROPERTY JOB_BLOCK_EXECUTION_RESULTS_PROPERTY JOB_BLOCK_IS_PARALLEL_PROPERTY JOB_BLOCK_IS_RELATIVE_ABORT_DEADLINE_PROPERTY JOB_BLOCK_IS_RELATIVE_DEADLINE_PROPERTY JOB_BLOCK_JOB_TEXT_PROPERTY JOB_BLOCK_NAME_PROPERTY JOB_BLOCK_NEED_SIGN_ON_PERFORM_PROPERTY JOB_BLOCK_PERFORMER_PROPERTY JOB_BLOCK_RELATIVE_ABORT_DEADLINE_TYPE_PROPERTY JOB_BLOCK_RELATIVE_DEADLINE_TYPE_PROPERTY JOB_BLOCK_SUBJECT_PROPERTY ENGLISH_LANGUAGE_CODE RUSSIAN_LANGUAGE_CODE smHidden smMaximized smMinimized smNormal wmNo wmYes COMPONENT_TOKEN_LINK_KIND DOCUMENT_LINK_KIND EDOCUMENT_LINK_KIND FOLDER_LINK_KIND JOB_LINK_KIND REFERENCE_LINK_KIND TASK_LINK_KIND COMPONENT_TOKEN_LOCK_TYPE EDOCUMENT_VERSION_LOCK_TYPE MONITOR_BLOCK_AFTER_FINISH_EVENT MONITOR_BLOCK_BEFORE_START_EVENT MONITOR_BLOCK_DEADLINE_PROPERTY MONITOR_BLOCK_INTERVAL_PROPERTY MONITOR_BLOCK_INTERVAL_TYPE_PROPERTY MONITOR_BLOCK_IS_RELATIVE_DEADLINE_PROPERTY MONITOR_BLOCK_NAME_PROPERTY MONITOR_BLOCK_RELATIVE_DEADLINE_TYPE_PROPERTY MONITOR_BLOCK_SEARCH_SCRIPT_PROPERTY NOTICE_BLOCK_AFTER_FINISH_EVENT NOTICE_BLOCK_ATTACHMENT_PROPERTY NOTICE_BLOCK_ATTACHMENTS_RIGHTS_GROUP_PROPERTY NOTICE_BLOCK_ATTACHMENTS_RIGHTS_TYPE_PROPERTY NOTICE_BLOCK_BEFORE_START_EVENT NOTICE_BLOCK_CREATED_NOTICES_PROPERTY NOTICE_BLOCK_DEADLINE_PROPERTY NOTICE_BLOCK_IS_RELATIVE_DEADLINE_PROPERTY NOTICE_BLOCK_NAME_PROPERTY NOTICE_BLOCK_NOTICE_TEXT_PROPERTY NOTICE_BLOCK_PERFORMER_PROPERTY NOTICE_BLOCK_RELATIVE_DEADLINE_TYPE_PROPERTY NOTICE_BLOCK_SUBJECT_PROPERTY dseAfterCancel dseAfterClose dseAfterDelete dseAfterDeleteOutOfTransaction dseAfterInsert dseAfterOpen dseAfterScroll dseAfterUpdate dseAfterUpdateOutOfTransaction dseBeforeCancel dseBeforeClose dseBeforeDelete dseBeforeDetailUpdate dseBeforeInsert dseBeforeOpen dseBeforeUpdate dseOnAnyRequisiteChange dseOnCloseRecord dseOnDeleteError dseOnOpenRecord dseOnPrepareUpdate dseOnUpdateError dseOnUpdateRatifiedRecord dseOnValidDelete dseOnValidUpdate reOnChange reOnChangeValues SELECTION_BEGIN_ROUTE_EVENT SELECTION_END_ROUTE_EVENT CURRENT_PERIOD_IS_REQUIRED PREVIOUS_CARD_TYPE_NAME SHOW_RECORD_PROPERTIES_FORM ACCESS_RIGHTS_SETTING_DIALOG_CODE ADMINISTRATOR_USER_CODE ANALYTIC_REPORT_TYPE asrtHideLocal asrtHideRemote CALCULATED_ROLE_TYPE_CODE COMPONENTS_REFERENCE_DEVELOPER_VIEW_CODE DCTS_TEST_PROTOCOLS_FOLDER_PATH E_EDOC_VERSION_ALREADY_APPROVINGLY_SIGNED E_EDOC_VERSION_ALREADY_APPROVINGLY_SIGNED_BY_USER E_EDOC_VERSION_ALREDY_SIGNED E_EDOC_VERSION_ALREDY_SIGNED_BY_USER EDOC_TYPES_CODE_REQUISITE_FIELD_NAME EDOCUMENTS_ALIAS_NAME FILES_FOLDER_PATH FILTER_OPERANDS_DELIMITER FILTER_OPERATIONS_DELIMITER FORMCARD_NAME FORMLIST_NAME GET_EXTENDED_DOCUMENT_EXTENSION_CREATION_MODE GET_EXTENDED_DOCUMENT_EXTENSION_IMPORT_MODE INTEGRATED_REPORT_TYPE IS_BUILDER_APPLICATION_ROLE IS_BUILDER_APPLICATION_ROLE2 IS_BUILDER_USERS ISBSYSDEV LOG_FOLDER_PATH mbCancel mbNo mbNoToAll mbOK mbYes mbYesToAll MEMORY_DATASET_DESRIPTIONS_FILENAME mrNo mrNoToAll mrYes mrYesToAll MULTIPLE_SELECT_DIALOG_CODE NONOPERATING_RECORD_FLAG_FEMININE NONOPERATING_RECORD_FLAG_MASCULINE OPERATING_RECORD_FLAG_FEMININE OPERATING_RECORD_FLAG_MASCULINE PROFILING_SETTINGS_COMMON_SETTINGS_CODE_VALUE PROGRAM_INITIATED_LOOKUP_ACTION ratDelete ratEdit ratInsert REPORT_TYPE REQUIRED_PICK_VALUES_VARIABLE rmCard rmList SBRTE_PROGID_DEV SBRTE_PROGID_RELEASE STATIC_ROLE_TYPE_CODE SUPPRESS_EMPTY_TEMPLATE_CREATION SYSTEM_USER_CODE UPDATE_DIALOG_DATASET USED_IN_OBJECT_HINT_PARAM USER_INITIATED_LOOKUP_ACTION USER_NAME_FORMAT USER_SELECTION_RESTRICTIONS WORKFLOW_TEST_PROTOCOLS_FOLDER_PATH ELS_SUBTYPE_CONTROL_NAME ELS_FOLDER_KIND_CONTROL_NAME REPEAT_PROCESS_CURRENT_OBJECT_EXCEPTION_NAME PRIVILEGE_COMPONENT_FULL_ACCESS PRIVILEGE_DEVELOPMENT_EXPORT PRIVILEGE_DEVELOPMENT_IMPORT PRIVILEGE_DOCUMENT_DELETE PRIVILEGE_ESD PRIVILEGE_FOLDER_DELETE PRIVILEGE_MANAGE_ACCESS_RIGHTS PRIVILEGE_MANAGE_REPLICATION PRIVILEGE_MANAGE_SESSION_SERVER PRIVILEGE_OBJECT_FULL_ACCESS PRIVILEGE_OBJECT_VIEW PRIVILEGE_RESERVE_LICENSE PRIVILEGE_SYSTEM_CUSTOMIZE PRIVILEGE_SYSTEM_DEVELOP PRIVILEGE_SYSTEM_INSTALL PRIVILEGE_TASK_DELETE PRIVILEGE_USER_PLUGIN_SETTINGS_CUSTOMIZE PRIVILEGES_PSEUDOREFERENCE_CODE ACCESS_TYPES_PSEUDOREFERENCE_CODE ALL_AVAILABLE_COMPONENTS_PSEUDOREFERENCE_CODE ALL_AVAILABLE_PRIVILEGES_PSEUDOREFERENCE_CODE ALL_REPLICATE_COMPONENTS_PSEUDOREFERENCE_CODE AVAILABLE_DEVELOPERS_COMPONENTS_PSEUDOREFERENCE_CODE COMPONENTS_PSEUDOREFERENCE_CODE FILTRATER_SETTINGS_CONFLICTS_PSEUDOREFERENCE_CODE GROUPS_PSEUDOREFERENCE_CODE RECEIVE_PROTOCOL_PSEUDOREFERENCE_CODE REFERENCE_REQUISITE_PSEUDOREFERENCE_CODE REFERENCE_REQUISITES_PSEUDOREFERENCE_CODE REFTYPES_PSEUDOREFERENCE_CODE REPLICATION_SEANCES_DIARY_PSEUDOREFERENCE_CODE SEND_PROTOCOL_PSEUDOREFERENCE_CODE SUBSTITUTES_PSEUDOREFERENCE_CODE SYSTEM_SETTINGS_PSEUDOREFERENCE_CODE UNITS_PSEUDOREFERENCE_CODE USERS_PSEUDOREFERENCE_CODE VIEWERS_PSEUDOREFERENCE_CODE CERTIFICATE_TYPE_ENCRYPT CERTIFICATE_TYPE_SIGN CERTIFICATE_TYPE_SIGN_AND_ENCRYPT STORAGE_TYPE_FILE STORAGE_TYPE_NAS_CIFS STORAGE_TYPE_SAPERION STORAGE_TYPE_SQL_SERVER COMPTYPE2_REQUISITE_DOCUMENTS_VALUE COMPTYPE2_REQUISITE_TASKS_VALUE COMPTYPE2_REQUISITE_FOLDERS_VALUE COMPTYPE2_REQUISITE_REFERENCES_VALUE SYSREQ_CODE SYSREQ_COMPTYPE2 SYSREQ_CONST_AVAILABLE_FOR_WEB SYSREQ_CONST_COMMON_CODE SYSREQ_CONST_COMMON_VALUE SYSREQ_CONST_FIRM_CODE SYSREQ_CONST_FIRM_STATUS SYSREQ_CONST_FIRM_VALUE SYSREQ_CONST_SERVER_STATUS SYSREQ_CONTENTS SYSREQ_DATE_OPEN SYSREQ_DATE_CLOSE SYSREQ_DESCRIPTION SYSREQ_DESCRIPTION_LOCALIZE_ID SYSREQ_DOUBLE SYSREQ_EDOC_ACCESS_TYPE SYSREQ_EDOC_AUTHOR SYSREQ_EDOC_CREATED SYSREQ_EDOC_DELEGATE_RIGHTS_REQUISITE_CODE SYSREQ_EDOC_EDITOR SYSREQ_EDOC_ENCODE_TYPE SYSREQ_EDOC_ENCRYPTION_PLUGIN_NAME SYSREQ_EDOC_ENCRYPTION_PLUGIN_VERSION SYSREQ_EDOC_EXPORT_DATE SYSREQ_EDOC_EXPORTER SYSREQ_EDOC_KIND SYSREQ_EDOC_LIFE_STAGE_NAME SYSREQ_EDOC_LOCKED_FOR_SERVER_CODE SYSREQ_EDOC_MODIFIED SYSREQ_EDOC_NAME SYSREQ_EDOC_NOTE SYSREQ_EDOC_QUALIFIED_ID SYSREQ_EDOC_SESSION_KEY SYSREQ_EDOC_SESSION_KEY_ENCRYPTION_PLUGIN_NAME SYSREQ_EDOC_SESSION_KEY_ENCRYPTION_PLUGIN_VERSION SYSREQ_EDOC_SIGNATURE_TYPE SYSREQ_EDOC_SIGNED SYSREQ_EDOC_STORAGE SYSREQ_EDOC_STORAGES_ARCHIVE_STORAGE SYSREQ_EDOC_STORAGES_CHECK_RIGHTS SYSREQ_EDOC_STORAGES_COMPUTER_NAME SYSREQ_EDOC_STORAGES_EDIT_IN_STORAGE SYSREQ_EDOC_STORAGES_EXECUTIVE_STORAGE SYSREQ_EDOC_STORAGES_FUNCTION SYSREQ_EDOC_STORAGES_INITIALIZED SYSREQ_EDOC_STORAGES_LOCAL_PATH SYSREQ_EDOC_STORAGES_SAPERION_DATABASE_NAME SYSREQ_EDOC_STORAGES_SEARCH_BY_TEXT SYSREQ_EDOC_STORAGES_SERVER_NAME SYSREQ_EDOC_STORAGES_SHARED_SOURCE_NAME SYSREQ_EDOC_STORAGES_TYPE SYSREQ_EDOC_TEXT_MODIFIED SYSREQ_EDOC_TYPE_ACT_CODE SYSREQ_EDOC_TYPE_ACT_DESCRIPTION SYSREQ_EDOC_TYPE_ACT_DESCRIPTION_LOCALIZE_ID SYSREQ_EDOC_TYPE_ACT_ON_EXECUTE SYSREQ_EDOC_TYPE_ACT_ON_EXECUTE_EXISTS SYSREQ_EDOC_TYPE_ACT_SECTION SYSREQ_EDOC_TYPE_ADD_PARAMS SYSREQ_EDOC_TYPE_COMMENT SYSREQ_EDOC_TYPE_EVENT_TEXT SYSREQ_EDOC_TYPE_NAME_IN_SINGULAR SYSREQ_EDOC_TYPE_NAME_IN_SINGULAR_LOCALIZE_ID SYSREQ_EDOC_TYPE_NAME_LOCALIZE_ID SYSREQ_EDOC_TYPE_NUMERATION_METHOD SYSREQ_EDOC_TYPE_PSEUDO_REQUISITE_CODE SYSREQ_EDOC_TYPE_REQ_CODE SYSREQ_EDOC_TYPE_REQ_DESCRIPTION SYSREQ_EDOC_TYPE_REQ_DESCRIPTION_LOCALIZE_ID SYSREQ_EDOC_TYPE_REQ_IS_LEADING SYSREQ_EDOC_TYPE_REQ_IS_REQUIRED SYSREQ_EDOC_TYPE_REQ_NUMBER SYSREQ_EDOC_TYPE_REQ_ON_CHANGE SYSREQ_EDOC_TYPE_REQ_ON_CHANGE_EXISTS SYSREQ_EDOC_TYPE_REQ_ON_SELECT SYSREQ_EDOC_TYPE_REQ_ON_SELECT_KIND SYSREQ_EDOC_TYPE_REQ_SECTION SYSREQ_EDOC_TYPE_VIEW_CARD SYSREQ_EDOC_TYPE_VIEW_CODE SYSREQ_EDOC_TYPE_VIEW_COMMENT SYSREQ_EDOC_TYPE_VIEW_IS_MAIN SYSREQ_EDOC_TYPE_VIEW_NAME SYSREQ_EDOC_TYPE_VIEW_NAME_LOCALIZE_ID SYSREQ_EDOC_VERSION_AUTHOR SYSREQ_EDOC_VERSION_CRC SYSREQ_EDOC_VERSION_DATA SYSREQ_EDOC_VERSION_EDITOR SYSREQ_EDOC_VERSION_EXPORT_DATE SYSREQ_EDOC_VERSION_EXPORTER SYSREQ_EDOC_VERSION_HIDDEN SYSREQ_EDOC_VERSION_LIFE_STAGE SYSREQ_EDOC_VERSION_MODIFIED SYSREQ_EDOC_VERSION_NOTE SYSREQ_EDOC_VERSION_SIGNATURE_TYPE SYSREQ_EDOC_VERSION_SIGNED SYSREQ_EDOC_VERSION_SIZE SYSREQ_EDOC_VERSION_SOURCE SYSREQ_EDOC_VERSION_TEXT_MODIFIED SYSREQ_EDOCKIND_DEFAULT_VERSION_STATE_CODE SYSREQ_FOLDER_KIND SYSREQ_FUNC_CATEGORY SYSREQ_FUNC_COMMENT SYSREQ_FUNC_GROUP SYSREQ_FUNC_GROUP_COMMENT SYSREQ_FUNC_GROUP_NUMBER SYSREQ_FUNC_HELP SYSREQ_FUNC_PARAM_DEF_VALUE SYSREQ_FUNC_PARAM_IDENT SYSREQ_FUNC_PARAM_NUMBER SYSREQ_FUNC_PARAM_TYPE SYSREQ_FUNC_TEXT SYSREQ_GROUP_CATEGORY SYSREQ_ID SYSREQ_LAST_UPDATE SYSREQ_LEADER_REFERENCE SYSREQ_LINE_NUMBER SYSREQ_MAIN_RECORD_ID SYSREQ_NAME SYSREQ_NAME_LOCALIZE_ID SYSREQ_NOTE SYSREQ_ORIGINAL_RECORD SYSREQ_OUR_FIRM SYSREQ_PROFILING_SETTINGS_BATCH_LOGING SYSREQ_PROFILING_SETTINGS_BATCH_SIZE SYSREQ_PROFILING_SETTINGS_PROFILING_ENABLED SYSREQ_PROFILING_SETTINGS_SQL_PROFILING_ENABLED SYSREQ_PROFILING_SETTINGS_START_LOGGED SYSREQ_RECORD_STATUS SYSREQ_REF_REQ_FIELD_NAME SYSREQ_REF_REQ_FORMAT SYSREQ_REF_REQ_GENERATED SYSREQ_REF_REQ_LENGTH SYSREQ_REF_REQ_PRECISION SYSREQ_REF_REQ_REFERENCE SYSREQ_REF_REQ_SECTION SYSREQ_REF_REQ_STORED SYSREQ_REF_REQ_TOKENS SYSREQ_REF_REQ_TYPE SYSREQ_REF_REQ_VIEW SYSREQ_REF_TYPE_ACT_CODE SYSREQ_REF_TYPE_ACT_DESCRIPTION SYSREQ_REF_TYPE_ACT_DESCRIPTION_LOCALIZE_ID SYSREQ_REF_TYPE_ACT_ON_EXECUTE SYSREQ_REF_TYPE_ACT_ON_EXECUTE_EXISTS SYSREQ_REF_TYPE_ACT_SECTION SYSREQ_REF_TYPE_ADD_PARAMS SYSREQ_REF_TYPE_COMMENT SYSREQ_REF_TYPE_COMMON_SETTINGS SYSREQ_REF_TYPE_DISPLAY_REQUISITE_NAME SYSREQ_REF_TYPE_EVENT_TEXT SYSREQ_REF_TYPE_MAIN_LEADING_REF SYSREQ_REF_TYPE_NAME_IN_SINGULAR SYSREQ_REF_TYPE_NAME_IN_SINGULAR_LOCALIZE_ID SYSREQ_REF_TYPE_NAME_LOCALIZE_ID SYSREQ_REF_TYPE_NUMERATION_METHOD SYSREQ_REF_TYPE_REQ_CODE SYSREQ_REF_TYPE_REQ_DESCRIPTION SYSREQ_REF_TYPE_REQ_DESCRIPTION_LOCALIZE_ID SYSREQ_REF_TYPE_REQ_IS_CONTROL SYSREQ_REF_TYPE_REQ_IS_FILTER SYSREQ_REF_TYPE_REQ_IS_LEADING SYSREQ_REF_TYPE_REQ_IS_REQUIRED SYSREQ_REF_TYPE_REQ_NUMBER SYSREQ_REF_TYPE_REQ_ON_CHANGE SYSREQ_REF_TYPE_REQ_ON_CHANGE_EXISTS SYSREQ_REF_TYPE_REQ_ON_SELECT SYSREQ_REF_TYPE_REQ_ON_SELECT_KIND SYSREQ_REF_TYPE_REQ_SECTION SYSREQ_REF_TYPE_VIEW_CARD SYSREQ_REF_TYPE_VIEW_CODE SYSREQ_REF_TYPE_VIEW_COMMENT SYSREQ_REF_TYPE_VIEW_IS_MAIN SYSREQ_REF_TYPE_VIEW_NAME SYSREQ_REF_TYPE_VIEW_NAME_LOCALIZE_ID SYSREQ_REFERENCE_TYPE_ID SYSREQ_STATE SYSREQ_STAT\u0415 SYSREQ_SYSTEM_SETTINGS_VALUE SYSREQ_TYPE SYSREQ_UNIT SYSREQ_UNIT_ID SYSREQ_USER_GROUPS_GROUP_FULL_NAME SYSREQ_USER_GROUPS_GROUP_NAME SYSREQ_USER_GROUPS_GROUP_SERVER_NAME SYSREQ_USERS_ACCESS_RIGHTS SYSREQ_USERS_AUTHENTICATION SYSREQ_USERS_CATEGORY SYSREQ_USERS_COMPONENT SYSREQ_USERS_COMPONENT_USER_IS_PUBLIC SYSREQ_USERS_DOMAIN SYSREQ_USERS_FULL_USER_NAME SYSREQ_USERS_GROUP SYSREQ_USERS_IS_MAIN_SERVER SYSREQ_USERS_LOGIN SYSREQ_USERS_REFERENCE_USER_IS_PUBLIC SYSREQ_USERS_STATUS SYSREQ_USERS_USER_CERTIFICATE SYSREQ_USERS_USER_CERTIFICATE_INFO SYSREQ_USERS_USER_CERTIFICATE_PLUGIN_NAME SYSREQ_USERS_USER_CERTIFICATE_PLUGIN_VERSION SYSREQ_USERS_USER_CERTIFICATE_STATE SYSREQ_USERS_USER_CERTIFICATE_SUBJECT_NAME SYSREQ_USERS_USER_CERTIFICATE_THUMBPRINT SYSREQ_USERS_USER_DEFAULT_CERTIFICATE SYSREQ_USERS_USER_DESCRIPTION SYSREQ_USERS_USER_GLOBAL_NAME SYSREQ_USERS_USER_LOGIN SYSREQ_USERS_USER_MAIN_SERVER SYSREQ_USERS_USER_TYPE SYSREQ_WORK_RULES_FOLDER_ID RESULT_VAR_NAME RESULT_VAR_NAME_ENG AUTO_NUMERATION_RULE_ID CANT_CHANGE_ID_REQUISITE_RULE_ID CANT_CHANGE_OURFIRM_REQUISITE_RULE_ID CHECK_CHANGING_REFERENCE_RECORD_USE_RULE_ID CHECK_CODE_REQUISITE_RULE_ID CHECK_DELETING_REFERENCE_RECORD_USE_RULE_ID CHECK_FILTRATER_CHANGES_RULE_ID CHECK_RECORD_INTERVAL_RULE_ID CHECK_REFERENCE_INTERVAL_RULE_ID CHECK_REQUIRED_DATA_FULLNESS_RULE_ID CHECK_REQUIRED_REQUISITES_FULLNESS_RULE_ID MAKE_RECORD_UNRATIFIED_RULE_ID RESTORE_AUTO_NUMERATION_RULE_ID SET_FIRM_CONTEXT_FROM_RECORD_RULE_ID SET_FIRST_RECORD_IN_LIST_FORM_RULE_ID SET_IDSPS_VALUE_RULE_ID SET_NEXT_CODE_VALUE_RULE_ID SET_OURFIRM_BOUNDS_RULE_ID SET_OURFIRM_REQUISITE_RULE_ID SCRIPT_BLOCK_AFTER_FINISH_EVENT SCRIPT_BLOCK_BEFORE_START_EVENT SCRIPT_BLOCK_EXECUTION_RESULTS_PROPERTY SCRIPT_BLOCK_NAME_PROPERTY SCRIPT_BLOCK_SCRIPT_PROPERTY SUBTASK_BLOCK_ABORT_DEADLINE_PROPERTY SUBTASK_BLOCK_AFTER_FINISH_EVENT SUBTASK_BLOCK_ASSIGN_PARAMS_EVENT SUBTASK_BLOCK_ATTACHMENTS_PROPERTY SUBTASK_BLOCK_ATTACHMENTS_RIGHTS_GROUP_PROPERTY SUBTASK_BLOCK_ATTACHMENTS_RIGHTS_TYPE_PROPERTY SUBTASK_BLOCK_BEFORE_START_EVENT SUBTASK_BLOCK_CREATED_TASK_PROPERTY SUBTASK_BLOCK_CREATION_EVENT SUBTASK_BLOCK_DEADLINE_PROPERTY SUBTASK_BLOCK_IMPORTANCE_PROPERTY SUBTASK_BLOCK_INITIATOR_PROPERTY SUBTASK_BLOCK_IS_RELATIVE_ABORT_DEADLINE_PROPERTY SUBTASK_BLOCK_IS_RELATIVE_DEADLINE_PROPERTY SUBTASK_BLOCK_JOBS_TYPE_PROPERTY SUBTASK_BLOCK_NAME_PROPERTY SUBTASK_BLOCK_PARALLEL_ROUTE_PROPERTY SUBTASK_BLOCK_PERFORMERS_PROPERTY SUBTASK_BLOCK_RELATIVE_ABORT_DEADLINE_TYPE_PROPERTY SUBTASK_BLOCK_RELATIVE_DEADLINE_TYPE_PROPERTY SUBTASK_BLOCK_REQUIRE_SIGN_PROPERTY SUBTASK_BLOCK_STANDARD_ROUTE_PROPERTY SUBTASK_BLOCK_START_EVENT SUBTASK_BLOCK_STEP_CONTROL_PROPERTY SUBTASK_BLOCK_SUBJECT_PROPERTY SUBTASK_BLOCK_TASK_CONTROL_PROPERTY SUBTASK_BLOCK_TEXT_PROPERTY SUBTASK_BLOCK_UNLOCK_ATTACHMENTS_ON_STOP_PROPERTY SUBTASK_BLOCK_USE_STANDARD_ROUTE_PROPERTY SUBTASK_BLOCK_WAIT_FOR_TASK_COMPLETE_PROPERTY SYSCOMP_CONTROL_JOBS SYSCOMP_FOLDERS SYSCOMP_JOBS SYSCOMP_NOTICES SYSCOMP_TASKS SYSDLG_CREATE_EDOCUMENT SYSDLG_CREATE_EDOCUMENT_VERSION SYSDLG_CURRENT_PERIOD SYSDLG_EDIT_FUNCTION_HELP SYSDLG_EDOCUMENT_KINDS_FOR_TEMPLATE SYSDLG_EXPORT_MULTIPLE_EDOCUMENTS SYSDLG_EXPORT_SINGLE_EDOCUMENT SYSDLG_IMPORT_EDOCUMENT SYSDLG_MULTIPLE_SELECT SYSDLG_SETUP_ACCESS_RIGHTS SYSDLG_SETUP_DEFAULT_RIGHTS SYSDLG_SETUP_FILTER_CONDITION SYSDLG_SETUP_SIGN_RIGHTS SYSDLG_SETUP_TASK_OBSERVERS SYSDLG_SETUP_TASK_ROUTE SYSDLG_SETUP_USERS_LIST SYSDLG_SIGN_EDOCUMENT SYSDLG_SIGN_MULTIPLE_EDOCUMENTS SYSREF_ACCESS_RIGHTS_TYPES SYSREF_ADMINISTRATION_HISTORY SYSREF_ALL_AVAILABLE_COMPONENTS SYSREF_ALL_AVAILABLE_PRIVILEGES SYSREF_ALL_REPLICATING_COMPONENTS SYSREF_AVAILABLE_DEVELOPERS_COMPONENTS SYSREF_CALENDAR_EVENTS SYSREF_COMPONENT_TOKEN_HISTORY SYSREF_COMPONENT_TOKENS SYSREF_COMPONENTS SYSREF_CONSTANTS SYSREF_DATA_RECEIVE_PROTOCOL SYSREF_DATA_SEND_PROTOCOL SYSREF_DIALOGS SYSREF_DIALOGS_REQUISITES SYSREF_EDITORS SYSREF_EDOC_CARDS SYSREF_EDOC_TYPES SYSREF_EDOCUMENT_CARD_REQUISITES SYSREF_EDOCUMENT_CARD_TYPES SYSREF_EDOCUMENT_CARD_TYPES_REFERENCE SYSREF_EDOCUMENT_CARDS SYSREF_EDOCUMENT_HISTORY SYSREF_EDOCUMENT_KINDS SYSREF_EDOCUMENT_REQUISITES SYSREF_EDOCUMENT_SIGNATURES SYSREF_EDOCUMENT_TEMPLATES SYSREF_EDOCUMENT_TEXT_STORAGES SYSREF_EDOCUMENT_VIEWS SYSREF_FILTERER_SETUP_CONFLICTS SYSREF_FILTRATER_SETTING_CONFLICTS SYSREF_FOLDER_HISTORY SYSREF_FOLDERS SYSREF_FUNCTION_GROUPS SYSREF_FUNCTION_PARAMS SYSREF_FUNCTIONS SYSREF_JOB_HISTORY SYSREF_LINKS SYSREF_LOCALIZATION_DICTIONARY SYSREF_LOCALIZATION_LANGUAGES SYSREF_MODULES SYSREF_PRIVILEGES SYSREF_RECORD_HISTORY SYSREF_REFERENCE_REQUISITES SYSREF_REFERENCE_TYPE_VIEWS SYSREF_REFERENCE_TYPES SYSREF_REFERENCES SYSREF_REFERENCES_REQUISITES SYSREF_REMOTE_SERVERS SYSREF_REPLICATION_SESSIONS_LOG SYSREF_REPLICATION_SESSIONS_PROTOCOL SYSREF_REPORTS SYSREF_ROLES SYSREF_ROUTE_BLOCK_GROUPS SYSREF_ROUTE_BLOCKS SYSREF_SCRIPTS SYSREF_SEARCHES SYSREF_SERVER_EVENTS SYSREF_SERVER_EVENTS_HISTORY SYSREF_STANDARD_ROUTE_GROUPS SYSREF_STANDARD_ROUTES SYSREF_STATUSES SYSREF_SYSTEM_SETTINGS SYSREF_TASK_HISTORY SYSREF_TASK_KIND_GROUPS SYSREF_TASK_KINDS SYSREF_TASK_RIGHTS SYSREF_TASK_SIGNATURES SYSREF_TASKS SYSREF_UNITS SYSREF_USER_GROUPS SYSREF_USER_GROUPS_REFERENCE SYSREF_USER_SUBSTITUTION SYSREF_USERS SYSREF_USERS_REFERENCE SYSREF_VIEWERS SYSREF_WORKING_TIME_CALENDARS ACCESS_RIGHTS_TABLE_NAME EDMS_ACCESS_TABLE_NAME EDOC_TYPES_TABLE_NAME TEST_DEV_DB_NAME TEST_DEV_SYSTEM_CODE TEST_EDMS_DB_NAME TEST_EDMS_MAIN_CODE TEST_EDMS_MAIN_DB_NAME TEST_EDMS_SECOND_CODE TEST_EDMS_SECOND_DB_NAME TEST_EDMS_SYSTEM_CODE TEST_ISB5_MAIN_CODE TEST_ISB5_SECOND_CODE TEST_SQL_SERVER_2005_NAME TEST_SQL_SERVER_NAME ATTENTION_CAPTION cbsCommandLinks cbsDefault CONFIRMATION_CAPTION ERROR_CAPTION INFORMATION_CAPTION mrCancel mrOk EDOC_VERSION_ACTIVE_STAGE_CODE EDOC_VERSION_DESIGN_STAGE_CODE EDOC_VERSION_OBSOLETE_STAGE_CODE cpDataEnciphermentEnabled cpDigitalSignatureEnabled cpID cpIssuer cpPluginVersion cpSerial cpSubjectName cpSubjSimpleName cpValidFromDate cpValidToDate ISBL_SYNTAX NO_SYNTAX XML_SYNTAX WAIT_BLOCK_AFTER_FINISH_EVENT WAIT_BLOCK_BEFORE_START_EVENT WAIT_BLOCK_DEADLINE_PROPERTY WAIT_BLOCK_IS_RELATIVE_DEADLINE_PROPERTY WAIT_BLOCK_NAME_PROPERTY WAIT_BLOCK_RELATIVE_DEADLINE_TYPE_PROPERTY SYSRES_COMMON SYSRES_CONST SYSRES_MBFUNC SYSRES_SBDATA SYSRES_SBGUI SYSRES_SBINTF SYSRES_SBREFDSC SYSRES_SQLERRORS SYSRES_SYSCOMP atUser atGroup atRole aemEnabledAlways aemDisabledAlways aemEnabledOnBrowse aemEnabledOnEdit aemDisabledOnBrowseEmpty apBegin apEnd alLeft alRight asmNever asmNoButCustomize asmAsLastTime asmYesButCustomize asmAlways cirCommon cirRevoked ctSignature ctEncode ctSignatureEncode clbUnchecked clbChecked clbGrayed ceISB ceAlways ceNever ctDocument ctReference ctScript ctUnknown ctReport ctDialog ctFunction ctFolder ctEDocument ctTask ctJob ctNotice ctControlJob cfInternal cfDisplay ciUnspecified ciWrite ciRead ckFolder ckEDocument ckTask ckJob ckComponentToken ckAny ckReference ckScript ckReport ckDialog ctISBLEditor ctBevel ctButton ctCheckListBox ctComboBox ctComboEdit ctGrid ctDBCheckBox ctDBComboBox ctDBEdit ctDBEllipsis ctDBMemo ctDBNavigator ctDBRadioGroup ctDBStatusLabel ctEdit ctGroupBox ctInplaceHint ctMemo ctPanel ctListBox ctRadioButton ctRichEdit ctTabSheet ctWebBrowser ctImage ctHyperLink ctLabel ctDBMultiEllipsis ctRibbon ctRichView ctInnerPanel ctPanelGroup ctBitButton cctDate cctInteger cctNumeric cctPick cctReference cctString cctText cltInternal cltPrimary cltGUI dseBeforeOpen dseAfterOpen dseBeforeClose dseAfterClose dseOnValidDelete dseBeforeDelete dseAfterDelete dseAfterDeleteOutOfTransaction dseOnDeleteError dseBeforeInsert dseAfterInsert dseOnValidUpdate dseBeforeUpdate dseOnUpdateRatifiedRecord dseAfterUpdate dseAfterUpdateOutOfTransaction dseOnUpdateError dseAfterScroll dseOnOpenRecord dseOnCloseRecord dseBeforeCancel dseAfterCancel dseOnUpdateDeadlockError dseBeforeDetailUpdate dseOnPrepareUpdate dseOnAnyRequisiteChange dssEdit dssInsert dssBrowse dssInActive dftDate dftShortDate dftDateTime dftTimeStamp dotDays dotHours dotMinutes dotSeconds dtkndLocal dtkndUTC arNone arView arEdit arFull ddaView ddaEdit emLock emEdit emSign emExportWithLock emImportWithUnlock emChangeVersionNote emOpenForModify emChangeLifeStage emDelete emCreateVersion emImport emUnlockExportedWithLock emStart emAbort emReInit emMarkAsReaded emMarkAsUnreaded emPerform emAccept emResume emChangeRights emEditRoute emEditObserver emRecoveryFromLocalCopy emChangeWorkAccessType emChangeEncodeTypeToCertificate emChangeEncodeTypeToPassword emChangeEncodeTypeToNone emChangeEncodeTypeToCertificatePassword emChangeStandardRoute emGetText emOpenForView emMoveToStorage emCreateObject emChangeVersionHidden emDeleteVersion emChangeLifeCycleStage emApprovingSign emExport emContinue emLockFromEdit emUnLockForEdit emLockForServer emUnlockFromServer emDelegateAccessRights emReEncode ecotFile ecotProcess eaGet eaCopy eaCreate eaCreateStandardRoute edltAll edltNothing edltQuery essmText essmCard esvtLast esvtLastActive esvtSpecified edsfExecutive edsfArchive edstSQLServer edstFile edvstNone edvstEDocumentVersionCopy edvstFile edvstTemplate edvstScannedFile vsDefault vsDesign vsActive vsObsolete etNone etCertificate etPassword etCertificatePassword ecException ecWarning ecInformation estAll estApprovingOnly evtLast evtLastActive evtQuery fdtString fdtNumeric fdtInteger fdtDate fdtText fdtUnknown fdtWideString fdtLargeInteger ftInbox ftOutbox ftFavorites ftCommonFolder ftUserFolder ftComponents ftQuickLaunch ftShortcuts ftSearch grhAuto grhX1 grhX2 grhX3 hltText hltRTF hltHTML iffBMP iffJPEG iffMultiPageTIFF iffSinglePageTIFF iffTIFF iffPNG im8bGrayscale im24bRGB im1bMonochrome itBMP itJPEG itWMF itPNG ikhInformation ikhWarning ikhError ikhNoIcon icUnknown icScript icFunction icIntegratedReport icAnalyticReport icDataSetEventHandler icActionHandler icFormEventHandler icLookUpEventHandler icRequisiteChangeEventHandler icBeforeSearchEventHandler icRoleCalculation icSelectRouteEventHandler icBlockPropertyCalculation icBlockQueryParamsEventHandler icChangeSearchResultEventHandler icBlockEventHandler icSubTaskInitEventHandler icEDocDataSetEventHandler icEDocLookUpEventHandler icEDocActionHandler icEDocFormEventHandler icEDocRequisiteChangeEventHandler icStructuredConversionRule icStructuredConversionEventBefore icStructuredConversionEventAfter icWizardEventHandler icWizardFinishEventHandler icWizardStepEventHandler icWizardStepFinishEventHandler icWizardActionEnableEventHandler icWizardActionExecuteEventHandler icCreateJobsHandler icCreateNoticesHandler icBeforeLookUpEventHandler icAfterLookUpEventHandler icTaskAbortEventHandler icWorkflowBlockActionHandler icDialogDataSetEventHandler icDialogActionHandler icDialogLookUpEventHandler icDialogRequisiteChangeEventHandler icDialogFormEventHandler icDialogValidCloseEventHandler icBlockFormEventHandler icTaskFormEventHandler icReferenceMethod icEDocMethod icDialogMethod icProcessMessageHandler isShow isHide isByUserSettings jkJob jkNotice jkControlJob jtInner jtLeft jtRight jtFull jtCross lbpAbove lbpBelow lbpLeft lbpRight eltPerConnection eltPerUser sfcUndefined sfcBlack sfcGreen sfcRed sfcBlue sfcOrange sfcLilac sfsItalic sfsStrikeout sfsNormal ldctStandardRoute ldctWizard ldctScript ldctFunction ldctRouteBlock ldctIntegratedReport ldctAnalyticReport ldctReferenceType ldctEDocumentType ldctDialog ldctServerEvents mrcrtNone mrcrtUser mrcrtMaximal mrcrtCustom vtEqual vtGreaterOrEqual vtLessOrEqual vtRange rdYesterday rdToday rdTomorrow rdThisWeek rdThisMonth rdThisYear rdNextMonth rdNextWeek rdLastWeek rdLastMonth rdWindow rdFile rdPrinter rdtString rdtNumeric rdtInteger rdtDate rdtReference rdtAccount rdtText rdtPick rdtUnknown rdtLargeInteger rdtDocument reOnChange reOnChangeValues ttGlobal ttLocal ttUser ttSystem ssmBrowse ssmSelect ssmMultiSelect ssmBrowseModal smSelect smLike smCard stNone stAuthenticating stApproving sctString sctStream sstAnsiSort sstNaturalSort svtEqual svtContain soatString soatNumeric soatInteger soatDatetime soatReferenceRecord soatText soatPick soatBoolean soatEDocument soatAccount soatIntegerCollection soatNumericCollection soatStringCollection soatPickCollection soatDatetimeCollection soatBooleanCollection soatReferenceRecordCollection soatEDocumentCollection soatAccountCollection soatContents soatUnknown tarAbortByUser tarAbortByWorkflowException tvtAllWords tvtExactPhrase tvtAnyWord usNone usCompleted usRedSquare usBlueSquare usYellowSquare usGreenSquare usOrangeSquare usPurpleSquare usFollowUp utUnknown utUser utDeveloper utAdministrator utSystemDeveloper utDisconnected btAnd btDetailAnd btOr btNotOr btOnly vmView vmSelect vmNavigation vsmSingle vsmMultiple vsmMultipleCheck vsmNoSelection wfatPrevious wfatNext wfatCancel wfatFinish wfepUndefined wfepText3 wfepText6 wfepText9 wfepSpinEdit wfepDropDown wfepRadioGroup wfepFlag wfepText12 wfepText15 wfepText18 wfepText21 wfepText24 wfepText27 wfepText30 wfepRadioGroupColumn1 wfepRadioGroupColumn2 wfepRadioGroupColumn3 wfetQueryParameter wfetText wfetDelimiter wfetLabel wptString wptInteger wptNumeric wptBoolean wptDateTime wptPick wptText wptUser wptUserList wptEDocumentInfo wptEDocumentInfoList wptReferenceRecordInfo wptReferenceRecordInfoList wptFolderInfo wptTaskInfo wptContents wptFileName wptDate wsrComplete wsrGoNext wsrGoPrevious wsrCustom wsrCancel wsrGoFinal wstForm wstEDocument wstTaskCard wstReferenceRecordCard wstFinal waAll waPerformers waManual wsbStart wsbFinish wsbNotice wsbStep wsbDecision wsbWait wsbMonitor wsbScript wsbConnector wsbSubTask wsbLifeCycleStage wsbPause wdtInteger wdtFloat wdtString wdtPick wdtDateTime wdtBoolean wdtTask wdtJob wdtFolder wdtEDocument wdtReferenceRecord wdtUser wdtGroup wdtRole wdtIntegerCollection wdtFloatCollection wdtStringCollection wdtPickCollection wdtDateTimeCollection wdtBooleanCollection wdtTaskCollection wdtJobCollection wdtFolderCollection wdtEDocumentCollection wdtReferenceRecordCollection wdtUserCollection wdtGroupCollection wdtRoleCollection wdtContents wdtUserList wdtSearchDescription wdtDeadLine wdtPickSet wdtAccountCollection wiLow wiNormal wiHigh wrtSoft wrtHard wsInit wsRunning wsDone wsControlled wsAborted wsContinued wtmFull wtmFromCurrent wtmOnlyCurrent ", +"class":"AltState Application CallType ComponentTokens CreatedJobs CreatedNotices ControlState DialogResult Dialogs EDocuments EDocumentVersionSource Folders GlobalIDs Job Jobs InputValue LookUpReference LookUpRequisiteNames LookUpSearch Object ParentComponent Processes References Requisite ReportName Reports Result Scripts Searches SelectedAttachments SelectedItems SelectMode Sender ServerEvents ServiceFactory ShiftState SubTask SystemDialogs Tasks Wizard Wizards Work \u0412\u044b\u0437\u043e\u0432\u0421\u043f\u043e\u0441\u043e\u0431 \u0418\u043c\u044f\u041e\u0442\u0447\u0435\u0442\u0430 \u0420\u0435\u043a\u0432\u0417\u043d\u0430\u0447 ",literal:"null true false nil "},s={begin:"\\.\\s*"+e.UNDERSCORE_IDENT_RE,keywords:o,relevance:0},l={className:"type",begin:":[ \\t]*("+"IApplication IAccessRights IAccountRepository IAccountSelectionRestrictions IAction IActionList IAdministrationHistoryDescription IAnchors IApplication IArchiveInfo IAttachment IAttachmentList ICheckListBox ICheckPointedList IColumn IComponent IComponentDescription IComponentToken IComponentTokenFactory IComponentTokenInfo ICompRecordInfo IConnection IContents IControl IControlJob IControlJobInfo IControlList ICrypto ICrypto2 ICustomJob ICustomJobInfo ICustomListBox ICustomObjectWizardStep ICustomWork ICustomWorkInfo IDataSet IDataSetAccessInfo IDataSigner IDateCriterion IDateRequisite IDateRequisiteDescription IDateValue IDeaAccessRights IDeaObjectInfo IDevelopmentComponentLock IDialog IDialogFactory IDialogPickRequisiteItems IDialogsFactory IDICSFactory IDocRequisite IDocumentInfo IDualListDialog IECertificate IECertificateInfo IECertificates IEditControl IEditorForm IEdmsExplorer IEdmsObject IEdmsObjectDescription IEdmsObjectFactory IEdmsObjectInfo IEDocument IEDocumentAccessRights IEDocumentDescription IEDocumentEditor IEDocumentFactory IEDocumentInfo IEDocumentStorage IEDocumentVersion IEDocumentVersionListDialog IEDocumentVersionSource IEDocumentWizardStep IEDocVerSignature IEDocVersionState IEnabledMode IEncodeProvider IEncrypter IEvent IEventList IException IExternalEvents IExternalHandler IFactory IField IFileDialog IFolder IFolderDescription IFolderDialog IFolderFactory IFolderInfo IForEach IForm IFormTitle IFormWizardStep IGlobalIDFactory IGlobalIDInfo IGrid IHasher IHistoryDescription IHyperLinkControl IImageButton IImageControl IInnerPanel IInplaceHint IIntegerCriterion IIntegerList IIntegerRequisite IIntegerValue IISBLEditorForm IJob IJobDescription IJobFactory IJobForm IJobInfo ILabelControl ILargeIntegerCriterion ILargeIntegerRequisite ILargeIntegerValue ILicenseInfo ILifeCycleStage IList IListBox ILocalIDInfo ILocalization ILock IMemoryDataSet IMessagingFactory IMetadataRepository INotice INoticeInfo INumericCriterion INumericRequisite INumericValue IObject IObjectDescription IObjectImporter IObjectInfo IObserver IPanelGroup IPickCriterion IPickProperty IPickRequisite IPickRequisiteDescription IPickRequisiteItem IPickRequisiteItems IPickValue IPrivilege IPrivilegeList IProcess IProcessFactory IProcessMessage IProgress IProperty IPropertyChangeEvent IQuery IReference IReferenceCriterion IReferenceEnabledMode IReferenceFactory IReferenceHistoryDescription IReferenceInfo IReferenceRecordCardWizardStep IReferenceRequisiteDescription IReferencesFactory IReferenceValue IRefRequisite IReport IReportFactory IRequisite IRequisiteDescription IRequisiteDescriptionList IRequisiteFactory IRichEdit IRouteStep IRule IRuleList ISchemeBlock IScript IScriptFactory ISearchCriteria ISearchCriterion ISearchDescription ISearchFactory ISearchFolderInfo ISearchForObjectDescription ISearchResultRestrictions ISecuredContext ISelectDialog IServerEvent IServerEventFactory IServiceDialog IServiceFactory ISignature ISignProvider ISignProvider2 ISignProvider3 ISimpleCriterion IStringCriterion IStringList IStringRequisite IStringRequisiteDescription IStringValue ISystemDialogsFactory ISystemInfo ITabSheet ITask ITaskAbortReasonInfo ITaskCardWizardStep ITaskDescription ITaskFactory ITaskInfo ITaskRoute ITextCriterion ITextRequisite ITextValue ITreeListSelectDialog IUser IUserList IValue IView IWebBrowserControl IWizard IWizardAction IWizardFactory IWizardFormElement IWizardParam IWizardPickParam IWizardReferenceParam IWizardStep IWorkAccessRights IWorkDescription IWorkflowAskableParam IWorkflowAskableParams IWorkflowBlock IWorkflowBlockResult IWorkflowEnabledMode IWorkflowParam IWorkflowPickParam IWorkflowReferenceParam IWorkState IWorkTreeCustomNode IWorkTreeJobNode IWorkTreeTaskNode IXMLEditorForm SBCrypto ".trim().replace(/\s/g,"|")+")",end:"[ \\t]*=",excludeEnd:!0},_={className:"variable",lexemes:t,keywords:o,begin:t,relevance:0,contains:[l,s]};return{name:"ISBL",aliases:["isbl"],case_insensitive:!0,lexemes:t,keywords:o,illegal:"\\$|\\?|%|,|;$|~|#|@|)?\\s+)+"+e.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,end:/[{;=]/,excludeEnd:!0,keywords:t,contains:[{begin:e.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,relevance:0,contains:[e.UNDERSCORE_TITLE_MODE]},{className:"params",begin:/\(/,end:/\)/,keywords:t,relevance:0,contains:[a,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,e.C_NUMBER_MODE,e.C_BLOCK_COMMENT_MODE]},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},n,a]}},Oo=function(e){var t="<>",a="",n={begin:/<[A-Za-z0-9\\._:-]+/,end:/\/[A-Za-z0-9\\._:-]+>|\/>/},r="[A-Za-z$_][0-9A-Za-z$_]*",i={keyword:"in of if for while finally var new function do return void else break catch instanceof with throw case default try this switch continue typeof delete let yield const export super debugger as async await static import from as",literal:"true false null undefined NaN Infinity",built_in:"eval isFinite isNaN parseFloat parseInt decodeURI decodeURIComponent encodeURI encodeURIComponent escape unescape Object Function Boolean Error EvalError InternalError RangeError ReferenceError StopIteration SyntaxError TypeError URIError Number Math Date String RegExp Array Float32Array Float64Array Int16Array Int32Array Int8Array Uint16Array Uint32Array Uint8Array Uint8ClampedArray ArrayBuffer DataView JSON Intl arguments require module console window document Symbol Set Map WeakSet WeakMap Proxy Reflect Promise"},o={className:"number",variants:[{begin:"\\b(0[bB][01]+)n?"},{begin:"\\b(0[oO][0-7]+)n?"},{begin:e.C_NUMBER_RE+"n?"}],relevance:0},s={className:"subst",begin:"\\$\\{",end:"\\}",keywords:i,contains:[]},l={begin:"html`",end:"",starts:{end:"`",returnEnd:!1,contains:[e.BACKSLASH_ESCAPE,s],subLanguage:"xml"}},_={begin:"css`",end:"",starts:{end:"`",returnEnd:!1,contains:[e.BACKSLASH_ESCAPE,s],subLanguage:"css"}},c={className:"string",begin:"`",end:"`",contains:[e.BACKSLASH_ESCAPE,s]};s.contains=[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,l,_,c,o,e.REGEXP_MODE];var d=s.contains.concat([e.C_BLOCK_COMMENT_MODE,e.C_LINE_COMMENT_MODE]),u={className:"params",begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,contains:d};return{name:"JavaScript",aliases:["js","jsx","mjs","cjs"],keywords:i,contains:[{className:"meta",relevance:10,begin:/^\s*['"]use (strict|asm)['"]/},{className:"meta",begin:/^#!/,end:/$/},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,l,_,c,e.C_LINE_COMMENT_MODE,e.COMMENT("/\\*\\*","\\*/",{relevance:0,contains:[{className:"doctag",begin:"@[A-Za-z]+",contains:[{className:"type",begin:"\\{",end:"\\}",relevance:0},{className:"variable",begin:r+"(?=\\s*(-)|$)",endsParent:!0,relevance:0},{begin:/(?=[^\n])\s/,relevance:0}]}]}),e.C_BLOCK_COMMENT_MODE,o,{begin:/[{,\n]\s*/,relevance:0,contains:[{begin:r+"\\s*:",returnBegin:!0,relevance:0,contains:[{className:"attr",begin:r,relevance:0}]}]},{begin:"("+e.RE_STARTERS_RE+"|\\b(case|return|throw)\\b)\\s*",keywords:"return throw case",contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.REGEXP_MODE,{className:"function",begin:"(\\(.*?\\)|"+r+")\\s*=>",returnBegin:!0,end:"\\s*=>",contains:[{className:"params",variants:[{begin:r},{begin:/\(\s*\)/},{begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:i,contains:d}]}]},{begin:/,/,relevance:0},{className:"",begin:/\s/,end:/\s*/,skip:!0},{variants:[{begin:t,end:a},{begin:n.begin,end:n.end}],subLanguage:"xml",contains:[{begin:n.begin,end:n.end,skip:!0,contains:["self"]}]}],relevance:0},{className:"function",beginKeywords:"function",end:/\{/,excludeEnd:!0,contains:[e.inherit(e.TITLE_MODE,{begin:r}),u],illegal:/\[|%/},{begin:/\$[(.]/},e.METHOD_GUARD,{className:"class",beginKeywords:"class",end:/[{;=]/,excludeEnd:!0,illegal:/[:"\[\]]/,contains:[{beginKeywords:"extends"},e.UNDERSCORE_TITLE_MODE]},{beginKeywords:"constructor",end:/\{/,excludeEnd:!0},{begin:"(get|set)\\s+(?="+r+"\\()",end:/{/,keywords:"get set",contains:[e.inherit(e.TITLE_MODE,{begin:r}),{begin:/\(\)/},u]}],illegal:/#(?!!)/}},vo=function(e){var t={className:"params",begin:/\(/,end:/\)/,contains:[{begin:/[\w-]+ *=/,returnBegin:!0,relevance:0,contains:[{className:"attr",begin:/[\w-]+/}]}],relevance:0};return{name:"JBoss CLI",aliases:["wildfly-cli"],lexemes:"[a-z-]+",keywords:{keyword:"alias batch cd clear command connect connection-factory connection-info data-source deploy deployment-info deployment-overlay echo echo-dmr help history if jdbc-driver-info jms-queue|20 jms-topic|20 ls patch pwd quit read-attribute read-operation reload rollout-plan run-batch set shutdown try unalias undeploy unset version xa-data-source",literal:"true false"},contains:[e.HASH_COMMENT_MODE,e.QUOTE_STRING_MODE,{className:"params",begin:/--[\w\-=\/]+/},{className:"function",begin:/:[\w\-.]+/,relevance:0},{className:"string",begin:/\B(([\/.])[\w\-.\/=]+)+/},t]}},Io=function(e){var t={literal:"true false null"},a=[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE],n=[e.QUOTE_STRING_MODE,e.C_NUMBER_MODE],r={end:",",endsWithParent:!0,excludeEnd:!0,contains:n,keywords:t},i={begin:"{",end:"}",contains:[{className:"attr",begin:/"/,end:/"/,contains:[e.BACKSLASH_ESCAPE],illegal:"\\n"},e.inherit(r,{begin:/:/})].concat(a),illegal:"\\S"},o={begin:"\\[",end:"\\]",contains:[e.inherit(r)],illegal:"\\S"};return n.push(i,o),a.forEach(function(e){n.push(e)}),{name:"JSON",contains:n,keywords:t,illegal:"\\S"}},ho=function(e){var t={keyword:"in isa where baremodule begin break catch ccall const continue do else elseif end export false finally for function global if import importall let local macro module quote return true try using while type immutable abstract bitstype typealias ",literal:"true false ARGS C_NULL DevNull ENDIAN_BOM ENV I Inf Inf16 Inf32 Inf64 InsertionSort JULIA_HOME LOAD_PATH MergeSort NaN NaN16 NaN32 NaN64 PROGRAM_FILE QuickSort RoundDown RoundFromZero RoundNearest RoundNearestTiesAway RoundNearestTiesUp RoundToZero RoundUp STDERR STDIN STDOUT VERSION catalan e|0 eu|0 eulergamma golden im nothing pi \u03b3 \u03c0 \u03c6 ", +built_in:"ANY AbstractArray AbstractChannel AbstractFloat AbstractMatrix AbstractRNG AbstractSerializer AbstractSet AbstractSparseArray AbstractSparseMatrix AbstractSparseVector AbstractString AbstractUnitRange AbstractVecOrMat AbstractVector Any ArgumentError Array AssertionError Associative Base64DecodePipe Base64EncodePipe Bidiagonal BigFloat BigInt BitArray BitMatrix BitVector Bool BoundsError BufferStream CachingPool CapturedException CartesianIndex CartesianRange Cchar Cdouble Cfloat Channel Char Cint Cintmax_t Clong Clonglong ClusterManager Cmd CodeInfo Colon Complex Complex128 Complex32 Complex64 CompositeException Condition ConjArray ConjMatrix ConjVector Cptrdiff_t Cshort Csize_t Cssize_t Cstring Cuchar Cuint Cuintmax_t Culong Culonglong Cushort Cwchar_t Cwstring DataType Date DateFormat DateTime DenseArray DenseMatrix DenseVecOrMat DenseVector Diagonal Dict DimensionMismatch Dims DirectIndexString Display DivideError DomainError EOFError EachLine Enum Enumerate ErrorException Exception ExponentialBackOff Expr Factorization FileMonitor Float16 Float32 Float64 Function Future GlobalRef GotoNode HTML Hermitian IO IOBuffer IOContext IOStream IPAddr IPv4 IPv6 IndexCartesian IndexLinear IndexStyle InexactError InitError Int Int128 Int16 Int32 Int64 Int8 IntSet Integer InterruptException InvalidStateException Irrational KeyError LabelNode LinSpace LineNumberNode LoadError LowerTriangular MIME Matrix MersenneTwister Method MethodError MethodTable Module NTuple NewvarNode NullException Nullable Number ObjectIdDict OrdinalRange OutOfMemoryError OverflowError Pair ParseError PartialQuickSort PermutedDimsArray Pipe PollingFileWatcher ProcessExitedException Ptr QuoteNode RandomDevice Range RangeIndex Rational RawFD ReadOnlyMemoryError Real ReentrantLock Ref Regex RegexMatch RemoteChannel RemoteException RevString RoundingMode RowVector SSAValue SegmentationFault SerializationState Set SharedArray SharedMatrix SharedVector Signed SimpleVector Slot SlotNumber SparseMatrixCSC SparseVector StackFrame StackOverflowError StackTrace StepRange StepRangeLen StridedArray StridedMatrix StridedVecOrMat StridedVector String SubArray SubString SymTridiagonal Symbol Symmetric SystemError TCPSocket Task Text TextDisplay Timer Tridiagonal Tuple Type TypeError TypeMapEntry TypeMapLevel TypeName TypeVar TypedSlot UDPSocket UInt UInt128 UInt16 UInt32 UInt64 UInt8 UndefRefError UndefVarError UnicodeError UniformScaling Union UnionAll UnitRange Unsigned UpperTriangular Val Vararg VecElement VecOrMat Vector VersionNumber Void WeakKeyDict WeakRef WorkerConfig WorkerPool "},a="[A-Za-z_\\u00A1-\\uFFFF][A-Za-z_0-9\\u00A1-\\uFFFF]*",n={lexemes:a,keywords:t,illegal:/<\//},r={className:"subst",begin:/\$\(/,end:/\)/,keywords:t},i={className:"variable",begin:"\\$"+a},o={className:"string",contains:[e.BACKSLASH_ESCAPE,r,i],variants:[{begin:/\w*"""/,end:/"""\w*/,relevance:10},{begin:/\w*"/,end:/"\w*/}]},s={className:"string",contains:[e.BACKSLASH_ESCAPE,r,i],begin:"`",end:"`"},l={className:"meta",begin:"@"+a};return n.name="Julia",n.contains=[{className:"number",begin:/(\b0x[\d_]*(\.[\d_]*)?|0x\.\d[\d_]*)p[-+]?\d+|\b0[box][a-fA-F0-9][a-fA-F0-9_]*|(\b\d[\d_]*(\.[\d_]*)?|\.\d[\d_]*)([eEfF][-+]?\d+)?/,relevance:0},{className:"string",begin:/'(.|\\[xXuU][a-zA-Z0-9]+)'/},o,s,l,{className:"comment",variants:[{begin:"#=",end:"=#",relevance:10},{begin:"#",end:"$"}]},e.HASH_COMMENT_MODE,{className:"keyword",begin:"\\b(((abstract|primitive)\\s+)type|(mutable\\s+)?struct)\\b"},{begin:/<:/}],r.contains=n.contains,n},Ao=function(){return{name:"Julia REPL",contains:[{className:"meta",begin:/^julia>/,relevance:10,starts:{end:/^(?![ ]{6})/,subLanguage:"julia"},aliases:["jldoctest"]}]}},yo=function(e){var t={keyword:"abstract as val var vararg get set class object open private protected public noinline crossinline dynamic final enum if else do while for when throw try catch finally import package is in fun override companion reified inline lateinit init interface annotation data sealed internal infix operator out by constructor super tailrec where const inner suspend typealias external expect actual trait volatile transient native default",built_in:"Byte Short Char Int Long Boolean Float Double Void Unit Nothing",literal:"true false null"},a={className:"symbol",begin:e.UNDERSCORE_IDENT_RE+"@"},n={className:"subst",begin:"\\${",end:"}",contains:[e.C_NUMBER_MODE]},r={className:"variable",begin:"\\$"+e.UNDERSCORE_IDENT_RE},i={className:"string",variants:[{begin:'"""',end:'"""(?=[^"])',contains:[r,n]},{begin:"'",end:"'",illegal:/\n/,contains:[e.BACKSLASH_ESCAPE]},{begin:'"',end:'"',illegal:/\n/,contains:[e.BACKSLASH_ESCAPE,r,n]}]};n.contains.push(i);var o={className:"meta",begin:"@(?:file|property|field|get|set|receiver|param|setparam|delegate)\\s*:(?:\\s*"+e.UNDERSCORE_IDENT_RE+")?"},s={className:"meta",begin:"@"+e.UNDERSCORE_IDENT_RE,contains:[{begin:/\(/,end:/\)/,contains:[e.inherit(i,{className:"meta-string"})]}]},l={className:"number",begin:"\\b(0[bB]([01]+[01_]+[01]+|[01]+)|0[xX]([a-fA-F0-9]+[a-fA-F0-9_]+[a-fA-F0-9]+|[a-fA-F0-9]+)|(([\\d]+[\\d_]+[\\d]+|[\\d]+)(\\.([\\d]+[\\d_]+[\\d]+|[\\d]+))?|\\.([\\d]+[\\d_]+[\\d]+|[\\d]+))([eE][-+]?\\d+)?)[lLfF]?",relevance:0},_=e.COMMENT("/\\*","\\*/",{contains:[e.C_BLOCK_COMMENT_MODE]}),c={variants:[{className:"type",begin:e.UNDERSCORE_IDENT_RE},{begin:/\(/,end:/\)/,contains:[]}]},d=c;return d.variants[1].contains=[c],c.variants[1].contains=[d],{name:"Kotlin",aliases:["kt"],keywords:t,contains:[e.COMMENT("/\\*\\*","\\*/",{relevance:0,contains:[{className:"doctag",begin:"@[A-Za-z]+"}]}),e.C_LINE_COMMENT_MODE,_,{className:"keyword",begin:/\b(break|continue|return|this)\b/,starts:{contains:[{className:"symbol",begin:/@\w+/}]}},a,o,s,{className:"function",beginKeywords:"fun",end:"[(]|$",returnBegin:!0,excludeEnd:!0,keywords:t,illegal:/fun\s+(<.*>)?[^\s\(]+(\s+[^\s\(]+)\s*=/,relevance:5,contains:[{begin:e.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,relevance:0,contains:[e.UNDERSCORE_TITLE_MODE]},{className:"type",begin://,keywords:"reified",relevance:0},{className:"params",begin:/\(/,end:/\)/,endsParent:!0,keywords:t,relevance:0,contains:[{begin:/:/,end:/[=,\/]/,endsWithParent:!0,contains:[c,e.C_LINE_COMMENT_MODE,_],relevance:0},e.C_LINE_COMMENT_MODE,_,o,s,i,e.C_NUMBER_MODE]},_]},{className:"class",beginKeywords:"class interface trait",end:/[:\{(]|$/,excludeEnd:!0,illegal:"extends implements",contains:[{beginKeywords:"public protected internal private constructor"},e.UNDERSCORE_TITLE_MODE,{className:"type",begin://,excludeBegin:!0,excludeEnd:!0,relevance:0},{className:"type",begin:/[,:]\s*/,end:/[<\(,]|$/,excludeBegin:!0,returnEnd:!0},o,s]},i,{className:"meta",begin:"^#!/usr/bin/env",end:"$",illegal:"\n"},l]}},Do=function(e){var t="\\]|\\?>",a={literal:"true false none minimal full all void and or not bw nbw ew new cn ncn lt lte gt gte eq neq rx nrx ft",built_in:"array date decimal duration integer map pair string tag xml null boolean bytes keyword list locale queue set stack staticarray local var variable global data self inherited currentcapture givenblock",keyword:"cache database_names database_schemanames database_tablenames define_tag define_type email_batch encode_set html_comment handle handle_error header if inline iterate ljax_target link link_currentaction link_currentgroup link_currentrecord link_detail link_firstgroup link_firstrecord link_lastgroup link_lastrecord link_nextgroup link_nextrecord link_prevgroup link_prevrecord log loop namespace_using output_none portal private protect records referer referrer repeating resultset rows search_args search_arguments select sort_args sort_arguments thread_atomic value_list while abort case else fail_if fail_ifnot fail if_empty if_false if_null if_true loop_abort loop_continue loop_count params params_up return return_value run_children soap_definetag soap_lastrequest soap_lastresponse tag_name ascending average by define descending do equals frozen group handle_failure import in into join let match max min on order parent protected provide public require returnhome skip split_thread sum take thread to trait type where with yield yieldhome"},n=e.COMMENT("",{relevance:0}),r={className:"meta",begin:"\\[noprocess\\]",starts:{end:"\\[/noprocess\\]",returnEnd:!0,contains:[n]}},i={className:"meta",begin:"\\[/noprocess|<\\?(lasso(script)?|=)"},o={className:"symbol",begin:"'[a-zA-Z_][\\w.]*'"},s=[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.inherit(e.C_NUMBER_MODE,{begin:e.C_NUMBER_RE+"|(-?infinity|NaN)\\b"}),e.inherit(e.APOS_STRING_MODE,{illegal:null}),e.inherit(e.QUOTE_STRING_MODE,{illegal:null}),{className:"string",begin:"`",end:"`"},{variants:[{begin:"[#$][a-zA-Z_][\\w.]*"},{begin:"#",end:"\\d+",illegal:"\\W"}]},{className:"type",begin:"::\\s*",end:"[a-zA-Z_][\\w.]*",illegal:"\\W"},{className:"params",variants:[{begin:"-(?!infinity)[a-zA-Z_][\\w.]*",relevance:0},{begin:"(\\.\\.\\.)"}]},{begin:/(->|\.)\s*/,relevance:0,contains:[o]},{className:"class",beginKeywords:"define",returnEnd:!0,end:"\\(|=>",contains:[e.inherit(e.TITLE_MODE,{begin:"[a-zA-Z_][\\w.]*(=(?!>))?|[-+*/%](?!>)"})]}];return{name:"Lasso",aliases:["ls","lassoscript"],case_insensitive:!0,lexemes:"[a-zA-Z_][\\w.]*|&[lg]t;",keywords:a,contains:[{className:"meta",begin:t,relevance:0,starts:{end:"\\[|<\\?(lasso(script)?|=)",returnEnd:!0,relevance:0,contains:[n]}},r,i,{className:"meta",begin:"\\[no_square_brackets",starts:{end:"\\[/no_square_brackets\\]",lexemes:"[a-zA-Z_][\\w.]*|&[lg]t;",keywords:a,contains:[{className:"meta",begin:t,relevance:0,starts:{end:"\\[noprocess\\]|<\\?(lasso(script)?|=)",returnEnd:!0,contains:[n]}},r,i].concat(s)}},{className:"meta",begin:"\\[",relevance:0},{className:"meta",begin:"^#!",end:"lasso9$",relevance:10}].concat(s)}},Mo=function(e){var t={className:"tag",begin:/\\/,relevance:0,contains:[{className:"name",variants:[{begin:/[a-zA-Z\u0430-\u044f\u0410-\u042f]+[*]?/},{begin:/[^a-zA-Z\u0430-\u044f\u0410-\u042f0-9]/}],starts:{endsWithParent:!0,relevance:0,contains:[{className:"string",variants:[{begin:/\[/,end:/\]/},{begin:/\{/,end:/\}/}]},{begin:/\s*=\s*/,endsWithParent:!0,relevance:0,contains:[{className:"number",begin:/-?\d*\.?\d+(pt|pc|mm|cm|in|dd|cc|ex|em)?/}]}]}}]};return{name:"LaTeX",aliases:["tex"],contains:[t,{className:"formula",contains:[t],relevance:0,variants:[{begin:/\$\$/,end:/\$\$/},{begin:/\$/,end:/\$/}]},e.COMMENT("%","$",{relevance:0})]}},Lo=function(e){return{name:"LDIF",contains:[{className:"attribute",begin:"^dn",end:": ",excludeEnd:!0,starts:{end:"$",relevance:0},relevance:10},{className:"attribute",begin:"^\\w",end:": ",excludeEnd:!0,starts:{end:"$",relevance:0}},{className:"literal",begin:"^-",end:"$"},e.HASH_COMMENT_MODE]}},xo=function(){return{name:"Leaf",contains:[{className:"function",begin:"#+[A-Za-z_0-9]*\\(",end:" {",returnBegin:!0,excludeEnd:!0,contains:[{className:"keyword",begin:"#+"},{className:"title",begin:"[A-Za-z_][A-Za-z_0-9]*"},{className:"params",begin:"\\(",end:"\\)",endsParent:!0,contains:[{className:"string",begin:'"',end:'"'},{className:"variable",begin:"[A-Za-z_][A-Za-z_0-9]*"}]}]}]}},wo=function(e){var t=[],a=[],n=function(e){return{className:"string",begin:"~?"+e+".*?"+e}},r=function(e,t,a){return{className:e,begin:t,relevance:a}},i={begin:"\\(",end:"\\)",contains:a,relevance:0};a.push(e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,n("'"),n('"'),e.CSS_NUMBER_MODE,{begin:"(url|data-uri)\\(",starts:{className:"string",end:"[\\)\\n]",excludeEnd:!0}},r("number","#[0-9A-Fa-f]+\\b"),i,r("variable","@@?[\\w-]+",10),r("variable","@{[\\w-]+}"),r("built_in","~?`[^`]*?`"),{className:"attribute",begin:"[\\w-]+\\s*:",end:":",returnBegin:!0,excludeEnd:!0},{className:"meta",begin:"!important"});var o=a.concat({begin:"{",end:"}",contains:t}),s={beginKeywords:"when",endsWithParent:!0,contains:[{beginKeywords:"and not"}].concat(a)},l={begin:"([\\w-]+|@{[\\w-]+})\\s*:",returnBegin:!0,end:"[;}]",relevance:0,contains:[{className:"attribute",begin:"([\\w-]+|@{[\\w-]+})",end:":",excludeEnd:!0,starts:{endsWithParent:!0,illegal:"[<=$]",relevance:0,contains:a}}]},_={className:"keyword",begin:"@(import|media|charset|font-face|(-[a-z]+-)?keyframes|supports|document|namespace|page|viewport|host)\\b",starts:{end:"[;{}]",returnEnd:!0,contains:a,relevance:0}},c={className:"variable",variants:[{begin:"@[\\w-]+\\s*:",relevance:15},{begin:"@[\\w-]+"}],starts:{end:"[;}]",returnEnd:!0,contains:o}},d={variants:[{begin:"[\\.#:&\\[>]",end:"[;{}]"},{begin:"([\\w-]+|@{[\\w-]+})",end:"{"}],returnBegin:!0,returnEnd:!0,illegal:"[<='$\"]",relevance:0,contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,s,r("keyword","all\\b"),r("variable","@{[\\w-]+}"),r("selector-tag","([\\w-]+|@{[\\w-]+})%?",0),r("selector-id","#([\\w-]+|@{[\\w-]+})"),r("selector-class","\\.([\\w-]+|@{[\\w-]+})",0),r("selector-tag","&",0),{className:"selector-attr",begin:"\\[",end:"\\]"},{className:"selector-pseudo",begin:/:(:)?[a-zA-Z0-9\_\-\+\(\)"'.]+/},{begin:"\\(",end:"\\)",contains:o},{begin:"!important"}]};return t.push(e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,_,c,l,d),{name:"Less",case_insensitive:!0,illegal:"[=>'/<($\"]",contains:t}},Po=function(e){var t="[a-zA-Z_\\-\\+\\*\\/\\<\\=\\>\\&\\#][a-zA-Z0-9_\\-\\+\\*\\/\\<\\=\\>\\&\\#!]*",a="(\\-|\\+)?\\d+(\\.\\d+|\\/\\d+)?((d|e|f|l|s|D|E|F|L|S)(\\+|\\-)?\\d+)?",n={className:"literal",begin:"\\b(t{1}|nil)\\b"},r={className:"number",variants:[{begin:a,relevance:0},{begin:"#(b|B)[0-1]+(/[0-1]+)?"},{begin:"#(o|O)[0-7]+(/[0-7]+)?"},{begin:"#(x|X)[0-9a-fA-F]+(/[0-9a-fA-F]+)?"},{begin:"#(c|C)\\("+a+" +"+a,end:"\\)"}]},i=e.inherit(e.QUOTE_STRING_MODE,{illegal:null}),o=e.COMMENT(";","$",{relevance:0}),s={begin:"\\*",end:"\\*"},l={className:"symbol",begin:"[:&]"+t},_={begin:t,relevance:0},c={begin:"\\|[^]*?\\|"},d={contains:[r,i,s,l,{begin:"\\(",end:"\\)",contains:["self",n,i,r,_]},_],variants:[{begin:"['`]\\(",end:"\\)"},{begin:"\\(quote ",end:"\\)",keywords:{name:"quote"}},{begin:"'\\|[^]*?\\|"}]},u={variants:[{begin:"'"+t},{begin:"#'"+t+"(::"+t+")*"}]},m={begin:"\\(\\s*",end:"\\)"},p={endsWithParent:!0,relevance:0};return m.contains=[{className:"name",variants:[{begin:t},{begin:"\\|[^]*?\\|"}]},p],p.contains=[d,u,m,n,r,i,o,s,l,c,_],{name:"Lisp",illegal:/\S/,contains:[r,{className:"meta",begin:"^#!",end:"$"},n,i,o,d,u,m,_]}},ko=function(e){var t={className:"variable",variants:[{begin:"\\b([gtps][A-Z]{1}[a-zA-Z0-9]*)(\\[.+\\])?(?:\\s*?)"},{begin:"\\$_[A-Z]+"}],relevance:0},a=[e.C_BLOCK_COMMENT_MODE,e.HASH_COMMENT_MODE,e.COMMENT("--","$"),e.COMMENT("[^:]//","$")],n=e.inherit(e.TITLE_MODE,{variants:[{begin:"\\b_*rig[A-Z]+[A-Za-z0-9_\\-]*"},{begin:"\\b_[a-z0-9\\-]+"}]}),r=e.inherit(e.TITLE_MODE,{begin:"\\b([A-Za-z0-9_\\-]+)\\b"});return{name:"LiveCode",case_insensitive:!1,keywords:{keyword:"$_COOKIE $_FILES $_GET $_GET_BINARY $_GET_RAW $_POST $_POST_BINARY $_POST_RAW $_SESSION $_SERVER codepoint codepoints segment segments codeunit codeunits sentence sentences trueWord trueWords paragraph after byte bytes english the until http forever descending using line real8 with seventh for stdout finally element word words fourth before black ninth sixth characters chars stderr uInt1 uInt1s uInt2 uInt2s stdin string lines relative rel any fifth items from middle mid at else of catch then third it file milliseconds seconds second secs sec int1 int1s int4 int4s internet int2 int2s normal text item last long detailed effective uInt4 uInt4s repeat end repeat URL in try into switch to words https token binfile each tenth as ticks tick system real4 by dateItems without char character ascending eighth whole dateTime numeric short first ftp integer abbreviated abbr abbrev private case while if div mod wrap and or bitAnd bitNot bitOr bitXor among not in a an within contains ends with begins the keys of keys",literal:"SIX TEN FORMFEED NINE ZERO NONE SPACE FOUR FALSE COLON CRLF PI COMMA ENDOFFILE EOF EIGHT FIVE QUOTE EMPTY ONE TRUE RETURN CR LINEFEED RIGHT BACKSLASH NULL SEVEN TAB THREE TWO six ten formfeed nine zero none space four false colon crlf pi comma endoffile eof eight five quote empty one true return cr linefeed right backslash null seven tab three two RIVERSION RISTATE FILE_READ_MODE FILE_WRITE_MODE FILE_WRITE_MODE DIR_WRITE_MODE FILE_READ_UMASK FILE_WRITE_UMASK DIR_READ_UMASK DIR_WRITE_UMASK",built_in:"put abs acos aliasReference annuity arrayDecode arrayEncode asin atan atan2 average avg avgDev base64Decode base64Encode baseConvert binaryDecode binaryEncode byteOffset byteToNum cachedURL cachedURLs charToNum cipherNames codepointOffset codepointProperty codepointToNum codeunitOffset commandNames compound compress constantNames cos date dateFormat decompress difference directories diskSpace DNSServers exp exp1 exp2 exp10 extents files flushEvents folders format functionNames geometricMean global globals hasMemory harmonicMean hostAddress hostAddressToName hostName hostNameToAddress isNumber ISOToMac itemOffset keys len length libURLErrorData libUrlFormData libURLftpCommand libURLLastHTTPHeaders libURLLastRHHeaders libUrlMultipartFormAddPart libUrlMultipartFormData libURLVersion lineOffset ln ln1 localNames log log2 log10 longFilePath lower macToISO matchChunk matchText matrixMultiply max md5Digest median merge messageAuthenticationCode messageDigest millisec millisecs millisecond milliseconds min monthNames nativeCharToNum normalizeText num number numToByte numToChar numToCodepoint numToNativeChar offset open openfiles openProcesses openProcessIDs openSockets paragraphOffset paramCount param params peerAddress pendingMessages platform popStdDev populationStandardDeviation populationVariance popVariance processID random randomBytes replaceText result revCreateXMLTree revCreateXMLTreeFromFile revCurrentRecord revCurrentRecordIsFirst revCurrentRecordIsLast revDatabaseColumnCount revDatabaseColumnIsNull revDatabaseColumnLengths revDatabaseColumnNames revDatabaseColumnNamed revDatabaseColumnNumbered revDatabaseColumnTypes revDatabaseConnectResult revDatabaseCursors revDatabaseID revDatabaseTableNames revDatabaseType revDataFromQuery revdb_closeCursor revdb_columnbynumber revdb_columncount revdb_columnisnull revdb_columnlengths revdb_columnnames revdb_columntypes revdb_commit revdb_connect revdb_connections revdb_connectionerr revdb_currentrecord revdb_cursorconnection revdb_cursorerr revdb_cursors revdb_dbtype revdb_disconnect revdb_execute revdb_iseof revdb_isbof revdb_movefirst revdb_movelast revdb_movenext revdb_moveprev revdb_query revdb_querylist revdb_recordcount revdb_rollback revdb_tablenames revGetDatabaseDriverPath revNumberOfRecords revOpenDatabase revOpenDatabases revQueryDatabase revQueryDatabaseBlob revQueryResult revQueryIsAtStart revQueryIsAtEnd revUnixFromMacPath revXMLAttribute revXMLAttributes revXMLAttributeValues revXMLChildContents revXMLChildNames revXMLCreateTreeFromFileWithNamespaces revXMLCreateTreeWithNamespaces revXMLDataFromXPathQuery revXMLEvaluateXPath revXMLFirstChild revXMLMatchingNode revXMLNextSibling revXMLNodeContents revXMLNumberOfChildren revXMLParent revXMLPreviousSibling revXMLRootNode revXMLRPC_CreateRequest revXMLRPC_Documents revXMLRPC_Error revXMLRPC_GetHost revXMLRPC_GetMethod revXMLRPC_GetParam revXMLText revXMLRPC_Execute revXMLRPC_GetParamCount revXMLRPC_GetParamNode revXMLRPC_GetParamType revXMLRPC_GetPath revXMLRPC_GetPort revXMLRPC_GetProtocol revXMLRPC_GetRequest revXMLRPC_GetResponse revXMLRPC_GetSocket revXMLTree revXMLTrees revXMLValidateDTD revZipDescribeItem revZipEnumerateItems revZipOpenArchives round sampVariance sec secs seconds sentenceOffset sha1Digest shell shortFilePath sin specialFolderPath sqrt standardDeviation statRound stdDev sum sysError systemVersion tan tempName textDecode textEncode tick ticks time to tokenOffset toLower toUpper transpose truewordOffset trunc uniDecode uniEncode upper URLDecode URLEncode URLStatus uuid value variableNames variance version waitDepth weekdayNames wordOffset xsltApplyStylesheet xsltApplyStylesheetFromFile xsltLoadStylesheet xsltLoadStylesheetFromFile add breakpoint cancel clear local variable file word line folder directory URL close socket process combine constant convert create new alias folder directory decrypt delete variable word line folder directory URL dispatch divide do encrypt filter get include intersect kill libURLDownloadToFile libURLFollowHttpRedirects libURLftpUpload libURLftpUploadFile libURLresetAll libUrlSetAuthCallback libURLSetDriver libURLSetCustomHTTPHeaders libUrlSetExpect100 libURLSetFTPListCommand libURLSetFTPMode libURLSetFTPStopTime libURLSetStatusCallback load extension loadedExtensions multiply socket prepare process post seek rel relative read from process rename replace require resetAll resolve revAddXMLNode revAppendXML revCloseCursor revCloseDatabase revCommitDatabase revCopyFile revCopyFolder revCopyXMLNode revDeleteFolder revDeleteXMLNode revDeleteAllXMLTrees revDeleteXMLTree revExecuteSQL revGoURL revInsertXMLNode revMoveFolder revMoveToFirstRecord revMoveToLastRecord revMoveToNextRecord revMoveToPreviousRecord revMoveToRecord revMoveXMLNode revPutIntoXMLNode revRollBackDatabase revSetDatabaseDriverPath revSetXMLAttribute revXMLRPC_AddParam revXMLRPC_DeleteAllDocuments revXMLAddDTD revXMLRPC_Free revXMLRPC_FreeAll revXMLRPC_DeleteDocument revXMLRPC_DeleteParam revXMLRPC_SetHost revXMLRPC_SetMethod revXMLRPC_SetPort revXMLRPC_SetProtocol revXMLRPC_SetSocket revZipAddItemWithData revZipAddItemWithFile revZipAddUncompressedItemWithData revZipAddUncompressedItemWithFile revZipCancel revZipCloseArchive revZipDeleteItem revZipExtractItemToFile revZipExtractItemToVariable revZipSetProgressCallback revZipRenameItem revZipReplaceItemWithData revZipReplaceItemWithFile revZipOpenArchive send set sort split start stop subtract symmetric union unload vectorDotProduct wait write"},contains:[t,{className:"keyword",begin:"\\bend\\sif\\b"},{className:"function",beginKeywords:"function",end:"$",contains:[t,r,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,e.BINARY_NUMBER_MODE,e.C_NUMBER_MODE,n]},{className:"function",begin:"\\bend\\s+",end:"$",keywords:"end",contains:[r,n],relevance:0},{beginKeywords:"command on",end:"$",contains:[t,r,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,e.BINARY_NUMBER_MODE,e.C_NUMBER_MODE,n]},{className:"meta",variants:[{begin:"<\\?(rev|lc|livecode)",relevance:10},{begin:"<\\?"},{begin:"\\?>"}]},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,e.BINARY_NUMBER_MODE,e.C_NUMBER_MODE,n].concat(a),illegal:";$|^\\[|^=|&|{"}},Uo=function(e){var t={keyword:"in if for while finally new do return else break catch instanceof throw try this switch continue typeof delete debugger case default function var with then unless until loop of by when and or is isnt not it that otherwise from to til fallthrough super case default function var void const let enum export import native list map __hasProp __extends __slice __bind __indexOf",literal:"true false null undefined yes no on off it that void",built_in:"npm require console print module global window document"},a="[A-Za-z$_](?:-[0-9A-Za-z$_]|[0-9A-Za-z$_])*",n=e.inherit(e.TITLE_MODE,{begin:a}),r={className:"subst",begin:/#\{/,end:/}/,keywords:t},i={className:"subst",begin:/#[A-Za-z$_]/,end:/(?:\-[0-9A-Za-z$_]|[0-9A-Za-z$_])*/,keywords:t},o=[e.BINARY_NUMBER_MODE,{className:"number",begin:"(\\b0[xX][a-fA-F0-9_]+)|(\\b\\d(\\d|_\\d)*(\\.(\\d(\\d|_\\d)*)?)?(_*[eE]([-+]\\d(_\\d|\\d)*)?)?[_a-z]*)",relevance:0,starts:{end:"(\\s*/)?",relevance:0}},{className:"string",variants:[{begin:/'''/,end:/'''/,contains:[e.BACKSLASH_ESCAPE]},{begin:/'/,end:/'/,contains:[e.BACKSLASH_ESCAPE]},{begin:/"""/,end:/"""/,contains:[e.BACKSLASH_ESCAPE,r,i]},{begin:/"/,end:/"/,contains:[e.BACKSLASH_ESCAPE,r,i]},{begin:/\\/,end:/(\s|$)/,excludeEnd:!0}]},{className:"regexp",variants:[{begin:"//",end:"//[gim]*",contains:[r,e.HASH_COMMENT_MODE]},{begin:/\/(?![ *])(\\\/|.)*?\/[gim]*(?=\W)/}]},{begin:"@"+a},{begin:"``",end:"``",excludeBegin:!0,excludeEnd:!0,subLanguage:"javascript"}];r.contains=o;var s={className:"params",begin:"\\(",returnBegin:!0,contains:[{begin:/\(/,end:/\)/,keywords:t,contains:["self"].concat(o)}]};return{name:"LiveScript",aliases:["ls"],keywords:t,illegal:/\/\*/,contains:o.concat([e.COMMENT("\\/\\*","\\*\\/"),e.HASH_COMMENT_MODE,{begin:"(#=>|=>|\\|>>|-?->|\\!->)"},{className:"function",contains:[n,s],returnBegin:!0,variants:[{begin:"("+a+"\\s*(?:=|:=)\\s*)?(\\(.*\\))?\\s*\\B\\->\\*?",end:"\\->\\*?"},{begin:"("+a+"\\s*(?:=|:=)\\s*)?!?(\\(.*\\))?\\s*\\B[-~]{1,2}>\\*?",end:"[-~]{1,2}>\\*?"},{begin:"("+a+"\\s*(?:=|:=)\\s*)?(\\(.*\\))?\\s*\\B!?[-~]{1,2}>\\*?",end:"!?[-~]{1,2}>\\*?"}]},{className:"class",beginKeywords:"class",end:"$",illegal:/[:="\[\]]/,contains:[{beginKeywords:"extends",endsWithParent:!0,illegal:/[:="\[\]]/,contains:[n]},n]},{begin:a+":",end:":",returnBegin:!0,returnEnd:!0,relevance:0}])}},Fo=function(e){var t="([-a-zA-Z$._][\\w\\-$.]*)";return{name:"LLVM IR",keywords:"begin end true false declare define global constant private linker_private internal available_externally linkonce linkonce_odr weak weak_odr appending dllimport dllexport common default hidden protected extern_weak external thread_local zeroinitializer undef null to tail target triple datalayout volatile nuw nsw nnan ninf nsz arcp fast exact inbounds align addrspace section alias module asm sideeffect gc dbg linker_private_weak attributes blockaddress initialexec localdynamic localexec prefix unnamed_addr ccc fastcc coldcc x86_stdcallcc x86_fastcallcc arm_apcscc arm_aapcscc arm_aapcs_vfpcc ptx_device ptx_kernel intel_ocl_bicc msp430_intrcc spir_func spir_kernel x86_64_sysvcc x86_64_win64cc x86_thiscallcc cc c signext zeroext inreg sret nounwind noreturn noalias nocapture byval nest readnone readonly inlinehint noinline alwaysinline optsize ssp sspreq noredzone noimplicitfloat naked builtin cold nobuiltin noduplicate nonlazybind optnone returns_twice sanitize_address sanitize_memory sanitize_thread sspstrong uwtable returned type opaque eq ne slt sgt sle sge ult ugt ule uge oeq one olt ogt ole oge ord uno ueq une x acq_rel acquire alignstack atomic catch cleanup filter inteldialect max min monotonic nand personality release seq_cst singlethread umax umin unordered xchg add fadd sub fsub mul fmul udiv sdiv fdiv urem srem frem shl lshr ashr and or xor icmp fcmp phi call trunc zext sext fptrunc fpext uitofp sitofp fptoui fptosi inttoptr ptrtoint bitcast addrspacecast select va_arg ret br switch invoke unwind unreachable indirectbr landingpad resume malloc alloca free load store getelementptr extractelement insertelement shufflevector getresult extractvalue insertvalue atomicrmw cmpxchg fence argmemonly double",contains:[{className:"keyword",begin:"i\\d+"},e.COMMENT(";","\\n",{relevance:0}),e.QUOTE_STRING_MODE,{className:"string",variants:[{begin:'"',end:'[^\\\\]"'}],relevance:0},{className:"title",variants:[{begin:"@"+t},{begin:"@\\d+"},{begin:"!"+t},{begin:"!\\d+"+t}]},{className:"symbol",variants:[{begin:"%"+t},{begin:"%\\d+"},{begin:"#\\d+"}]},{className:"number",variants:[{begin:"0[xX][a-fA-F0-9]+"},{begin:"-?\\d+(?:[.]\\d+)?(?:[eE][-+]?\\d+(?:[.]\\d+)?)?"}],relevance:0}]}},Bo=function(e){var t={className:"string",begin:'"',end:'"',contains:[{className:"subst",begin:/\\[tn"\\]/}]},a={className:"number",begin:e.C_NUMBER_RE};return{name:"LSL (Linden Scripting Language)",illegal:":",contains:[t,{className:"comment",variants:[e.COMMENT("//","$"),e.COMMENT("/\\*","\\*/")],relevance:0},a,{className:"section",variants:[{begin:"\\b(?:state|default)\\b"},{begin:"\\b(?:state_(?:entry|exit)|touch(?:_(?:start|end))?|(?:land_)?collision(?:_(?:start|end))?|timer|listen|(?:no_)?sensor|control|(?:not_)?at_(?:rot_)?target|money|email|experience_permissions(?:_denied)?|run_time_permissions|changed|attach|dataserver|moving_(?:start|end)|link_message|(?:on|object)_rez|remote_data|http_re(?:sponse|quest)|path_update|transaction_result)\\b"}]},{className:"built_in",begin:"\\b(?:ll(?:AgentInExperience|(?:Create|DataSize|Delete|KeyCount|Keys|Read|Update)KeyValue|GetExperience(?:Details|ErrorMessage)|ReturnObjectsBy(?:ID|Owner)|Json(?:2List|[GS]etValue|ValueType)|Sin|Cos|Tan|Atan2|Sqrt|Pow|Abs|Fabs|Frand|Floor|Ceil|Round|Vec(?:Mag|Norm|Dist)|Rot(?:Between|2(?:Euler|Fwd|Left|Up))|(?:Euler|Axes)2Rot|Whisper|(?:Region|Owner)?Say|Shout|Listen(?:Control|Remove)?|Sensor(?:Repeat|Remove)?|Detected(?:Name|Key|Owner|Type|Pos|Vel|Grab|Rot|Group|LinkNumber)|Die|Ground|Wind|(?:[GS]et)(?:AnimationOverride|MemoryLimit|PrimMediaParams|ParcelMusicURL|Object(?:Desc|Name)|PhysicsMaterial|Status|Scale|Color|Alpha|Texture|Pos|Rot|Force|Torque)|ResetAnimationOverride|(?:Scale|Offset|Rotate)Texture|(?:Rot)?Target(?:Remove)?|(?:Stop)?MoveToTarget|Apply(?:Rotational)?Impulse|Set(?:KeyframedMotion|ContentType|RegionPos|(?:Angular)?Velocity|Buoyancy|HoverHeight|ForceAndTorque|TimerEvent|ScriptState|Damage|TextureAnim|Sound(?:Queueing|Radius)|Vehicle(?:Type|(?:Float|Vector|Rotation)Param)|(?:Touch|Sit)?Text|Camera(?:Eye|At)Offset|PrimitiveParams|ClickAction|Link(?:Alpha|Color|PrimitiveParams(?:Fast)?|Texture(?:Anim)?|Camera|Media)|RemoteScriptAccessPin|PayPrice|LocalRot)|ScaleByFactor|Get(?:(?:Max|Min)ScaleFactor|ClosestNavPoint|StaticPath|SimStats|Env|PrimitiveParams|Link(?:PrimitiveParams|Number(?:OfSides)?|Key|Name|Media)|HTTPHeader|FreeURLs|Object(?:Details|PermMask|PrimCount)|Parcel(?:MaxPrims|Details|Prim(?:Count|Owners))|Attached(?:List)?|(?:SPMax|Free|Used)Memory|Region(?:Name|TimeDilation|FPS|Corner|AgentCount)|Root(?:Position|Rotation)|UnixTime|(?:Parcel|Region)Flags|(?:Wall|GMT)clock|SimulatorHostname|BoundingBox|GeometricCenter|Creator|NumberOf(?:Prims|NotecardLines|Sides)|Animation(?:List)?|(?:Camera|Local)(?:Pos|Rot)|Vel|Accel|Omega|Time(?:stamp|OfDay)|(?:Object|CenterOf)?Mass|MassMKS|Energy|Owner|(?:Owner)?Key|SunDirection|Texture(?:Offset|Scale|Rot)|Inventory(?:Number|Name|Key|Type|Creator|PermMask)|Permissions(?:Key)?|StartParameter|List(?:Length|EntryType)|Date|Agent(?:Size|Info|Language|List)|LandOwnerAt|NotecardLine|Script(?:Name|State))|(?:Get|Reset|GetAndReset)Time|PlaySound(?:Slave)?|LoopSound(?:Master|Slave)?|(?:Trigger|Stop|Preload)Sound|(?:(?:Get|Delete)Sub|Insert)String|To(?:Upper|Lower)|Give(?:InventoryList|Money)|RezObject|(?:Stop)?LookAt|Sleep|CollisionFilter|(?:Take|Release)Controls|DetachFromAvatar|AttachToAvatar(?:Temp)?|InstantMessage|(?:GetNext)?Email|StopHover|MinEventDelay|RotLookAt|String(?:Length|Trim)|(?:Start|Stop)Animation|TargetOmega|Request(?:Experience)?Permissions|(?:Create|Break)Link|BreakAllLinks|(?:Give|Remove)Inventory|Water|PassTouches|Request(?:Agent|Inventory)Data|TeleportAgent(?:Home|GlobalCoords)?|ModifyLand|CollisionSound|ResetScript|MessageLinked|PushObject|PassCollisions|AxisAngle2Rot|Rot2(?:Axis|Angle)|A(?:cos|sin)|AngleBetween|AllowInventoryDrop|SubStringIndex|List2(?:CSV|Integer|Json|Float|String|Key|Vector|Rot|List(?:Strided)?)|DeleteSubList|List(?:Statistics|Sort|Randomize|(?:Insert|Find|Replace)List)|EdgeOfWorld|AdjustSoundVolume|Key2Name|TriggerSoundLimited|EjectFromLand|(?:CSV|ParseString)2List|OverMyLand|SameGroup|UnSit|Ground(?:Slope|Normal|Contour)|GroundRepel|(?:Set|Remove)VehicleFlags|SitOnLink|(?:AvatarOn)?(?:Link)?SitTarget|Script(?:Danger|Profiler)|Dialog|VolumeDetect|ResetOtherScript|RemoteLoadScriptPin|(?:Open|Close)RemoteDataChannel|SendRemoteData|RemoteDataReply|(?:Integer|String)ToBase64|XorBase64|Log(?:10)?|Base64To(?:String|Integer)|ParseStringKeepNulls|RezAtRoot|RequestSimulatorData|ForceMouselook|(?:Load|Release|(?:E|Une)scape)URL|ParcelMedia(?:CommandList|Query)|ModPow|MapDestination|(?:RemoveFrom|AddTo|Reset)Land(?:Pass|Ban)List|(?:Set|Clear)CameraParams|HTTP(?:Request|Response)|TextBox|DetectedTouch(?:UV|Face|Pos|(?:N|Bin)ormal|ST)|(?:MD5|SHA1|DumpList2)String|Request(?:Secure)?URL|Clear(?:Prim|Link)Media|(?:Link)?ParticleSystem|(?:Get|Request)(?:Username|DisplayName)|RegionSayTo|CastRay|GenerateKey|TransferLindenDollars|ManageEstateAccess|(?:Create|Delete)Character|ExecCharacterCmd|Evade|FleeFrom|NavigateTo|PatrolPoints|Pursue|UpdateCharacter|WanderWithin))\\b"},{className:"literal",variants:[{begin:"\\b(?:PI|TWO_PI|PI_BY_TWO|DEG_TO_RAD|RAD_TO_DEG|SQRT2)\\b"},{ +begin:"\\b(?:XP_ERROR_(?:EXPERIENCES_DISABLED|EXPERIENCE_(?:DISABLED|SUSPENDED)|INVALID_(?:EXPERIENCE|PARAMETERS)|KEY_NOT_FOUND|MATURITY_EXCEEDED|NONE|NOT_(?:FOUND|PERMITTED(?:_LAND)?)|NO_EXPERIENCE|QUOTA_EXCEEDED|RETRY_UPDATE|STORAGE_EXCEPTION|STORE_DISABLED|THROTTLED|UNKNOWN_ERROR)|JSON_APPEND|STATUS_(?:PHYSICS|ROTATE_[XYZ]|PHANTOM|SANDBOX|BLOCK_GRAB(?:_OBJECT)?|(?:DIE|RETURN)_AT_EDGE|CAST_SHADOWS|OK|MALFORMED_PARAMS|TYPE_MISMATCH|BOUNDS_ERROR|NOT_(?:FOUND|SUPPORTED)|INTERNAL_ERROR|WHITELIST_FAILED)|AGENT(?:_(?:BY_(?:LEGACY_|USER)NAME|FLYING|ATTACHMENTS|SCRIPTED|MOUSELOOK|SITTING|ON_OBJECT|AWAY|WALKING|IN_AIR|TYPING|CROUCHING|BUSY|ALWAYS_RUN|AUTOPILOT|LIST_(?:PARCEL(?:_OWNER)?|REGION)))?|CAMERA_(?:PITCH|DISTANCE|BEHINDNESS_(?:ANGLE|LAG)|(?:FOCUS|POSITION)(?:_(?:THRESHOLD|LOCKED|LAG))?|FOCUS_OFFSET|ACTIVE)|ANIM_ON|LOOP|REVERSE|PING_PONG|SMOOTH|ROTATE|SCALE|ALL_SIDES|LINK_(?:ROOT|SET|ALL_(?:OTHERS|CHILDREN)|THIS)|ACTIVE|PASS(?:IVE|_(?:ALWAYS|IF_NOT_HANDLED|NEVER))|SCRIPTED|CONTROL_(?:FWD|BACK|(?:ROT_)?(?:LEFT|RIGHT)|UP|DOWN|(?:ML_)?LBUTTON)|PERMISSION_(?:RETURN_OBJECTS|DEBIT|OVERRIDE_ANIMATIONS|SILENT_ESTATE_MANAGEMENT|TAKE_CONTROLS|TRIGGER_ANIMATION|ATTACH|CHANGE_LINKS|(?:CONTROL|TRACK)_CAMERA|TELEPORT)|INVENTORY_(?:TEXTURE|SOUND|OBJECT|SCRIPT|LANDMARK|CLOTHING|NOTECARD|BODYPART|ANIMATION|GESTURE|ALL|NONE)|CHANGED_(?:INVENTORY|COLOR|SHAPE|SCALE|TEXTURE|LINK|ALLOWED_DROP|OWNER|REGION(?:_START)?|TELEPORT|MEDIA)|OBJECT_(?:CLICK_ACTION|HOVER_HEIGHT|LAST_OWNER_ID|(?:PHYSICS|SERVER|STREAMING)_COST|UNKNOWN_DETAIL|CHARACTER_TIME|PHANTOM|PHYSICS|TEMP_(?:ATTACHED|ON_REZ)|NAME|DESC|POS|PRIM_(?:COUNT|EQUIVALENCE)|RETURN_(?:PARCEL(?:_OWNER)?|REGION)|REZZER_KEY|ROO?T|VELOCITY|OMEGA|OWNER|GROUP(?:_TAG)?|CREATOR|ATTACHED_(?:POINT|SLOTS_AVAILABLE)|RENDER_WEIGHT|(?:BODY_SHAPE|PATHFINDING)_TYPE|(?:RUNNING|TOTAL)_SCRIPT_COUNT|TOTAL_INVENTORY_COUNT|SCRIPT_(?:MEMORY|TIME))|TYPE_(?:INTEGER|FLOAT|STRING|KEY|VECTOR|ROTATION|INVALID)|(?:DEBUG|PUBLIC)_CHANNEL|ATTACH_(?:AVATAR_CENTER|CHEST|HEAD|BACK|PELVIS|MOUTH|CHIN|NECK|NOSE|BELLY|[LR](?:SHOULDER|HAND|FOOT|EAR|EYE|[UL](?:ARM|LEG)|HIP)|(?:LEFT|RIGHT)_PEC|HUD_(?:CENTER_[12]|TOP_(?:RIGHT|CENTER|LEFT)|BOTTOM(?:_(?:RIGHT|LEFT))?)|[LR]HAND_RING1|TAIL_(?:BASE|TIP)|[LR]WING|FACE_(?:JAW|[LR]EAR|[LR]EYE|TOUNGE)|GROIN|HIND_[LR]FOOT)|LAND_(?:LEVEL|RAISE|LOWER|SMOOTH|NOISE|REVERT)|DATA_(?:ONLINE|NAME|BORN|SIM_(?:POS|STATUS|RATING)|PAYINFO)|PAYMENT_INFO_(?:ON_FILE|USED)|REMOTE_DATA_(?:CHANNEL|REQUEST|REPLY)|PSYS_(?:PART_(?:BF_(?:ZERO|ONE(?:_MINUS_(?:DEST_COLOR|SOURCE_(ALPHA|COLOR)))?|DEST_COLOR|SOURCE_(ALPHA|COLOR))|BLEND_FUNC_(DEST|SOURCE)|FLAGS|(?:START|END)_(?:COLOR|ALPHA|SCALE|GLOW)|MAX_AGE|(?:RIBBON|WIND|INTERP_(?:COLOR|SCALE)|BOUNCE|FOLLOW_(?:SRC|VELOCITY)|TARGET_(?:POS|LINEAR)|EMISSIVE)_MASK)|SRC_(?:MAX_AGE|PATTERN|ANGLE_(?:BEGIN|END)|BURST_(?:RATE|PART_COUNT|RADIUS|SPEED_(?:MIN|MAX))|ACCEL|TEXTURE|TARGET_KEY|OMEGA|PATTERN_(?:DROP|EXPLODE|ANGLE(?:_CONE(?:_EMPTY)?)?)))|VEHICLE_(?:REFERENCE_FRAME|TYPE_(?:NONE|SLED|CAR|BOAT|AIRPLANE|BALLOON)|(?:LINEAR|ANGULAR)_(?:FRICTION_TIMESCALE|MOTOR_DIRECTION)|LINEAR_MOTOR_OFFSET|HOVER_(?:HEIGHT|EFFICIENCY|TIMESCALE)|BUOYANCY|(?:LINEAR|ANGULAR)_(?:DEFLECTION_(?:EFFICIENCY|TIMESCALE)|MOTOR_(?:DECAY_)?TIMESCALE)|VERTICAL_ATTRACTION_(?:EFFICIENCY|TIMESCALE)|BANKING_(?:EFFICIENCY|MIX|TIMESCALE)|FLAG_(?:NO_DEFLECTION_UP|LIMIT_(?:ROLL_ONLY|MOTOR_UP)|HOVER_(?:(?:WATER|TERRAIN|UP)_ONLY|GLOBAL_HEIGHT)|MOUSELOOK_(?:STEER|BANK)|CAMERA_DECOUPLED))|PRIM_(?:ALLOW_UNSIT|ALPHA_MODE(?:_(?:BLEND|EMISSIVE|MASK|NONE))?|NORMAL|SPECULAR|TYPE(?:_(?:BOX|CYLINDER|PRISM|SPHERE|TORUS|TUBE|RING|SCULPT))?|HOLE_(?:DEFAULT|CIRCLE|SQUARE|TRIANGLE)|MATERIAL(?:_(?:STONE|METAL|GLASS|WOOD|FLESH|PLASTIC|RUBBER))?|SHINY_(?:NONE|LOW|MEDIUM|HIGH)|BUMP_(?:NONE|BRIGHT|DARK|WOOD|BARK|BRICKS|CHECKER|CONCRETE|TILE|STONE|DISKS|GRAVEL|BLOBS|SIDING|LARGETILE|STUCCO|SUCTION|WEAVE)|TEXGEN_(?:DEFAULT|PLANAR)|SCRIPTED_SIT_ONLY|SCULPT_(?:TYPE_(?:SPHERE|TORUS|PLANE|CYLINDER|MASK)|FLAG_(?:MIRROR|INVERT))|PHYSICS(?:_(?:SHAPE_(?:CONVEX|NONE|PRIM|TYPE)))?|(?:POS|ROT)_LOCAL|SLICE|TEXT|FLEXIBLE|POINT_LIGHT|TEMP_ON_REZ|PHANTOM|POSITION|SIT_TARGET|SIZE|ROTATION|TEXTURE|NAME|OMEGA|DESC|LINK_TARGET|COLOR|BUMP_SHINY|FULLBRIGHT|TEXGEN|GLOW|MEDIA_(?:ALT_IMAGE_ENABLE|CONTROLS|(?:CURRENT|HOME)_URL|AUTO_(?:LOOP|PLAY|SCALE|ZOOM)|FIRST_CLICK_INTERACT|(?:WIDTH|HEIGHT)_PIXELS|WHITELIST(?:_ENABLE)?|PERMS_(?:INTERACT|CONTROL)|PARAM_MAX|CONTROLS_(?:STANDARD|MINI)|PERM_(?:NONE|OWNER|GROUP|ANYONE)|MAX_(?:URL_LENGTH|WHITELIST_(?:SIZE|COUNT)|(?:WIDTH|HEIGHT)_PIXELS)))|MASK_(?:BASE|OWNER|GROUP|EVERYONE|NEXT)|PERM_(?:TRANSFER|MODIFY|COPY|MOVE|ALL)|PARCEL_(?:MEDIA_COMMAND_(?:STOP|PAUSE|PLAY|LOOP|TEXTURE|URL|TIME|AGENT|UNLOAD|AUTO_ALIGN|TYPE|SIZE|DESC|LOOP_SET)|FLAG_(?:ALLOW_(?:FLY|(?:GROUP_)?SCRIPTS|LANDMARK|TERRAFORM|DAMAGE|CREATE_(?:GROUP_)?OBJECTS)|USE_(?:ACCESS_(?:GROUP|LIST)|BAN_LIST|LAND_PASS_LIST)|LOCAL_SOUND_ONLY|RESTRICT_PUSHOBJECT|ALLOW_(?:GROUP|ALL)_OBJECT_ENTRY)|COUNT_(?:TOTAL|OWNER|GROUP|OTHER|SELECTED|TEMP)|DETAILS_(?:NAME|DESC|OWNER|GROUP|AREA|ID|SEE_AVATARS))|LIST_STAT_(?:MAX|MIN|MEAN|MEDIAN|STD_DEV|SUM(?:_SQUARES)?|NUM_COUNT|GEOMETRIC_MEAN|RANGE)|PAY_(?:HIDE|DEFAULT)|REGION_FLAG_(?:ALLOW_DAMAGE|FIXED_SUN|BLOCK_TERRAFORM|SANDBOX|DISABLE_(?:COLLISIONS|PHYSICS)|BLOCK_FLY|ALLOW_DIRECT_TELEPORT|RESTRICT_PUSHOBJECT)|HTTP_(?:METHOD|MIMETYPE|BODY_(?:MAXLENGTH|TRUNCATED)|CUSTOM_HEADER|PRAGMA_NO_CACHE|VERBOSE_THROTTLE|VERIFY_CERT)|SIT_(?:INVALID_(?:AGENT|LINK_OBJECT)|NO(?:T_EXPERIENCE|_(?:ACCESS|EXPERIENCE_PERMISSION|SIT_TARGET)))|STRING_(?:TRIM(?:_(?:HEAD|TAIL))?)|CLICK_ACTION_(?:NONE|TOUCH|SIT|BUY|PAY|OPEN(?:_MEDIA)?|PLAY|ZOOM)|TOUCH_INVALID_FACE|PROFILE_(?:NONE|SCRIPT_MEMORY)|RC_(?:DATA_FLAGS|DETECT_PHANTOM|GET_(?:LINK_NUM|NORMAL|ROOT_KEY)|MAX_HITS|REJECT_(?:TYPES|AGENTS|(?:NON)?PHYSICAL|LAND))|RCERR_(?:CAST_TIME_EXCEEDED|SIM_PERF_LOW|UNKNOWN)|ESTATE_ACCESS_(?:ALLOWED_(?:AGENT|GROUP)_(?:ADD|REMOVE)|BANNED_AGENT_(?:ADD|REMOVE))|DENSITY|FRICTION|RESTITUTION|GRAVITY_MULTIPLIER|KFM_(?:COMMAND|CMD_(?:PLAY|STOP|PAUSE)|MODE|FORWARD|LOOP|PING_PONG|REVERSE|DATA|ROTATION|TRANSLATION)|ERR_(?:GENERIC|PARCEL_PERMISSIONS|MALFORMED_PARAMS|RUNTIME_PERMISSIONS|THROTTLED)|CHARACTER_(?:CMD_(?:(?:SMOOTH_)?STOP|JUMP)|DESIRED_(?:TURN_)?SPEED|RADIUS|STAY_WITHIN_PARCEL|LENGTH|ORIENTATION|ACCOUNT_FOR_SKIPPED_FRAMES|AVOIDANCE_MODE|TYPE(?:_(?:[ABCD]|NONE))?|MAX_(?:DECEL|TURN_RADIUS|(?:ACCEL|SPEED)))|PURSUIT_(?:OFFSET|FUZZ_FACTOR|GOAL_TOLERANCE|INTERCEPT)|REQUIRE_LINE_OF_SIGHT|FORCE_DIRECT_PATH|VERTICAL|HORIZONTAL|AVOID_(?:CHARACTERS|DYNAMIC_OBSTACLES|NONE)|PU_(?:EVADE_(?:HIDDEN|SPOTTED)|FAILURE_(?:DYNAMIC_PATHFINDING_DISABLED|INVALID_(?:GOAL|START)|NO_(?:NAVMESH|VALID_DESTINATION)|OTHER|TARGET_GONE|(?:PARCEL_)?UNREACHABLE)|(?:GOAL|SLOWDOWN_DISTANCE)_REACHED)|TRAVERSAL_TYPE(?:_(?:FAST|NONE|SLOW))?|CONTENT_TYPE_(?:ATOM|FORM|HTML|JSON|LLSD|RSS|TEXT|XHTML|XML)|GCNP_(?:RADIUS|STATIC)|(?:PATROL|WANDER)_PAUSE_AT_WAYPOINTS|OPT_(?:AVATAR|CHARACTER|EXCLUSION_VOLUME|LEGACY_LINKSET|MATERIAL_VOLUME|OTHER|STATIC_OBSTACLE|WALKABLE)|SIM_STAT_PCT_CHARS_STEPPED)\\b"},{begin:"\\b(?:FALSE|TRUE)\\b"},{begin:"\\b(?:ZERO_ROTATION)\\b"},{begin:"\\b(?:EOF|JSON_(?:ARRAY|DELETE|FALSE|INVALID|NULL|NUMBER|OBJECT|STRING|TRUE)|NULL_KEY|TEXTURE_(?:BLANK|DEFAULT|MEDIA|PLYWOOD|TRANSPARENT)|URL_REQUEST_(?:GRANTED|DENIED))\\b"},{begin:"\\b(?:ZERO_VECTOR|TOUCH_INVALID_(?:TEXCOORD|VECTOR))\\b"}]},{className:"type",begin:"\\b(?:integer|float|string|key|vector|quaternion|rotation|list)\\b"}]}},Go=function(e){var t={begin:"\\[=*\\[",end:"\\]=*\\]",contains:["self"]},a=[e.COMMENT("--(?!\\[=*\\[)","$"),e.COMMENT("--\\[=*\\[","\\]=*\\]",{contains:[t],relevance:10})];return{name:"Lua",lexemes:e.UNDERSCORE_IDENT_RE,keywords:{literal:"true false nil",keyword:"and break do else elseif end for goto if in local not or repeat return then until while",built_in:"_G _ENV _VERSION __index __newindex __mode __call __metatable __tostring __len __gc __add __sub __mul __div __mod __pow __concat __unm __eq __lt __le assert collectgarbage dofile error getfenv getmetatable ipairs load loadfile loadstring module next pairs pcall print rawequal rawget rawset require select setfenv setmetatable tonumber tostring type unpack xpcall arg self coroutine resume yield status wrap create running debug getupvalue debug sethook getmetatable gethook setmetatable setlocal traceback setfenv getinfo setupvalue getlocal getregistry getfenv io lines write close flush open output type read stderr stdin input stdout popen tmpfile math log max acos huge ldexp pi cos tanh pow deg tan cosh sinh random randomseed frexp ceil floor rad abs sqrt modf asin min mod fmod log10 atan2 exp sin atan os exit setlocale date getenv difftime remove time clock tmpname rename execute package preload loadlib loaded loaders cpath config path seeall string sub upper len gfind rep find match char dump gmatch reverse byte format gsub lower table setn insert getn foreachi maxn foreach concat sort remove"},contains:a.concat([{className:"function",beginKeywords:"function",end:"\\)",contains:[e.inherit(e.TITLE_MODE,{begin:"([_a-zA-Z]\\w*\\.)*([_a-zA-Z]\\w*:)?[_a-zA-Z]\\w*"}),{className:"params",begin:"\\(",endsWithParent:!0,contains:a}].concat(a)},e.C_NUMBER_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{className:"string",begin:"\\[=*\\[",end:"\\]=*\\]",contains:[t],relevance:5}])}},Yo=function(e){var t={className:"variable",variants:[{begin:"\\$\\("+e.UNDERSCORE_IDENT_RE+"\\)",contains:[e.BACKSLASH_ESCAPE]},{begin:/\$[@%"},{begin:"<=",relevance:0},{begin:"=>",relevance:0},{begin:"/\\\\"},{begin:"\\\\/"}]},{className:"built_in",variants:[{begin:":-\\|-->"},{begin:"=",relevance:0}]},t,e.C_BLOCK_COMMENT_MODE,{className:"number",begin:"0'.\\|0[box][0-9a-fA-F]*"},e.NUMBER_MODE,a,n,{begin:/:-/},{begin:/\.$/}]}},Qo=function(e){return{name:"MIPS Assembly",case_insensitive:!0,aliases:["mips"],lexemes:"\\.?"+e.IDENT_RE,keywords:{meta:".2byte .4byte .align .ascii .asciz .balign .byte .code .data .else .end .endif .endm .endr .equ .err .exitm .extern .global .hword .if .ifdef .ifndef .include .irp .long .macro .rept .req .section .set .skip .space .text .word .ltorg ",built_in:"$0 $1 $2 $3 $4 $5 $6 $7 $8 $9 $10 $11 $12 $13 $14 $15 $16 $17 $18 $19 $20 $21 $22 $23 $24 $25 $26 $27 $28 $29 $30 $31 zero at v0 v1 a0 a1 a2 a3 a4 a5 a6 a7 t0 t1 t2 t3 t4 t5 t6 t7 t8 t9 s0 s1 s2 s3 s4 s5 s6 s7 s8 k0 k1 gp sp fp ra $f0 $f1 $f2 $f2 $f4 $f5 $f6 $f7 $f8 $f9 $f10 $f11 $f12 $f13 $f14 $f15 $f16 $f17 $f18 $f19 $f20 $f21 $f22 $f23 $f24 $f25 $f26 $f27 $f28 $f29 $f30 $f31 Context Random EntryLo0 EntryLo1 Context PageMask Wired EntryHi HWREna BadVAddr Count Compare SR IntCtl SRSCtl SRSMap Cause EPC PRId EBase Config Config1 Config2 Config3 LLAddr Debug DEPC DESAVE CacheErr ECC ErrorEPC TagLo DataLo TagHi DataHi WatchLo WatchHi PerfCtl PerfCnt "},contains:[{className:"keyword",begin:"\\b(addi?u?|andi?|b(al)?|beql?|bgez(al)?l?|bgtzl?|blezl?|bltz(al)?l?|bnel?|cl[oz]|divu?|ext|ins|j(al)?|jalr(.hb)?|jr(.hb)?|lbu?|lhu?|ll|lui|lw[lr]?|maddu?|mfhi|mflo|movn|movz|move|msubu?|mthi|mtlo|mul|multu?|nop|nor|ori?|rotrv?|sb|sc|se[bh]|sh|sllv?|slti?u?|srav?|srlv?|subu?|sw[lr]?|xori?|wsbh|abs.[sd]|add.[sd]|alnv.ps|bc1[ft]l?|c.(s?f|un|u?eq|[ou]lt|[ou]le|ngle?|seq|l[et]|ng[et]).[sd]|(ceil|floor|round|trunc).[lw].[sd]|cfc1|cvt.d.[lsw]|cvt.l.[dsw]|cvt.ps.s|cvt.s.[dlw]|cvt.s.p[lu]|cvt.w.[dls]|div.[ds]|ldx?c1|luxc1|lwx?c1|madd.[sd]|mfc1|mov[fntz]?.[ds]|msub.[sd]|mth?c1|mul.[ds]|neg.[ds]|nmadd.[ds]|nmsub.[ds]|p[lu][lu].ps|recip.fmt|r?sqrt.[ds]|sdx?c1|sub.[ds]|suxc1|swx?c1|break|cache|d?eret|[de]i|ehb|mfc0|mtc0|pause|prefx?|rdhwr|rdpgpr|sdbbp|ssnop|synci?|syscall|teqi?|tgei?u?|tlb(p|r|w[ir])|tlti?u?|tnei?|wait|wrpgpr)",end:"\\s"},e.COMMENT("[;#](?!s*$)","$"),e.C_BLOCK_COMMENT_MODE,e.QUOTE_STRING_MODE,{className:"string",begin:"'",end:"[^\\\\]'",relevance:0},{className:"title",begin:"\\|",end:"\\|",illegal:"\\n",relevance:0},{className:"number",variants:[{begin:"0x[0-9a-f]+"},{begin:"\\b-?\\d+"}],relevance:0},{className:"symbol",variants:[{begin:"^\\s*[a-z_\\.\\$][a-z0-9_\\.\\$]+:"},{begin:"^\\s*[0-9]+:"},{begin:"[0-9]+[bf]"}],relevance:0}],illegal:"/"}},$o=function(e){return{name:"Mizar",keywords:"environ vocabularies notations constructors definitions registrations theorems schemes requirements begin end definition registration cluster existence pred func defpred deffunc theorem proof let take assume then thus hence ex for st holds consider reconsider such that and in provided of as from be being by means equals implies iff redefine define now not or attr is mode suppose per cases set thesis contradiction scheme reserve struct correctness compatibility coherence symmetry assymetry reflexivity irreflexivity connectedness uniqueness commutativity idempotence involutiveness projectivity",contains:[e.COMMENT("::","$")]}},Ko=function(e){var t="getpwent getservent quotemeta msgrcv scalar kill dbmclose undef lc ma syswrite tr send umask sysopen shmwrite vec qx utime local oct semctl localtime readpipe do return format read sprintf dbmopen pop getpgrp not getpwnam rewinddir qq fileno qw endprotoent wait sethostent bless s|0 opendir continue each sleep endgrent shutdown dump chomp connect getsockname die socketpair close flock exists index shmget sub for endpwent redo lstat msgctl setpgrp abs exit select print ref gethostbyaddr unshift fcntl syscall goto getnetbyaddr join gmtime symlink semget splice x|0 getpeername recv log setsockopt cos last reverse gethostbyname getgrnam study formline endhostent times chop length gethostent getnetent pack getprotoent getservbyname rand mkdir pos chmod y|0 substr endnetent printf next open msgsnd readdir use unlink getsockopt getpriority rindex wantarray hex system getservbyport endservent int chr untie rmdir prototype tell listen fork shmread ucfirst setprotoent else sysseek link getgrgid shmctl waitpid unpack getnetbyname reset chdir grep split require caller lcfirst until warn while values shift telldir getpwuid my getprotobynumber delete and sort uc defined srand accept package seekdir getprotobyname semop our rename seek if q|0 chroot sysread setpwent no crypt getc chown sqrt write setnetent setpriority foreach tie sin msgget map stat getlogin unless elsif truncate exec keys glob tied closedir ioctl socket readlink eval xor readline binmode setservent eof ord bind alarm pipe atan2 getgrent exp time push setgrent gt lt or ne m|0 break given say state when",a={className:"subst",begin:"[$@]\\{",end:"\\}",keywords:t},n={begin:"->{",end:"}"},r={variants:[{begin:/\$\d/},{begin:/[\$%@](\^\w\b|#\w+(::\w+)*|{\w+}|\w+(::\w*)*)/},{begin:/[\$%@][^\s\w{]/,relevance:0}]},i=[e.BACKSLASH_ESCAPE,a,r],o=[r,e.HASH_COMMENT_MODE,e.COMMENT("^\\=\\w","\\=cut",{endsWithParent:!0}),n,{className:"string",contains:i,variants:[{begin:"q[qwxr]?\\s*\\(",end:"\\)",relevance:5},{begin:"q[qwxr]?\\s*\\[",end:"\\]",relevance:5},{begin:"q[qwxr]?\\s*\\{",end:"\\}",relevance:5},{begin:"q[qwxr]?\\s*\\|",end:"\\|",relevance:5},{begin:"q[qwxr]?\\s*\\<",end:"\\>",relevance:5},{begin:"qw\\s+q",end:"q",relevance:5},{begin:"'",end:"'",contains:[e.BACKSLASH_ESCAPE]},{begin:'"',end:'"'},{begin:"`",end:"`",contains:[e.BACKSLASH_ESCAPE]},{begin:"{\\w+}",contains:[],relevance:0},{begin:"-?\\w+\\s*\\=\\>",contains:[],relevance:0}]},{className:"number",begin:"(\\b0[0-7_]+)|(\\b0x[0-9a-fA-F_]+)|(\\b[1-9][0-9_]*(\\.[0-9_]+)?)|[0_]\\b",relevance:0},{begin:"(\\/\\/|"+e.RE_STARTERS_RE+"|\\b(split|return|print|reverse|grep)\\b)\\s*",keywords:"split return print reverse grep",relevance:0,contains:[e.HASH_COMMENT_MODE,{className:"regexp",begin:"(s|tr|y)/(\\\\.|[^/])*/(\\\\.|[^/])*/[a-z]*",relevance:10},{className:"regexp",begin:"(m|qr)?/",end:"/[a-z]*",contains:[e.BACKSLASH_ESCAPE],relevance:0}]},{className:"function",beginKeywords:"sub",end:"(\\s*\\(.*?\\))?[;{]",excludeEnd:!0,relevance:5,contains:[e.TITLE_MODE]},{begin:"-\\w\\b",relevance:0},{begin:"^__DATA__$",end:"^__END__$",subLanguage:"mojolicious",contains:[{begin:"^@@.*",end:"$",className:"comment"}]}];return a.contains=o,n.contains=o,{name:"Perl",aliases:["pl","pm"],lexemes:/[\w\.]+/,keywords:t,contains:o}},jo=function(){return{name:"Mojolicious",subLanguage:"xml",contains:[{className:"meta",begin:"^__(END|DATA)__$"},{begin:"^\\s*%{1,2}={0,2}",end:"$",subLanguage:"perl"},{begin:"<%{1,2}={0,2}",end:"={0,1}%>",subLanguage:"perl",excludeBegin:!0,excludeEnd:!0}]}},Xo=function(e){var t={className:"number",relevance:0,variants:[{begin:"[$][a-fA-F0-9]+"},e.NUMBER_MODE]};return{name:"Monkey",case_insensitive:!0,keywords:{keyword:"public private property continue exit extern new try catch eachin not abstract final select case default const local global field end if then else elseif endif while wend repeat until forever for to step next return module inline throw import",built_in:"DebugLog DebugStop Error Print ACos ACosr ASin ASinr ATan ATan2 ATan2r ATanr Abs Abs Ceil Clamp Clamp Cos Cosr Exp Floor Log Max Max Min Min Pow Sgn Sgn Sin Sinr Sqrt Tan Tanr Seed PI HALFPI TWOPI",literal:"true false null and or shl shr mod"},illegal:/\/\*/,contains:[e.COMMENT("#rem","#end"),e.COMMENT("'","$",{relevance:0}),{className:"function",beginKeywords:"function method",end:"[(=:]|$",illegal:/\n/,contains:[e.UNDERSCORE_TITLE_MODE]},{className:"class",beginKeywords:"class interface",end:"$",contains:[{beginKeywords:"extends implements"},e.UNDERSCORE_TITLE_MODE]},{className:"built_in",begin:"\\b(self|super)\\b"},{className:"meta",begin:"\\s*#",end:"$",keywords:{"meta-keyword":"if else elseif endif end then"}},{className:"meta",begin:"^\\s*strict\\b"},{beginKeywords:"alias",end:"=",contains:[e.UNDERSCORE_TITLE_MODE]},e.QUOTE_STRING_MODE,t]}},Zo=function(e){var t={keyword:"if then not for in while do return else elseif break continue switch and or unless when class extends super local import export from using",literal:"true false nil",built_in:"_G _VERSION assert collectgarbage dofile error getfenv getmetatable ipairs load loadfile loadstring module next pairs pcall print rawequal rawget rawset require select setfenv setmetatable tonumber tostring type unpack xpcall coroutine debug io math os package string table"},a="[A-Za-z$_][0-9A-Za-z$_]*",n={className:"subst",begin:/#\{/,end:/}/,keywords:t},r=[e.inherit(e.C_NUMBER_MODE,{starts:{end:"(\\s*/)?",relevance:0}}),{className:"string",variants:[{begin:/'/,end:/'/,contains:[e.BACKSLASH_ESCAPE]},{begin:/"/,end:/"/,contains:[e.BACKSLASH_ESCAPE,n]}]},{className:"built_in",begin:"@__"+e.IDENT_RE},{begin:"@"+e.IDENT_RE},{begin:e.IDENT_RE+"\\\\"+e.IDENT_RE}];n.contains=r;var i=e.inherit(e.TITLE_MODE,{begin:a}),o={className:"params",begin:"\\([^\\(]",returnBegin:!0,contains:[{begin:/\(/,end:/\)/,keywords:t,contains:["self"].concat(r)}]};return{name:"MoonScript",aliases:["moon"],keywords:t,illegal:/\/\*/,contains:r.concat([e.COMMENT("--","$"),{className:"function",begin:"^\\s*"+a+"\\s*=\\s*(\\(.*\\))?\\s*\\B[-=]>",end:"[-=]>",returnBegin:!0,contains:[i,o]},{begin:/[\(,:=]\s*/,relevance:0,contains:[{className:"function",begin:"(\\(.*\\))?\\s*\\B[-=]>",end:"[-=]>",returnBegin:!0,contains:[o]}]},{className:"class",beginKeywords:"class",end:"$",illegal:/[:="\[\]]/,contains:[{beginKeywords:"extends",endsWithParent:!0,illegal:/[:="\[\]]/,contains:[i]},i]},{className:"name",begin:a+":",end:":",returnBegin:!0,returnEnd:!0,relevance:0}])}},Jo=function(e){return{name:"N1QL",case_insensitive:!0,contains:[{beginKeywords:"build create index delete drop explain infer|10 insert merge prepare select update upsert|10",end:/;/,endsWithParent:!0,keywords:{keyword:"all alter analyze and any array as asc begin between binary boolean break bucket build by call case cast cluster collate collection commit connect continue correlate cover create database dataset datastore declare decrement delete derived desc describe distinct do drop each element else end every except exclude execute exists explain fetch first flatten for force from function grant group gsi having if ignore ilike in include increment index infer inline inner insert intersect into is join key keys keyspace known last left let letting like limit lsm map mapping matched materialized merge minus namespace nest not number object offset on option or order outer over parse partition password path pool prepare primary private privilege procedure public raw realm reduce rename return returning revoke right role rollback satisfies schema select self semi set show some start statistics string system then to transaction trigger truncate under union unique unknown unnest unset update upsert use user using validate value valued values via view when where while with within work xor",literal:"true false null missing|5",built_in:"array_agg array_append array_concat array_contains array_count array_distinct array_ifnull array_length array_max array_min array_position array_prepend array_put array_range array_remove array_repeat array_replace array_reverse array_sort array_sum avg count max min sum greatest least ifmissing ifmissingornull ifnull missingif nullif ifinf ifnan ifnanorinf naninf neginfif posinfif clock_millis clock_str date_add_millis date_add_str date_diff_millis date_diff_str date_part_millis date_part_str date_trunc_millis date_trunc_str duration_to_str millis str_to_millis millis_to_str millis_to_utc millis_to_zone_name now_millis now_str str_to_duration str_to_utc str_to_zone_name decode_json encode_json encoded_size poly_length base64 base64_encode base64_decode meta uuid abs acos asin atan atan2 ceil cos degrees e exp ln log floor pi power radians random round sign sin sqrt tan trunc object_length object_names object_pairs object_inner_pairs object_values object_inner_values object_add object_put object_remove object_unwrap regexp_contains regexp_like regexp_position regexp_replace contains initcap length lower ltrim position repeat replace rtrim split substr title trim upper isarray isatom isboolean isnumber isobject isstring type toarray toatom toboolean tonumber toobject tostring"},contains:[{className:"string",begin:"'",end:"'",contains:[e.BACKSLASH_ESCAPE],relevance:0},{className:"string",begin:'"',end:'"',contains:[e.BACKSLASH_ESCAPE],relevance:0},{className:"symbol",begin:"`",end:"`",contains:[e.BACKSLASH_ESCAPE],relevance:2},e.C_NUMBER_MODE,e.C_BLOCK_COMMENT_MODE]},e.C_BLOCK_COMMENT_MODE]}},es=function(e){var t={className:"variable",variants:[{begin:/\$\d+/},{begin:/\$\{/,end:/}/},{begin:"[\\$\\@]"+e.UNDERSCORE_IDENT_RE}]},a={endsWithParent:!0,lexemes:"[a-z/_]+",keywords:{literal:"on off yes no true false none blocked debug info notice warn error crit select break last permanent redirect kqueue rtsig epoll poll /dev/poll"},relevance:0,illegal:"=>",contains:[e.HASH_COMMENT_MODE,{className:"string",contains:[e.BACKSLASH_ESCAPE,t],variants:[{begin:/"/,end:/"/},{begin:/'/,end:/'/}]},{begin:"([a-z]+):/",end:"\\s",endsWithParent:!0,excludeEnd:!0,contains:[t]},{className:"regexp",contains:[e.BACKSLASH_ESCAPE,t],variants:[{begin:"\\s\\^",end:"\\s|{|;",returnEnd:!0},{begin:"~\\*?\\s+",end:"\\s|{|;",returnEnd:!0},{begin:"\\*(\\.[a-z\\-]+)+"},{begin:"([a-z\\-]+\\.)+\\*"}]},{className:"number",begin:"\\b\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}(:\\d{1,5})?\\b"},{className:"number",begin:"\\b\\d+[kKmMgGdshdwy]*\\b",relevance:0},t]};return{name:"Nginx config",aliases:["nginxconf"],contains:[e.HASH_COMMENT_MODE,{begin:e.UNDERSCORE_IDENT_RE+"\\s+{",returnBegin:!0,end:"{",contains:[{className:"section",begin:e.UNDERSCORE_IDENT_RE}],relevance:0},{begin:e.UNDERSCORE_IDENT_RE+"\\s",end:";|{",returnBegin:!0,contains:[{className:"attribute",begin:e.UNDERSCORE_IDENT_RE,starts:a}],relevance:0}],illegal:"[^\\s\\}]"}},ts=function(e){return{name:"Nim",aliases:["nim"],keywords:{keyword:"addr and as asm bind block break case cast const continue converter discard distinct div do elif else end enum except export finally for from func generic if import in include interface is isnot iterator let macro method mixin mod nil not notin object of or out proc ptr raise ref return shl shr static template try tuple type using var when while with without xor yield",literal:"shared guarded stdin stdout stderr result true false", +built_in:"int int8 int16 int32 int64 uint uint8 uint16 uint32 uint64 float float32 float64 bool char string cstring pointer expr stmt void auto any range array openarray varargs seq set clong culong cchar cschar cshort cint csize clonglong cfloat cdouble clongdouble cuchar cushort cuint culonglong cstringarray semistatic"},contains:[{className:"meta",begin:/{\./,end:/\.}/,relevance:10},{className:"string",begin:/[a-zA-Z]\w*"/,end:/"/,contains:[{begin:/""/}]},{className:"string",begin:/([a-zA-Z]\w*)?"""/,end:/"""/},e.QUOTE_STRING_MODE,{className:"type",begin:/\b[A-Z]\w+\b/,relevance:0},{className:"number",relevance:0,variants:[{begin:/\b(0[xX][0-9a-fA-F][_0-9a-fA-F]*)('?[iIuU](8|16|32|64))?/},{begin:/\b(0o[0-7][_0-7]*)('?[iIuUfF](8|16|32|64))?/},{begin:/\b(0(b|B)[01][_01]*)('?[iIuUfF](8|16|32|64))?/},{begin:/\b(\d[_\d]*)('?[iIuUfF](8|16|32|64))?/}]},e.HASH_COMMENT_MODE]}},as=function(e){var t={keyword:"rec with let in inherit assert if else then",literal:"true false or and null",built_in:"import abort baseNameOf dirOf isNull builtins map removeAttrs throw toString derivation"},a={className:"subst",begin:/\$\{/,end:/}/,keywords:t},n={className:"string",contains:[a],variants:[{begin:"''",end:"''"},{begin:'"',end:'"'}]},r=[e.NUMBER_MODE,e.HASH_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,n,{begin:/[a-zA-Z0-9-_]+(\s*=)/,returnBegin:!0,relevance:0,contains:[{className:"attr",begin:/\S+/}]}];return a.contains=r,{name:"Nix",aliases:["nixos"],keywords:t,contains:r}},ns=function(e){var t={className:"variable",begin:/\$+{[\w\.:-]+}/},a={className:"variable",begin:/\$+\w+/,illegal:/\(\){}/},n={className:"variable",begin:/\$+\([\w\^\.:-]+\)/},r={className:"string",variants:[{begin:'"',end:'"'},{begin:"'",end:"'"},{begin:"`",end:"`"}],illegal:/\n/,contains:[{className:"meta",begin:/\$(\\[nrt]|\$)/},{className:"variable",begin:/\$(ADMINTOOLS|APPDATA|CDBURN_AREA|CMDLINE|COMMONFILES32|COMMONFILES64|COMMONFILES|COOKIES|DESKTOP|DOCUMENTS|EXEDIR|EXEFILE|EXEPATH|FAVORITES|FONTS|HISTORY|HWNDPARENT|INSTDIR|INTERNET_CACHE|LANGUAGE|LOCALAPPDATA|MUSIC|NETHOOD|OUTDIR|PICTURES|PLUGINSDIR|PRINTHOOD|PROFILE|PROGRAMFILES32|PROGRAMFILES64|PROGRAMFILES|QUICKLAUNCH|RECENT|RESOURCES_LOCALIZED|RESOURCES|SENDTO|SMPROGRAMS|SMSTARTUP|STARTMENU|SYSDIR|TEMP|TEMPLATES|VIDEOS|WINDIR)/},t,a,n]};return{name:"NSIS",case_insensitive:!1,keywords:{keyword:"Abort AddBrandingImage AddSize AllowRootDirInstall AllowSkipFiles AutoCloseWindow BGFont BGGradient BrandingText BringToFront Call CallInstDLL Caption ChangeUI CheckBitmap ClearErrors CompletedText ComponentText CopyFiles CRCCheck CreateDirectory CreateFont CreateShortCut Delete DeleteINISec DeleteINIStr DeleteRegKey DeleteRegValue DetailPrint DetailsButtonText DirText DirVar DirVerify EnableWindow EnumRegKey EnumRegValue Exch Exec ExecShell ExecShellWait ExecWait ExpandEnvStrings File FileBufSize FileClose FileErrorText FileOpen FileRead FileReadByte FileReadUTF16LE FileReadWord FileSeek FileWrite FileWriteByte FileWriteUTF16LE FileWriteWord FindClose FindFirst FindNext FindWindow FlushINI FunctionEnd GetCurInstType GetCurrentAddress GetDlgItem GetDLLVersion GetDLLVersionLocal GetErrorLevel GetFileTime GetFileTimeLocal GetFullPathName GetFunctionAddress GetInstDirError GetLabelAddress GetTempFileName Goto HideWindow Icon IfAbort IfErrors IfFileExists IfRebootFlag IfSilent InitPluginsDir InstallButtonText InstallColors InstallDir InstallDirRegKey InstProgressFlags InstType InstTypeGetText InstTypeSetText Int64Cmp Int64CmpU Int64Fmt IntCmp IntCmpU IntFmt IntOp IntPtrCmp IntPtrCmpU IntPtrOp IsWindow LangString LicenseBkColor LicenseData LicenseForceSelection LicenseLangString LicenseText LoadLanguageFile LockWindow LogSet LogText ManifestDPIAware ManifestSupportedOS MessageBox MiscButtonText Name Nop OutFile Page PageCallbacks PageExEnd Pop Push Quit ReadEnvStr ReadINIStr ReadRegDWORD ReadRegStr Reboot RegDLL Rename RequestExecutionLevel ReserveFile Return RMDir SearchPath SectionEnd SectionGetFlags SectionGetInstTypes SectionGetSize SectionGetText SectionGroupEnd SectionIn SectionSetFlags SectionSetInstTypes SectionSetSize SectionSetText SendMessage SetAutoClose SetBrandingImage SetCompress SetCompressor SetCompressorDictSize SetCtlColors SetCurInstType SetDatablockOptimize SetDateSave SetDetailsPrint SetDetailsView SetErrorLevel SetErrors SetFileAttributes SetFont SetOutPath SetOverwrite SetRebootFlag SetRegView SetShellVarContext SetSilent ShowInstDetails ShowUninstDetails ShowWindow SilentInstall SilentUnInstall Sleep SpaceTexts StrCmp StrCmpS StrCpy StrLen SubCaption Unicode UninstallButtonText UninstallCaption UninstallIcon UninstallSubCaption UninstallText UninstPage UnRegDLL Var VIAddVersionKey VIFileVersion VIProductVersion WindowIcon WriteINIStr WriteRegBin WriteRegDWORD WriteRegExpandStr WriteRegMultiStr WriteRegNone WriteRegStr WriteUninstaller XPStyle",literal:"admin all auto both bottom bzip2 colored components current custom directory false force hide highest ifdiff ifnewer instfiles lastused leave left license listonly lzma nevershow none normal notset off on open print right show silent silentlog smooth textonly top true try un.components un.custom un.directory un.instfiles un.license uninstConfirm user Win10 Win7 Win8 WinVista zlib"},contains:[e.HASH_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.COMMENT(";","$",{relevance:0}),{className:"function",beginKeywords:"Function PageEx Section SectionGroup",end:"$"},r,{className:"keyword",begin:/\!(addincludedir|addplugindir|appendfile|cd|define|delfile|echo|else|endif|error|execute|finalize|getdllversion|gettlbversion|if|ifdef|ifmacrodef|ifmacrondef|ifndef|include|insertmacro|macro|macroend|makensis|packhdr|searchparse|searchreplace|system|tempfile|undef|verbose|warning)/},t,a,n,{className:"params",begin:"(ARCHIVE|FILE_ATTRIBUTE_ARCHIVE|FILE_ATTRIBUTE_NORMAL|FILE_ATTRIBUTE_OFFLINE|FILE_ATTRIBUTE_READONLY|FILE_ATTRIBUTE_SYSTEM|FILE_ATTRIBUTE_TEMPORARY|HKCR|HKCU|HKDD|HKEY_CLASSES_ROOT|HKEY_CURRENT_CONFIG|HKEY_CURRENT_USER|HKEY_DYN_DATA|HKEY_LOCAL_MACHINE|HKEY_PERFORMANCE_DATA|HKEY_USERS|HKLM|HKPD|HKU|IDABORT|IDCANCEL|IDIGNORE|IDNO|IDOK|IDRETRY|IDYES|MB_ABORTRETRYIGNORE|MB_DEFBUTTON1|MB_DEFBUTTON2|MB_DEFBUTTON3|MB_DEFBUTTON4|MB_ICONEXCLAMATION|MB_ICONINFORMATION|MB_ICONQUESTION|MB_ICONSTOP|MB_OK|MB_OKCANCEL|MB_RETRYCANCEL|MB_RIGHT|MB_RTLREADING|MB_SETFOREGROUND|MB_TOPMOST|MB_USERICON|MB_YESNO|NORMAL|OFFLINE|READONLY|SHCTX|SHELL_CONTEXT|SYSTEM|TEMPORARY)"},{className:"class",begin:/\w+\:\:\w+/},e.NUMBER_MODE]}},rs=function(e){var t=/[a-zA-Z@][a-zA-Z0-9_]*/,a="@interface @class @protocol @implementation";return{name:"Objective-C",aliases:["mm","objc","obj-c"],keywords:{keyword:"int float while char export sizeof typedef const struct for union unsigned long volatile static bool mutable if do return goto void enum else break extern asm case short default double register explicit signed typename this switch continue wchar_t inline readonly assign readwrite self @synchronized id typeof nonatomic super unichar IBOutlet IBAction strong weak copy in out inout bycopy byref oneway __strong __weak __block __autoreleasing @private @protected @public @try @property @end @throw @catch @finally @autoreleasepool @synthesize @dynamic @selector @optional @required @encode @package @import @defs @compatibility_alias __bridge __bridge_transfer __bridge_retained __bridge_retain __covariant __contravariant __kindof _Nonnull _Nullable _Null_unspecified __FUNCTION__ __PRETTY_FUNCTION__ __attribute__ getter setter retain unsafe_unretained nonnull nullable null_unspecified null_resettable class instancetype NS_DESIGNATED_INITIALIZER NS_UNAVAILABLE NS_REQUIRES_SUPER NS_RETURNS_INNER_POINTER NS_INLINE NS_AVAILABLE NS_DEPRECATED NS_ENUM NS_OPTIONS NS_SWIFT_UNAVAILABLE NS_ASSUME_NONNULL_BEGIN NS_ASSUME_NONNULL_END NS_REFINED_FOR_SWIFT NS_SWIFT_NAME NS_SWIFT_NOTHROW NS_DURING NS_HANDLER NS_ENDHANDLER NS_VALUERETURN NS_VOIDRETURN",literal:"false true FALSE TRUE nil YES NO NULL",built_in:"BOOL dispatch_once_t dispatch_queue_t dispatch_sync dispatch_async dispatch_once"},lexemes:t,illegal:"/,end:/$/,illegal:"\\n"},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{className:"class",begin:"("+a.split(" ").join("|")+")\\b",end:"({|$)",excludeEnd:!0,keywords:a,lexemes:t,contains:[e.UNDERSCORE_TITLE_MODE]},{begin:"\\."+e.UNDERSCORE_IDENT_RE,relevance:0}]}},is=function(e){return{name:"OCaml",aliases:["ml"],keywords:{keyword:"and as assert asr begin class constraint do done downto else end exception external for fun function functor if in include inherit! inherit initializer land lazy let lor lsl lsr lxor match method!|10 method mod module mutable new object of open! open or private rec sig struct then to try type val! val virtual when while with parser value",built_in:"array bool bytes char exn|5 float int int32 int64 list lazy_t|5 nativeint|5 string unit in_channel out_channel ref",literal:"true false"},illegal:/\/\/|>>/,lexemes:"[a-z_]\\w*!?",contains:[{className:"literal",begin:"\\[(\\|\\|)?\\]|\\(\\)",relevance:0},e.COMMENT("\\(\\*","\\*\\)",{contains:["self"]}),{className:"symbol",begin:"'[A-Za-z_](?!')[\\w']*"},{className:"type",begin:"`[A-Z][\\w']*"},{className:"type",begin:"\\b[A-Z][\\w']*",relevance:0},{begin:"[a-z_]\\w*'[\\w']*",relevance:0},e.inherit(e.APOS_STRING_MODE,{className:"string",relevance:0}),e.inherit(e.QUOTE_STRING_MODE,{illegal:null}),{className:"number",begin:"\\b(0[xX][a-fA-F0-9_]+[Lln]?|0[oO][0-7_]+[Lln]?|0[bB][01_]+[Lln]?|[0-9][0-9_]*([Lln]|(\\.[0-9_]*)?([eE][-+]?[0-9_]+)?)?)",relevance:0},{begin:/[-=]>/}]}},os=function(e){var t={className:"keyword",begin:"\\$(f[asn]|t|vp[rtd]|children)"},a={className:"number",begin:"\\b\\d+(\\.\\d+)?(e-?\\d+)?",relevance:0},n=e.inherit(e.QUOTE_STRING_MODE,{illegal:null}),r={className:"function",beginKeywords:"module function",end:"\\=|\\{",contains:[{className:"params",begin:"\\(",end:"\\)",contains:["self",a,n,t,{className:"literal",begin:"false|true|PI|undef"}]},e.UNDERSCORE_TITLE_MODE]};return{name:"OpenSCAD",aliases:["scad"],keywords:{keyword:"function module include use for intersection_for if else \\%",literal:"false true PI undef",built_in:"circle square polygon text sphere cube cylinder polyhedron translate rotate scale resize mirror multmatrix color offset hull minkowski union difference intersection abs sign sin cos tan acos asin atan atan2 floor round ceil ln log pow sqrt exp rands min max concat lookup str chr search version version_num norm cross parent_module echo import import_dxf dxf_linear_extrude linear_extrude rotate_extrude surface projection render children dxf_cross dxf_dim let assign"},contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,a,{className:"meta",keywords:{"meta-keyword":"include use"},begin:"include|use <",end:">"},n,t,{begin:"[*!#%]",relevance:0},r]}},ss=function(e){var t="abstract add and array as asc aspect assembly async begin break block by case class concat const copy constructor continue create default delegate desc distinct div do downto dynamic each else empty end ensure enum equals event except exit extension external false final finalize finalizer finally flags for forward from function future global group has if implementation implements implies in index inherited inline interface into invariants is iterator join locked locking loop matching method mod module namespace nested new nil not notify nullable of old on operator or order out override parallel params partial pinned private procedure property protected public queryable raise read readonly record reintroduce remove repeat require result reverse sealed select self sequence set shl shr skip static step soft take then to true try tuple type union unit unsafe until uses using var virtual raises volatile where while with write xor yield await mapped deprecated stdcall cdecl pascal register safecall overload library platform reference packed strict published autoreleasepool selector strong weak unretained",a=e.COMMENT("{","}",{relevance:0}),n=e.COMMENT("\\(\\*","\\*\\)",{relevance:10}),r={className:"string",begin:"'",end:"'",contains:[{begin:"''"}]},i={className:"string",begin:"(#\\d+)+"},o={className:"function",beginKeywords:"function constructor destructor procedure method",end:"[:;]",keywords:"function constructor|10 destructor|10 procedure|10 method|10",contains:[e.TITLE_MODE,{className:"params",begin:"\\(",end:"\\)",keywords:t,contains:[r,i]},a,n]};return{name:"Oxygene",case_insensitive:!0,lexemes:/\.?\w+/,keywords:t,illegal:'("|\\$[G-Zg-z]|\\/\\*||->)',contains:[a,n,e.C_LINE_COMMENT_MODE,r,i,e.NUMBER_MODE,o,{className:"class",begin:"=\\bclass\\b",end:"end;",keywords:t,contains:[r,i,a,n,e.C_LINE_COMMENT_MODE,o]}]}},ls=function(e){var t=e.COMMENT("{","}",{contains:["self"]});return{name:"Parser3",subLanguage:"xml",relevance:0,contains:[e.COMMENT("^#","$"),e.COMMENT("\\^rem{","}",{relevance:10,contains:[t]}),{className:"meta",begin:"^@(?:BASE|USE|CLASS|OPTIONS)$",relevance:10},{className:"title",begin:"@[\\w\\-]+\\[[\\w^;\\-]*\\](?:\\[[\\w^;\\-]*\\])?(?:.*)$"},{className:"variable",begin:"\\$\\{?[\\w\\-\\.\\:]+\\}?"},{className:"keyword",begin:"\\^[\\w\\-\\.\\:]+"},{className:"number",begin:"\\^#[0-9a-fA-F]+"},e.C_NUMBER_MODE]}},_s=function(e){return{name:"Packet Filter config",aliases:["pf.conf"],lexemes:/[a-z0-9_<>-]+/,keywords:{built_in:"block match pass load anchor|5 antispoof|10 set table",keyword:"in out log quick on rdomain inet inet6 proto from port os to route allow-opts divert-packet divert-reply divert-to flags group icmp-type icmp6-type label once probability recieved-on rtable prio queue tos tag tagged user keep fragment for os drop af-to|10 binat-to|10 nat-to|10 rdr-to|10 bitmask least-stats random round-robin source-hash static-port dup-to reply-to route-to parent bandwidth default min max qlimit block-policy debug fingerprints hostid limit loginterface optimization reassemble ruleset-optimization basic none profile skip state-defaults state-policy timeout const counters persist no modulate synproxy state|5 floating if-bound no-sync pflow|10 sloppy source-track global rule max-src-nodes max-src-states max-src-conn max-src-conn-rate overload flush scrub|5 max-mss min-ttl no-df|10 random-id",literal:"all any no-route self urpf-failed egress|5 unknown"},contains:[e.HASH_COMMENT_MODE,e.NUMBER_MODE,e.QUOTE_STRING_MODE,{className:"variable",begin:/\$[\w\d#@][\w\d_]*/},{className:"variable",begin:/<(?!\/)/,end:/>/}]}},cs=function(e){var t=e.COMMENT("--","$"),a="BIGINT INT8 BIGSERIAL SERIAL8 BIT VARYING VARBIT BOOLEAN BOOL BOX BYTEA CHARACTER CHAR VARCHAR CIDR CIRCLE DATE DOUBLE PRECISION FLOAT8 FLOAT INET INTEGER INT INT4 INTERVAL JSON JSONB LINE LSEG|10 MACADDR MACADDR8 MONEY NUMERIC DEC DECIMAL PATH POINT POLYGON REAL FLOAT4 SMALLINT INT2 SMALLSERIAL|10 SERIAL2|10 SERIAL|10 SERIAL4|10 TEXT TIME ZONE TIMETZ|10 TIMESTAMP TIMESTAMPTZ|10 TSQUERY|10 TSVECTOR|10 TXID_SNAPSHOT|10 UUID XML NATIONAL NCHAR INT4RANGE|10 INT8RANGE|10 NUMRANGE|10 TSRANGE|10 TSTZRANGE|10 DATERANGE|10 ANYELEMENT ANYARRAY ANYNONARRAY ANYENUM ANYRANGE CSTRING INTERNAL RECORD PG_DDL_COMMAND VOID UNKNOWN OPAQUE REFCURSOR NAME OID REGPROC|10 REGPROCEDURE|10 REGOPER|10 REGOPERATOR|10 REGCLASS|10 REGTYPE|10 REGROLE|10 REGNAMESPACE|10 REGCONFIG|10 REGDICTIONARY|10 ",n=a.trim().split(" ").map(function(e){return e.split("|")[0]}).join("|"),r="ARRAY_AGG AVG BIT_AND BIT_OR BOOL_AND BOOL_OR COUNT EVERY JSON_AGG JSONB_AGG JSON_OBJECT_AGG JSONB_OBJECT_AGG MAX MIN MODE STRING_AGG SUM XMLAGG CORR COVAR_POP COVAR_SAMP REGR_AVGX REGR_AVGY REGR_COUNT REGR_INTERCEPT REGR_R2 REGR_SLOPE REGR_SXX REGR_SXY REGR_SYY STDDEV STDDEV_POP STDDEV_SAMP VARIANCE VAR_POP VAR_SAMP PERCENTILE_CONT PERCENTILE_DISC ROW_NUMBER RANK DENSE_RANK PERCENT_RANK CUME_DIST NTILE LAG LEAD FIRST_VALUE LAST_VALUE NTH_VALUE NUM_NONNULLS NUM_NULLS ABS CBRT CEIL CEILING DEGREES DIV EXP FLOOR LN LOG MOD PI POWER RADIANS ROUND SCALE SIGN SQRT TRUNC WIDTH_BUCKET RANDOM SETSEED ACOS ACOSD ASIN ASIND ATAN ATAND ATAN2 ATAN2D COS COSD COT COTD SIN SIND TAN TAND BIT_LENGTH CHAR_LENGTH CHARACTER_LENGTH LOWER OCTET_LENGTH OVERLAY POSITION SUBSTRING TREAT TRIM UPPER ASCII BTRIM CHR CONCAT CONCAT_WS CONVERT CONVERT_FROM CONVERT_TO DECODE ENCODE INITCAP LEFT LENGTH LPAD LTRIM MD5 PARSE_IDENT PG_CLIENT_ENCODING QUOTE_IDENT|10 QUOTE_LITERAL|10 QUOTE_NULLABLE|10 REGEXP_MATCH REGEXP_MATCHES REGEXP_REPLACE REGEXP_SPLIT_TO_ARRAY REGEXP_SPLIT_TO_TABLE REPEAT REPLACE REVERSE RIGHT RPAD RTRIM SPLIT_PART STRPOS SUBSTR TO_ASCII TO_HEX TRANSLATE OCTET_LENGTH GET_BIT GET_BYTE SET_BIT SET_BYTE TO_CHAR TO_DATE TO_NUMBER TO_TIMESTAMP AGE CLOCK_TIMESTAMP|10 DATE_PART DATE_TRUNC ISFINITE JUSTIFY_DAYS JUSTIFY_HOURS JUSTIFY_INTERVAL MAKE_DATE MAKE_INTERVAL|10 MAKE_TIME MAKE_TIMESTAMP|10 MAKE_TIMESTAMPTZ|10 NOW STATEMENT_TIMESTAMP|10 TIMEOFDAY TRANSACTION_TIMESTAMP|10 ENUM_FIRST ENUM_LAST ENUM_RANGE AREA CENTER DIAMETER HEIGHT ISCLOSED ISOPEN NPOINTS PCLOSE POPEN RADIUS WIDTH BOX BOUND_BOX CIRCLE LINE LSEG PATH POLYGON ABBREV BROADCAST HOST HOSTMASK MASKLEN NETMASK NETWORK SET_MASKLEN TEXT INET_SAME_FAMILY INET_MERGE MACADDR8_SET7BIT ARRAY_TO_TSVECTOR GET_CURRENT_TS_CONFIG NUMNODE PLAINTO_TSQUERY PHRASETO_TSQUERY WEBSEARCH_TO_TSQUERY QUERYTREE SETWEIGHT STRIP TO_TSQUERY TO_TSVECTOR JSON_TO_TSVECTOR JSONB_TO_TSVECTOR TS_DELETE TS_FILTER TS_HEADLINE TS_RANK TS_RANK_CD TS_REWRITE TSQUERY_PHRASE TSVECTOR_TO_ARRAY TSVECTOR_UPDATE_TRIGGER TSVECTOR_UPDATE_TRIGGER_COLUMN XMLCOMMENT XMLCONCAT XMLELEMENT XMLFOREST XMLPI XMLROOT XMLEXISTS XML_IS_WELL_FORMED XML_IS_WELL_FORMED_DOCUMENT XML_IS_WELL_FORMED_CONTENT XPATH XPATH_EXISTS XMLTABLE XMLNAMESPACES TABLE_TO_XML TABLE_TO_XMLSCHEMA TABLE_TO_XML_AND_XMLSCHEMA QUERY_TO_XML QUERY_TO_XMLSCHEMA QUERY_TO_XML_AND_XMLSCHEMA CURSOR_TO_XML CURSOR_TO_XMLSCHEMA SCHEMA_TO_XML SCHEMA_TO_XMLSCHEMA SCHEMA_TO_XML_AND_XMLSCHEMA DATABASE_TO_XML DATABASE_TO_XMLSCHEMA DATABASE_TO_XML_AND_XMLSCHEMA XMLATTRIBUTES TO_JSON TO_JSONB ARRAY_TO_JSON ROW_TO_JSON JSON_BUILD_ARRAY JSONB_BUILD_ARRAY JSON_BUILD_OBJECT JSONB_BUILD_OBJECT JSON_OBJECT JSONB_OBJECT JSON_ARRAY_LENGTH JSONB_ARRAY_LENGTH JSON_EACH JSONB_EACH JSON_EACH_TEXT JSONB_EACH_TEXT JSON_EXTRACT_PATH JSONB_EXTRACT_PATH JSON_OBJECT_KEYS JSONB_OBJECT_KEYS JSON_POPULATE_RECORD JSONB_POPULATE_RECORD JSON_POPULATE_RECORDSET JSONB_POPULATE_RECORDSET JSON_ARRAY_ELEMENTS JSONB_ARRAY_ELEMENTS JSON_ARRAY_ELEMENTS_TEXT JSONB_ARRAY_ELEMENTS_TEXT JSON_TYPEOF JSONB_TYPEOF JSON_TO_RECORD JSONB_TO_RECORD JSON_TO_RECORDSET JSONB_TO_RECORDSET JSON_STRIP_NULLS JSONB_STRIP_NULLS JSONB_SET JSONB_INSERT JSONB_PRETTY CURRVAL LASTVAL NEXTVAL SETVAL COALESCE NULLIF GREATEST LEAST ARRAY_APPEND ARRAY_CAT ARRAY_NDIMS ARRAY_DIMS ARRAY_FILL ARRAY_LENGTH ARRAY_LOWER ARRAY_POSITION ARRAY_POSITIONS ARRAY_PREPEND ARRAY_REMOVE ARRAY_REPLACE ARRAY_TO_STRING ARRAY_UPPER CARDINALITY STRING_TO_ARRAY UNNEST ISEMPTY LOWER_INC UPPER_INC LOWER_INF UPPER_INF RANGE_MERGE GENERATE_SERIES GENERATE_SUBSCRIPTS CURRENT_DATABASE CURRENT_QUERY CURRENT_SCHEMA|10 CURRENT_SCHEMAS|10 INET_CLIENT_ADDR INET_CLIENT_PORT INET_SERVER_ADDR INET_SERVER_PORT ROW_SECURITY_ACTIVE FORMAT_TYPE TO_REGCLASS TO_REGPROC TO_REGPROCEDURE TO_REGOPER TO_REGOPERATOR TO_REGTYPE TO_REGNAMESPACE TO_REGROLE COL_DESCRIPTION OBJ_DESCRIPTION SHOBJ_DESCRIPTION TXID_CURRENT TXID_CURRENT_IF_ASSIGNED TXID_CURRENT_SNAPSHOT TXID_SNAPSHOT_XIP TXID_SNAPSHOT_XMAX TXID_SNAPSHOT_XMIN TXID_VISIBLE_IN_SNAPSHOT TXID_STATUS CURRENT_SETTING SET_CONFIG BRIN_SUMMARIZE_NEW_VALUES BRIN_SUMMARIZE_RANGE BRIN_DESUMMARIZE_RANGE GIN_CLEAN_PENDING_LIST SUPPRESS_REDUNDANT_UPDATES_TRIGGER LO_FROM_BYTEA LO_PUT LO_GET LO_CREAT LO_CREATE LO_UNLINK LO_IMPORT LO_EXPORT LOREAD LOWRITE GROUPING CAST ".trim().split(" ").map(function(e){return e.split("|")[0]}).join("|");return{name:"PostgreSQL",aliases:["postgres","postgresql"],case_insensitive:!0,keywords:{keyword:"ABORT ALTER ANALYZE BEGIN CALL CHECKPOINT|10 CLOSE CLUSTER COMMENT COMMIT COPY CREATE DEALLOCATE DECLARE DELETE DISCARD DO DROP END EXECUTE EXPLAIN FETCH GRANT IMPORT INSERT LISTEN LOAD LOCK MOVE NOTIFY PREPARE REASSIGN|10 REFRESH REINDEX RELEASE RESET REVOKE ROLLBACK SAVEPOINT SECURITY SELECT SET SHOW START TRUNCATE UNLISTEN|10 UPDATE VACUUM|10 VALUES AGGREGATE COLLATION CONVERSION|10 DATABASE DEFAULT PRIVILEGES DOMAIN TRIGGER EXTENSION FOREIGN WRAPPER|10 TABLE FUNCTION GROUP LANGUAGE LARGE OBJECT MATERIALIZED VIEW OPERATOR CLASS FAMILY POLICY PUBLICATION|10 ROLE RULE SCHEMA SEQUENCE SERVER STATISTICS SUBSCRIPTION SYSTEM TABLESPACE CONFIGURATION DICTIONARY PARSER TEMPLATE TYPE USER MAPPING PREPARED ACCESS METHOD CAST AS TRANSFORM TRANSACTION OWNED TO INTO SESSION AUTHORIZATION INDEX PROCEDURE ASSERTION ALL ANALYSE AND ANY ARRAY ASC ASYMMETRIC|10 BOTH CASE CHECK COLLATE COLUMN CONCURRENTLY|10 CONSTRAINT CROSS DEFERRABLE RANGE DESC DISTINCT ELSE EXCEPT FOR FREEZE|10 FROM FULL HAVING ILIKE IN INITIALLY INNER INTERSECT IS ISNULL JOIN LATERAL LEADING LIKE LIMIT NATURAL NOT NOTNULL NULL OFFSET ON ONLY OR ORDER OUTER OVERLAPS PLACING PRIMARY REFERENCES RETURNING SIMILAR SOME SYMMETRIC TABLESAMPLE THEN TRAILING UNION UNIQUE USING VARIADIC|10 VERBOSE WHEN WHERE WINDOW WITH BY RETURNS INOUT OUT SETOF|10 IF STRICT CURRENT CONTINUE OWNER LOCATION OVER PARTITION WITHIN BETWEEN ESCAPE EXTERNAL INVOKER DEFINER WORK RENAME VERSION CONNECTION CONNECT TABLES TEMP TEMPORARY FUNCTIONS SEQUENCES TYPES SCHEMAS OPTION CASCADE RESTRICT ADD ADMIN EXISTS VALID VALIDATE ENABLE DISABLE REPLICA|10 ALWAYS PASSING COLUMNS PATH REF VALUE OVERRIDING IMMUTABLE STABLE VOLATILE BEFORE AFTER EACH ROW PROCEDURAL ROUTINE NO HANDLER VALIDATOR OPTIONS STORAGE OIDS|10 WITHOUT INHERIT DEPENDS CALLED INPUT LEAKPROOF|10 COST ROWS NOWAIT SEARCH UNTIL ENCRYPTED|10 PASSWORD CONFLICT|10 INSTEAD INHERITS CHARACTERISTICS WRITE CURSOR ALSO STATEMENT SHARE EXCLUSIVE INLINE ISOLATION REPEATABLE READ COMMITTED SERIALIZABLE UNCOMMITTED LOCAL GLOBAL SQL PROCEDURES RECURSIVE SNAPSHOT ROLLUP CUBE TRUSTED|10 INCLUDE FOLLOWING PRECEDING UNBOUNDED RANGE GROUPS UNENCRYPTED|10 SYSID FORMAT DELIMITER HEADER QUOTE ENCODING FILTER OFF FORCE_QUOTE FORCE_NOT_NULL FORCE_NULL COSTS BUFFERS TIMING SUMMARY DISABLE_PAGE_SKIPPING RESTART CYCLE GENERATED IDENTITY DEFERRED IMMEDIATE LEVEL LOGGED UNLOGGED OF NOTHING NONE EXCLUDE ATTRIBUTE USAGE ROUTINES TRUE FALSE NAN INFINITY ALIAS BEGIN CONSTANT DECLARE END EXCEPTION RETURN PERFORM|10 RAISE GET DIAGNOSTICS STACKED|10 FOREACH LOOP ELSIF EXIT WHILE REVERSE SLICE DEBUG LOG INFO NOTICE WARNING ASSERT OPEN SUPERUSER NOSUPERUSER CREATEDB NOCREATEDB CREATEROLE NOCREATEROLE INHERIT NOINHERIT LOGIN NOLOGIN REPLICATION NOREPLICATION BYPASSRLS NOBYPASSRLS ",built_in:"CURRENT_TIME CURRENT_TIMESTAMP CURRENT_USER CURRENT_CATALOG|10 CURRENT_DATE LOCALTIME LOCALTIMESTAMP CURRENT_ROLE|10 CURRENT_SCHEMA|10 SESSION_USER PUBLIC FOUND NEW OLD TG_NAME|10 TG_WHEN|10 TG_LEVEL|10 TG_OP|10 TG_RELID|10 TG_RELNAME|10 TG_TABLE_NAME|10 TG_TABLE_SCHEMA|10 TG_NARGS|10 TG_ARGV|10 TG_EVENT|10 TG_TAG|10 ROW_COUNT RESULT_OID|10 PG_CONTEXT|10 RETURNED_SQLSTATE COLUMN_NAME CONSTRAINT_NAME PG_DATATYPE_NAME|10 MESSAGE_TEXT TABLE_NAME SCHEMA_NAME PG_EXCEPTION_DETAIL|10 PG_EXCEPTION_HINT|10 PG_EXCEPTION_CONTEXT|10 SQLSTATE SQLERRM|10 SUCCESSFUL_COMPLETION WARNING DYNAMIC_RESULT_SETS_RETURNED IMPLICIT_ZERO_BIT_PADDING NULL_VALUE_ELIMINATED_IN_SET_FUNCTION PRIVILEGE_NOT_GRANTED PRIVILEGE_NOT_REVOKED STRING_DATA_RIGHT_TRUNCATION DEPRECATED_FEATURE NO_DATA NO_ADDITIONAL_DYNAMIC_RESULT_SETS_RETURNED SQL_STATEMENT_NOT_YET_COMPLETE CONNECTION_EXCEPTION CONNECTION_DOES_NOT_EXIST CONNECTION_FAILURE SQLCLIENT_UNABLE_TO_ESTABLISH_SQLCONNECTION SQLSERVER_REJECTED_ESTABLISHMENT_OF_SQLCONNECTION TRANSACTION_RESOLUTION_UNKNOWN PROTOCOL_VIOLATION TRIGGERED_ACTION_EXCEPTION FEATURE_NOT_SUPPORTED INVALID_TRANSACTION_INITIATION LOCATOR_EXCEPTION INVALID_LOCATOR_SPECIFICATION INVALID_GRANTOR INVALID_GRANT_OPERATION INVALID_ROLE_SPECIFICATION DIAGNOSTICS_EXCEPTION STACKED_DIAGNOSTICS_ACCESSED_WITHOUT_ACTIVE_HANDLER CASE_NOT_FOUND CARDINALITY_VIOLATION DATA_EXCEPTION ARRAY_SUBSCRIPT_ERROR CHARACTER_NOT_IN_REPERTOIRE DATETIME_FIELD_OVERFLOW DIVISION_BY_ZERO ERROR_IN_ASSIGNMENT ESCAPE_CHARACTER_CONFLICT INDICATOR_OVERFLOW INTERVAL_FIELD_OVERFLOW INVALID_ARGUMENT_FOR_LOGARITHM INVALID_ARGUMENT_FOR_NTILE_FUNCTION INVALID_ARGUMENT_FOR_NTH_VALUE_FUNCTION INVALID_ARGUMENT_FOR_POWER_FUNCTION INVALID_ARGUMENT_FOR_WIDTH_BUCKET_FUNCTION INVALID_CHARACTER_VALUE_FOR_CAST INVALID_DATETIME_FORMAT INVALID_ESCAPE_CHARACTER INVALID_ESCAPE_OCTET INVALID_ESCAPE_SEQUENCE NONSTANDARD_USE_OF_ESCAPE_CHARACTER INVALID_INDICATOR_PARAMETER_VALUE INVALID_PARAMETER_VALUE INVALID_REGULAR_EXPRESSION INVALID_ROW_COUNT_IN_LIMIT_CLAUSE INVALID_ROW_COUNT_IN_RESULT_OFFSET_CLAUSE INVALID_TABLESAMPLE_ARGUMENT INVALID_TABLESAMPLE_REPEAT INVALID_TIME_ZONE_DISPLACEMENT_VALUE INVALID_USE_OF_ESCAPE_CHARACTER MOST_SPECIFIC_TYPE_MISMATCH NULL_VALUE_NOT_ALLOWED NULL_VALUE_NO_INDICATOR_PARAMETER NUMERIC_VALUE_OUT_OF_RANGE SEQUENCE_GENERATOR_LIMIT_EXCEEDED STRING_DATA_LENGTH_MISMATCH STRING_DATA_RIGHT_TRUNCATION SUBSTRING_ERROR TRIM_ERROR UNTERMINATED_C_STRING ZERO_LENGTH_CHARACTER_STRING FLOATING_POINT_EXCEPTION INVALID_TEXT_REPRESENTATION INVALID_BINARY_REPRESENTATION BAD_COPY_FILE_FORMAT UNTRANSLATABLE_CHARACTER NOT_AN_XML_DOCUMENT INVALID_XML_DOCUMENT INVALID_XML_CONTENT INVALID_XML_COMMENT INVALID_XML_PROCESSING_INSTRUCTION INTEGRITY_CONSTRAINT_VIOLATION RESTRICT_VIOLATION NOT_NULL_VIOLATION FOREIGN_KEY_VIOLATION UNIQUE_VIOLATION CHECK_VIOLATION EXCLUSION_VIOLATION INVALID_CURSOR_STATE INVALID_TRANSACTION_STATE ACTIVE_SQL_TRANSACTION BRANCH_TRANSACTION_ALREADY_ACTIVE HELD_CURSOR_REQUIRES_SAME_ISOLATION_LEVEL INAPPROPRIATE_ACCESS_MODE_FOR_BRANCH_TRANSACTION INAPPROPRIATE_ISOLATION_LEVEL_FOR_BRANCH_TRANSACTION NO_ACTIVE_SQL_TRANSACTION_FOR_BRANCH_TRANSACTION READ_ONLY_SQL_TRANSACTION SCHEMA_AND_DATA_STATEMENT_MIXING_NOT_SUPPORTED NO_ACTIVE_SQL_TRANSACTION IN_FAILED_SQL_TRANSACTION IDLE_IN_TRANSACTION_SESSION_TIMEOUT INVALID_SQL_STATEMENT_NAME TRIGGERED_DATA_CHANGE_VIOLATION INVALID_AUTHORIZATION_SPECIFICATION INVALID_PASSWORD DEPENDENT_PRIVILEGE_DESCRIPTORS_STILL_EXIST DEPENDENT_OBJECTS_STILL_EXIST INVALID_TRANSACTION_TERMINATION SQL_ROUTINE_EXCEPTION FUNCTION_EXECUTED_NO_RETURN_STATEMENT MODIFYING_SQL_DATA_NOT_PERMITTED PROHIBITED_SQL_STATEMENT_ATTEMPTED READING_SQL_DATA_NOT_PERMITTED INVALID_CURSOR_NAME EXTERNAL_ROUTINE_EXCEPTION CONTAINING_SQL_NOT_PERMITTED MODIFYING_SQL_DATA_NOT_PERMITTED PROHIBITED_SQL_STATEMENT_ATTEMPTED READING_SQL_DATA_NOT_PERMITTED EXTERNAL_ROUTINE_INVOCATION_EXCEPTION INVALID_SQLSTATE_RETURNED NULL_VALUE_NOT_ALLOWED TRIGGER_PROTOCOL_VIOLATED SRF_PROTOCOL_VIOLATED EVENT_TRIGGER_PROTOCOL_VIOLATED SAVEPOINT_EXCEPTION INVALID_SAVEPOINT_SPECIFICATION INVALID_CATALOG_NAME INVALID_SCHEMA_NAME TRANSACTION_ROLLBACK TRANSACTION_INTEGRITY_CONSTRAINT_VIOLATION SERIALIZATION_FAILURE STATEMENT_COMPLETION_UNKNOWN DEADLOCK_DETECTED SYNTAX_ERROR_OR_ACCESS_RULE_VIOLATION SYNTAX_ERROR INSUFFICIENT_PRIVILEGE CANNOT_COERCE GROUPING_ERROR WINDOWING_ERROR INVALID_RECURSION INVALID_FOREIGN_KEY INVALID_NAME NAME_TOO_LONG RESERVED_NAME DATATYPE_MISMATCH INDETERMINATE_DATATYPE COLLATION_MISMATCH INDETERMINATE_COLLATION WRONG_OBJECT_TYPE GENERATED_ALWAYS UNDEFINED_COLUMN UNDEFINED_FUNCTION UNDEFINED_TABLE UNDEFINED_PARAMETER UNDEFINED_OBJECT DUPLICATE_COLUMN DUPLICATE_CURSOR DUPLICATE_DATABASE DUPLICATE_FUNCTION DUPLICATE_PREPARED_STATEMENT DUPLICATE_SCHEMA DUPLICATE_TABLE DUPLICATE_ALIAS DUPLICATE_OBJECT AMBIGUOUS_COLUMN AMBIGUOUS_FUNCTION AMBIGUOUS_PARAMETER AMBIGUOUS_ALIAS INVALID_COLUMN_REFERENCE INVALID_COLUMN_DEFINITION INVALID_CURSOR_DEFINITION INVALID_DATABASE_DEFINITION INVALID_FUNCTION_DEFINITION INVALID_PREPARED_STATEMENT_DEFINITION INVALID_SCHEMA_DEFINITION INVALID_TABLE_DEFINITION INVALID_OBJECT_DEFINITION WITH_CHECK_OPTION_VIOLATION INSUFFICIENT_RESOURCES DISK_FULL OUT_OF_MEMORY TOO_MANY_CONNECTIONS CONFIGURATION_LIMIT_EXCEEDED PROGRAM_LIMIT_EXCEEDED STATEMENT_TOO_COMPLEX TOO_MANY_COLUMNS TOO_MANY_ARGUMENTS OBJECT_NOT_IN_PREREQUISITE_STATE OBJECT_IN_USE CANT_CHANGE_RUNTIME_PARAM LOCK_NOT_AVAILABLE OPERATOR_INTERVENTION QUERY_CANCELED ADMIN_SHUTDOWN CRASH_SHUTDOWN CANNOT_CONNECT_NOW DATABASE_DROPPED SYSTEM_ERROR IO_ERROR UNDEFINED_FILE DUPLICATE_FILE SNAPSHOT_TOO_OLD CONFIG_FILE_ERROR LOCK_FILE_EXISTS FDW_ERROR FDW_COLUMN_NAME_NOT_FOUND FDW_DYNAMIC_PARAMETER_VALUE_NEEDED FDW_FUNCTION_SEQUENCE_ERROR FDW_INCONSISTENT_DESCRIPTOR_INFORMATION FDW_INVALID_ATTRIBUTE_VALUE FDW_INVALID_COLUMN_NAME FDW_INVALID_COLUMN_NUMBER FDW_INVALID_DATA_TYPE FDW_INVALID_DATA_TYPE_DESCRIPTORS FDW_INVALID_DESCRIPTOR_FIELD_IDENTIFIER FDW_INVALID_HANDLE FDW_INVALID_OPTION_INDEX FDW_INVALID_OPTION_NAME FDW_INVALID_STRING_LENGTH_OR_BUFFER_LENGTH FDW_INVALID_STRING_FORMAT FDW_INVALID_USE_OF_NULL_POINTER FDW_TOO_MANY_HANDLES FDW_OUT_OF_MEMORY FDW_NO_SCHEMAS FDW_OPTION_NAME_NOT_FOUND FDW_REPLY_HANDLE FDW_SCHEMA_NOT_FOUND FDW_TABLE_NOT_FOUND FDW_UNABLE_TO_CREATE_EXECUTION FDW_UNABLE_TO_CREATE_REPLY FDW_UNABLE_TO_ESTABLISH_CONNECTION PLPGSQL_ERROR RAISE_EXCEPTION NO_DATA_FOUND TOO_MANY_ROWS ASSERT_FAILURE INTERNAL_ERROR DATA_CORRUPTED INDEX_CORRUPTED "},illegal:/:==|\W\s*\(\*|(^|\s)\$[a-z]|{{|[a-z]:\s*$|\.\.\.|TO:|DO:/,contains:[{className:"keyword",variants:[{begin:/\bTEXT\s*SEARCH\b/},{begin:/\b(PRIMARY|FOREIGN|FOR(\s+NO)?)\s+KEY\b/},{begin:/\bPARALLEL\s+(UNSAFE|RESTRICTED|SAFE)\b/},{begin:/\bSTORAGE\s+(PLAIN|EXTERNAL|EXTENDED|MAIN)\b/},{begin:/\bMATCH\s+(FULL|PARTIAL|SIMPLE)\b/},{begin:/\bNULLS\s+(FIRST|LAST)\b/},{begin:/\bEVENT\s+TRIGGER\b/},{begin:/\b(MAPPING|OR)\s+REPLACE\b/},{begin:/\b(FROM|TO)\s+(PROGRAM|STDIN|STDOUT)\b/},{begin:/\b(SHARE|EXCLUSIVE)\s+MODE\b/},{begin:/\b(LEFT|RIGHT)\s+(OUTER\s+)?JOIN\b/},{begin:/\b(FETCH|MOVE)\s+(NEXT|PRIOR|FIRST|LAST|ABSOLUTE|RELATIVE|FORWARD|BACKWARD)\b/},{begin:/\bPRESERVE\s+ROWS\b/},{begin:/\bDISCARD\s+PLANS\b/},{begin:/\bREFERENCING\s+(OLD|NEW)\b/},{begin:/\bSKIP\s+LOCKED\b/},{begin:/\bGROUPING\s+SETS\b/},{begin:/\b(BINARY|INSENSITIVE|SCROLL|NO\s+SCROLL)\s+(CURSOR|FOR)\b/},{begin:/\b(WITH|WITHOUT)\s+HOLD\b/},{begin:/\bWITH\s+(CASCADED|LOCAL)\s+CHECK\s+OPTION\b/},{begin:/\bEXCLUDE\s+(TIES|NO\s+OTHERS)\b/},{begin:/\bFORMAT\s+(TEXT|XML|JSON|YAML)\b/},{begin:/\bSET\s+((SESSION|LOCAL)\s+)?NAMES\b/},{begin:/\bIS\s+(NOT\s+)?UNKNOWN\b/},{begin:/\bSECURITY\s+LABEL\b/},{begin:/\bSTANDALONE\s+(YES|NO|NO\s+VALUE)\b/},{begin:/\bWITH\s+(NO\s+)?DATA\b/},{begin:/\b(FOREIGN|SET)\s+DATA\b/},{begin:/\bSET\s+(CATALOG|CONSTRAINTS)\b/},{begin:/\b(WITH|FOR)\s+ORDINALITY\b/},{begin:/\bIS\s+(NOT\s+)?DOCUMENT\b/},{begin:/\bXML\s+OPTION\s+(DOCUMENT|CONTENT)\b/},{begin:/\b(STRIP|PRESERVE)\s+WHITESPACE\b/},{begin:/\bNO\s+(ACTION|MAXVALUE|MINVALUE)\b/},{begin:/\bPARTITION\s+BY\s+(RANGE|LIST|HASH)\b/},{begin:/\bAT\s+TIME\s+ZONE\b/},{begin:/\bGRANTED\s+BY\b/},{begin:/\bRETURN\s+(QUERY|NEXT)\b/},{begin:/\b(ATTACH|DETACH)\s+PARTITION\b/},{begin:/\bFORCE\s+ROW\s+LEVEL\s+SECURITY\b/},{begin:/\b(INCLUDING|EXCLUDING)\s+(COMMENTS|CONSTRAINTS|DEFAULTS|IDENTITY|INDEXES|STATISTICS|STORAGE|ALL)\b/},{begin:/\bAS\s+(ASSIGNMENT|IMPLICIT|PERMISSIVE|RESTRICTIVE|ENUM|RANGE)\b/}]},{begin:/\b(FORMAT|FAMILY|VERSION)\s*\(/},{begin:/\bINCLUDE\s*\(/,keywords:"INCLUDE"},{begin:/\bRANGE(?!\s*(BETWEEN|UNBOUNDED|CURRENT|[-0-9]+))/},{begin:/\b(VERSION|OWNER|TEMPLATE|TABLESPACE|CONNECTION\s+LIMIT|PROCEDURE|RESTRICT|JOIN|PARSER|COPY|START|END|COLLATION|INPUT|ANALYZE|STORAGE|LIKE|DEFAULT|DELIMITER|ENCODING|COLUMN|CONSTRAINT|TABLE|SCHEMA)\s*=/},{begin:/\b(PG_\w+?|HAS_[A-Z_]+_PRIVILEGE)\b/,relevance:10},{begin:/\bEXTRACT\s*\(/,end:/\bFROM\b/,returnEnd:!0,keywords:{type:"CENTURY DAY DECADE DOW DOY EPOCH HOUR ISODOW ISOYEAR MICROSECONDS MILLENNIUM MILLISECONDS MINUTE MONTH QUARTER SECOND TIMEZONE TIMEZONE_HOUR TIMEZONE_MINUTE WEEK YEAR"}},{begin:/\b(XMLELEMENT|XMLPI)\s*\(\s*NAME/,keywords:{keyword:"NAME"}},{begin:/\b(XMLPARSE|XMLSERIALIZE)\s*\(\s*(DOCUMENT|CONTENT)/,keywords:{keyword:"DOCUMENT CONTENT"}},{beginKeywords:"CACHE INCREMENT MAXVALUE MINVALUE",end:e.C_NUMBER_RE,returnEnd:!0,keywords:"BY CACHE INCREMENT MAXVALUE MINVALUE"},{className:"type",begin:/\b(WITH|WITHOUT)\s+TIME\s+ZONE\b/},{className:"type",begin:/\bINTERVAL\s+(YEAR|MONTH|DAY|HOUR|MINUTE|SECOND)(\s+TO\s+(MONTH|HOUR|MINUTE|SECOND))?\b/},{ +begin:/\bRETURNS\s+(LANGUAGE_HANDLER|TRIGGER|EVENT_TRIGGER|FDW_HANDLER|INDEX_AM_HANDLER|TSM_HANDLER)\b/,keywords:{keyword:"RETURNS",type:"LANGUAGE_HANDLER TRIGGER EVENT_TRIGGER FDW_HANDLER INDEX_AM_HANDLER TSM_HANDLER"}},{begin:"\\b("+r+")\\s*\\("},{begin:"\\.("+n+")\\b"},{begin:"\\b("+n+")\\s+PATH\\b",keywords:{keyword:"PATH",type:a.replace("PATH ","")}},{className:"type",begin:"\\b("+n+")\\b"},{className:"string",begin:"'",end:"'",contains:[{begin:"''"}]},{className:"string",begin:"(e|E|u&|U&)'",end:"'",contains:[{begin:"\\\\."}],relevance:10},{begin:"\\$([a-zA-Z_]?|[a-zA-Z_][a-zA-Z_0-9]*)\\$",endSameAsBegin:!0,contains:[{subLanguage:["pgsql","perl","python","tcl","r","lua","java","php","ruby","bash","scheme","xml","json"],endsWithParent:!0}]},{begin:'"',end:'"',contains:[{begin:'""'}]},e.C_NUMBER_MODE,e.C_BLOCK_COMMENT_MODE,t,{className:"meta",variants:[{begin:"%(ROW)?TYPE",relevance:10},{begin:"\\$\\d+"},{begin:"^#\\w",end:"$"}]},{className:"symbol",begin:"<<\\s*[a-zA-Z_][a-zA-Z_0-9$]*\\s*>>",relevance:10}]}},ds=function(e){var t={begin:"\\$+[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*"},a={className:"meta",variants:[{begin:/<\?php/,relevance:10},{begin:/<\?[=]?/},{begin:/\?>/}]},n={className:"string",contains:[e.BACKSLASH_ESCAPE,a],variants:[{begin:'b"',end:'"'},{begin:"b'",end:"'"},e.inherit(e.APOS_STRING_MODE,{illegal:null}),e.inherit(e.QUOTE_STRING_MODE,{illegal:null})]},r={variants:[e.BINARY_NUMBER_MODE,e.C_NUMBER_MODE]},i={keyword:"__CLASS__ __DIR__ __FILE__ __FUNCTION__ __LINE__ __METHOD__ __NAMESPACE__ __TRAIT__ die echo exit include include_once print require require_once array abstract and as binary bool boolean break callable case catch class clone const continue declare default do double else elseif empty enddeclare endfor endforeach endif endswitch endwhile eval extends final finally float for foreach from global goto if implements instanceof insteadof int integer interface isset iterable list new object or private protected public real return string switch throw trait try unset use var void while xor yield",literal:"false null true",built_in:"Error|0 AppendIterator ArgumentCountError ArithmeticError ArrayIterator ArrayObject AssertionError BadFunctionCallException BadMethodCallException CachingIterator CallbackFilterIterator CompileError Countable DirectoryIterator DivisionByZeroError DomainException EmptyIterator ErrorException Exception FilesystemIterator FilterIterator GlobIterator InfiniteIterator InvalidArgumentException IteratorIterator LengthException LimitIterator LogicException MultipleIterator NoRewindIterator OutOfBoundsException OutOfRangeException OuterIterator OverflowException ParentIterator ParseError RangeException RecursiveArrayIterator RecursiveCachingIterator RecursiveCallbackFilterIterator RecursiveDirectoryIterator RecursiveFilterIterator RecursiveIterator RecursiveIteratorIterator RecursiveRegexIterator RecursiveTreeIterator RegexIterator RuntimeException SeekableIterator SplDoublyLinkedList SplFileInfo SplFileObject SplFixedArray SplHeap SplMaxHeap SplMinHeap SplObjectStorage SplObserver SplObserver SplPriorityQueue SplQueue SplStack SplSubject SplSubject SplTempFileObject TypeError UnderflowException UnexpectedValueException ArrayAccess Closure Generator Iterator IteratorAggregate Serializable Throwable Traversable WeakReference Directory __PHP_Incomplete_Class parent php_user_filter self static stdClass"};return{aliases:["php","php3","php4","php5","php6","php7"],case_insensitive:!0,keywords:i,contains:[e.HASH_COMMENT_MODE,e.COMMENT("//","$",{contains:[a]}),e.COMMENT("/\\*","\\*/",{contains:[{className:"doctag",begin:"@[A-Za-z]+"}]}),e.COMMENT("__halt_compiler.+?;",!1,{endsWithParent:!0,keywords:"__halt_compiler",lexemes:e.UNDERSCORE_IDENT_RE}),{className:"string",begin:/<<<['"]?\w+['"]?$/,end:/^\w+;?$/,contains:[e.BACKSLASH_ESCAPE,{className:"subst",variants:[{begin:/\$\w+/},{begin:/\{\$/,end:/\}/}]}]},a,{className:"keyword",begin:/\$this\b/},t,{begin:/(::|->)+[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*/},{className:"function",beginKeywords:"fn function",end:/[;{]/,excludeEnd:!0,illegal:"[$%\\[]",contains:[e.UNDERSCORE_TITLE_MODE,{className:"params",begin:"\\(",end:"\\)",excludeBegin:!0,excludeEnd:!0,keywords:i,contains:["self",t,e.C_BLOCK_COMMENT_MODE,n,r]}]},{className:"class",beginKeywords:"class interface",end:"{",excludeEnd:!0,illegal:/[:\(\$"]/,contains:[{beginKeywords:"extends implements"},e.UNDERSCORE_TITLE_MODE]},{beginKeywords:"namespace",end:";",illegal:/[\.']/,contains:[e.UNDERSCORE_TITLE_MODE]},{beginKeywords:"use",end:";",contains:[e.UNDERSCORE_TITLE_MODE]},{begin:"=>"},n,r]}},us=function(e){return{name:"PHP template",subLanguage:"xml",contains:[{begin:/<\?(php|=)?/,end:/\?>/,subLanguage:"php",contains:[{begin:"/\\*",end:"\\*/",skip:!0},{begin:'b"',end:'"',skip:!0},{begin:"b'",end:"'",skip:!0},e.inherit(e.APOS_STRING_MODE,{illegal:null,className:null,contains:null,skip:!0}),e.inherit(e.QUOTE_STRING_MODE,{illegal:null,className:null,contains:null,skip:!0})]}]}},ms=function(){return{name:"Plain text",aliases:["text","txt"],disableAutodetect:!0}},ps=function(e){return{name:"Pony",keywords:{keyword:"actor addressof and as be break class compile_error compile_intrinsic consume continue delegate digestof do else elseif embed end error for fun if ifdef in interface is isnt lambda let match new not object or primitive recover repeat return struct then trait try type until use var where while with xor",meta:"iso val tag trn box ref",literal:"this false true"},contains:[{className:"type",begin:"\\b_?[A-Z][\\w]*",relevance:0},{className:"string",begin:'"""',end:'"""',relevance:10},{className:"string",begin:'"',end:'"',contains:[e.BACKSLASH_ESCAPE]},{className:"string",begin:"'",end:"'",contains:[e.BACKSLASH_ESCAPE],relevance:0},{begin:e.IDENT_RE+"'",relevance:0},{className:"number",begin:"(-?)(\\b0[xX][a-fA-F0-9]+|\\b0[bB][01]+|(\\b\\d+(_\\d+)?(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",relevance:0},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]}},Es=function(e){var t={keyword:"if else foreach return do while until elseif begin for trap data dynamicparam end break throw param continue finally in switch exit filter try process catch hidden static parameter",built_in:"ac asnp cat cd CFS chdir clc clear clhy cli clp cls clv cnsn compare copy cp cpi cpp curl cvpa dbp del diff dir dnsn ebp echo|0 epal epcsv epsn erase etsn exsn fc fhx fl ft fw gal gbp gc gcb gci gcm gcs gdr gerr ghy gi gin gjb gl gm gmo gp gps gpv group gsn gsnp gsv gtz gu gv gwmi h history icm iex ihy ii ipal ipcsv ipmo ipsn irm ise iwmi iwr kill lp ls man md measure mi mount move mp mv nal ndr ni nmo npssc nsn nv ogv oh popd ps pushd pwd r rbp rcjb rcsn rd rdr ren ri rjb rm rmdir rmo rni rnp rp rsn rsnp rujb rv rvpa rwmi sajb sal saps sasv sbp sc scb select set shcm si sl sleep sls sort sp spjb spps spsv start stz sujb sv swmi tee trcm type wget where wjb write"},a={begin:"`[\\s\\S]",relevance:0},n={className:"variable",variants:[{begin:/\$\B/},{className:"keyword",begin:/\$this/},{begin:/\$[\w\d][\w\d_:]*/}]},r={className:"string",variants:[{begin:/"/,end:/"/},{begin:/@"/,end:/^"@/}],contains:[a,n,{className:"variable",begin:/\$[A-z]/,end:/[^A-z]/}]},i={className:"string",variants:[{begin:/'/,end:/'/},{begin:/@'/,end:/^'@/}]},o=e.inherit(e.COMMENT(null,null),{variants:[{begin:/#/,end:/$/},{begin:/<#/,end:/#>/}],contains:[{className:"doctag",variants:[{begin:/\.(synopsis|description|example|inputs|outputs|notes|link|component|role|functionality)/},{begin:/\.(parameter|forwardhelptargetname|forwardhelpcategory|remotehelprunspace|externalhelp)\s+\S+/}]}]}),s={className:"built_in",variants:[{begin:"(".concat("Add|Clear|Close|Copy|Enter|Exit|Find|Format|Get|Hide|Join|Lock|Move|New|Open|Optimize|Pop|Push|Redo|Remove|Rename|Reset|Resize|Search|Select|Set|Show|Skip|Split|Step|Switch|Undo|Unlock|Watch|Backup|Checkpoint|Compare|Compress|Convert|ConvertFrom|ConvertTo|Dismount|Edit|Expand|Export|Group|Import|Initialize|Limit|Merge|New|Out|Publish|Restore|Save|Sync|Unpublish|Update|Approve|Assert|Complete|Confirm|Deny|Disable|Enable|Install|Invoke|Register|Request|Restart|Resume|Start|Stop|Submit|Suspend|Uninstall|Unregister|Wait|Debug|Measure|Ping|Repair|Resolve|Test|Trace|Connect|Disconnect|Read|Receive|Send|Write|Block|Grant|Protect|Revoke|Unblock|Unprotect|Use|ForEach|Sort|Tee|Where",")+(-)[\\w\\d]+")}]},l={className:"class",beginKeywords:"class enum",end:/\s*[{]/,excludeEnd:!0,relevance:0,contains:[e.TITLE_MODE]},_={className:"function",begin:/function\s+/,end:/\s*\{|$/,excludeEnd:!0,returnBegin:!0,relevance:0,contains:[{begin:"function",relevance:0,className:"keyword"},{className:"title",begin:/\w[\w\d]*((-)[\w\d]+)*/,relevance:0},{begin:/\(/,end:/\)/,className:"params",relevance:0,contains:[n]}]},c={begin:/using\s/,end:/$/,returnBegin:!0,contains:[r,i,{className:"keyword",begin:/(using|assembly|command|module|namespace|type)/}]},d={variants:[{className:"operator",begin:"(".concat("-and|-as|-band|-bnot|-bor|-bxor|-casesensitive|-ccontains|-ceq|-cge|-cgt|-cle|-clike|-clt|-cmatch|-cne|-cnotcontains|-cnotlike|-cnotmatch|-contains|-creplace|-csplit|-eq|-exact|-f|-file|-ge|-gt|-icontains|-ieq|-ige|-igt|-ile|-ilike|-ilt|-imatch|-in|-ine|-inotcontains|-inotlike|-inotmatch|-ireplace|-is|-isnot|-isplit|-join|-le|-like|-lt|-match|-ne|-not|-notcontains|-notin|-notlike|-notmatch|-or|-regex|-replace|-shl|-shr|-split|-wildcard|-xor",")\\b")},{className:"literal",begin:/(-)[\w\d]+/,relevance:0}]},u={className:"function",begin:/\[.*\]\s*[\w]+[ ]??\(/,end:/$/,returnBegin:!0,relevance:0,contains:[{className:"keyword",begin:"(".concat(t.keyword.toString().replace(/\s/g,"|"),")\\b"),endsParent:!0,relevance:0},e.inherit(e.TITLE_MODE,{endsParent:!0})]},m=[u,o,a,e.NUMBER_MODE,r,i,s,n,{className:"literal",begin:/\$(null|true|false)\b/},{className:"selector-tag",begin:/\@\B/,relevance:0}],p={begin:/\[/,end:/\]/,excludeBegin:!0,excludeEnd:!0,relevance:0,contains:[].concat("self",m,{begin:"("+["string","char","byte","int","long","bool","decimal","single","double","DateTime","xml","array","hashtable","void"].join("|")+")",className:"built_in",relevance:0},{className:"type",begin:/[\.\w\d]+/,relevance:0})};return u.contains.unshift(p),{name:"PowerShell",aliases:["ps","ps1"],lexemes:/-?[A-z\.\-]+\b/,case_insensitive:!0,keywords:t,contains:m.concat(l,_,c,d,p)}},gs=function(e){return{name:"Processing",keywords:{keyword:"BufferedReader PVector PFont PImage PGraphics HashMap boolean byte char color double float int long String Array FloatDict FloatList IntDict IntList JSONArray JSONObject Object StringDict StringList Table TableRow XML false synchronized int abstract float private char boolean static null if const for true while long throw strictfp finally protected import native final return void enum else break transient new catch instanceof byte super volatile case assert short package default double public try this switch continue throws protected public private",literal:"P2D P3D HALF_PI PI QUARTER_PI TAU TWO_PI",title:"setup draw",built_in:"displayHeight displayWidth mouseY mouseX mousePressed pmouseX pmouseY key keyCode pixels focused frameCount frameRate height width size createGraphics beginDraw createShape loadShape PShape arc ellipse line point quad rect triangle bezier bezierDetail bezierPoint bezierTangent curve curveDetail curvePoint curveTangent curveTightness shape shapeMode beginContour beginShape bezierVertex curveVertex endContour endShape quadraticVertex vertex ellipseMode noSmooth rectMode smooth strokeCap strokeJoin strokeWeight mouseClicked mouseDragged mouseMoved mousePressed mouseReleased mouseWheel keyPressed keyPressedkeyReleased keyTyped print println save saveFrame day hour millis minute month second year background clear colorMode fill noFill noStroke stroke alpha blue brightness color green hue lerpColor red saturation modelX modelY modelZ screenX screenY screenZ ambient emissive shininess specular add createImage beginCamera camera endCamera frustum ortho perspective printCamera printProjection cursor frameRate noCursor exit loop noLoop popStyle pushStyle redraw binary boolean byte char float hex int str unbinary unhex join match matchAll nf nfc nfp nfs split splitTokens trim append arrayCopy concat expand reverse shorten sort splice subset box sphere sphereDetail createInput createReader loadBytes loadJSONArray loadJSONObject loadStrings loadTable loadXML open parseXML saveTable selectFolder selectInput beginRaw beginRecord createOutput createWriter endRaw endRecord PrintWritersaveBytes saveJSONArray saveJSONObject saveStream saveStrings saveXML selectOutput popMatrix printMatrix pushMatrix resetMatrix rotate rotateX rotateY rotateZ scale shearX shearY translate ambientLight directionalLight lightFalloff lights lightSpecular noLights normal pointLight spotLight image imageMode loadImage noTint requestImage tint texture textureMode textureWrap blend copy filter get loadPixels set updatePixels blendMode loadShader PShaderresetShader shader createFont loadFont text textFont textAlign textLeading textMode textSize textWidth textAscent textDescent abs ceil constrain dist exp floor lerp log mag map max min norm pow round sq sqrt acos asin atan atan2 cos degrees radians sin tan noise noiseDetail noiseSeed random randomGaussian randomSeed"},contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,e.C_NUMBER_MODE]}},Ss=function(e){return{name:"Python profiler",contains:[e.C_NUMBER_MODE,{begin:"[a-zA-Z_][\\da-zA-Z_]+\\.[\\da-zA-Z_]{1,3}",end:":",excludeEnd:!0},{begin:"(ncalls|tottime|cumtime)",end:"$",keywords:"ncalls tottime|10 cumtime|10 filename",relevance:10},{begin:"function calls",end:"$",contains:[e.C_NUMBER_MODE],relevance:10},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{className:"string",begin:"\\(",end:"\\)$",excludeBegin:!0,excludeEnd:!0,relevance:0}]}},Ts=function(e){var t={begin:/\(/,end:/\)/,relevance:0},a={begin:/\[/,end:/\]/},n={className:"comment",begin:/%/,end:/$/,contains:[e.PHRASAL_WORDS_MODE]},r={className:"string",begin:/`/,end:/`/,contains:[e.BACKSLASH_ESCAPE]},i=[{begin:/[a-z][A-Za-z0-9_]*/,relevance:0},{className:"symbol",variants:[{begin:/[A-Z][a-zA-Z0-9_]*/},{begin:/_[A-Za-z0-9_]*/}],relevance:0},t,{begin:/:-/},a,n,e.C_BLOCK_COMMENT_MODE,e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,r,{className:"string",begin:/0\'(\\\'|.)/},{className:"string",begin:/0\'\\s/},e.C_NUMBER_MODE];return t.contains=i,a.contains=i,{name:"Prolog",contains:i.concat([{begin:/\.$/}])}},bs=function(e){var t="[ \\t\\f]*",a="("+t+"[:=]"+t+"|[ \\t\\f]+)",n="([^\\\\:= \\t\\f\\n]|\\\\.)+",r={end:a,relevance:0,starts:{className:"string",end:/$/,relevance:0,contains:[{begin:"\\\\\\n"}]}};return{name:".properties",case_insensitive:!0,illegal:/\S/,contains:[e.COMMENT("^\\s*[!#]","$"),{begin:"([^\\\\\\W:= \\t\\f\\n]|\\\\.)+"+a,returnBegin:!0,contains:[{className:"attr",begin:"([^\\\\\\W:= \\t\\f\\n]|\\\\.)+",endsParent:!0,relevance:0}],starts:r},{begin:n+a,returnBegin:!0,relevance:0,contains:[{className:"meta",begin:n,endsParent:!0,relevance:0}],starts:r},{className:"attr",relevance:0,begin:n+t+"$"}]}},fs=function(e){return{name:"Protocol Buffers",keywords:{keyword:"package import option optional required repeated group oneof",built_in:"double float int32 int64 uint32 uint64 sint32 sint64 fixed32 fixed64 sfixed32 sfixed64 bool string bytes",literal:"true false"},contains:[e.QUOTE_STRING_MODE,e.NUMBER_MODE,e.C_LINE_COMMENT_MODE,{className:"class",beginKeywords:"message enum service",end:/\{/,illegal:/\n/,contains:[e.inherit(e.TITLE_MODE,{starts:{endsWithParent:!0,excludeEnd:!0}})]},{className:"function",beginKeywords:"rpc",end:/[{;]/,excludeEnd:!0,keywords:"rpc returns"},{begin:/^\s*[A-Z_]+/,end:/\s*=/,excludeEnd:!0}]}},Cs=function(e){var t=e.COMMENT("#","$"),a=e.inherit(e.TITLE_MODE,{begin:"([A-Za-z_]|::)(\\w|::)*"}),n={className:"variable",begin:"\\$([A-Za-z_]|::)(\\w|::)*"},r={className:"string",contains:[e.BACKSLASH_ESCAPE,n],variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/}]};return{name:"Puppet",aliases:["pp"],contains:[t,n,r,{beginKeywords:"class",end:"\\{|;",illegal:/=/,contains:[a,t]},{beginKeywords:"define",end:/\{/,contains:[{className:"section",begin:e.IDENT_RE,endsParent:!0}]},{begin:e.IDENT_RE+"\\s+\\{",returnBegin:!0,end:/\S/,contains:[{className:"keyword",begin:e.IDENT_RE},{begin:/\{/,end:/\}/,keywords:{keyword:"and case default else elsif false if in import enherits node or true undef unless main settings $string ",literal:"alias audit before loglevel noop require subscribe tag owner ensure group mode name|0 changes context force incl lens load_path onlyif provider returns root show_diff type_check en_address ip_address realname command environment hour monute month monthday special target weekday creates cwd ogoutput refresh refreshonly tries try_sleep umask backup checksum content ctime force ignore links mtime purge recurse recurselimit replace selinux_ignore_defaults selrange selrole seltype seluser source souirce_permissions sourceselect validate_cmd validate_replacement allowdupe attribute_membership auth_membership forcelocal gid ia_load_module members system host_aliases ip allowed_trunk_vlans description device_url duplex encapsulation etherchannel native_vlan speed principals allow_root auth_class auth_type authenticate_user k_of_n mechanisms rule session_owner shared options device fstype enable hasrestart directory present absent link atboot blockdevice device dump pass remounts poller_tag use message withpath adminfile allow_virtual allowcdrom category configfiles flavor install_options instance package_settings platform responsefile status uninstall_options vendor unless_system_user unless_uid binary control flags hasstatus manifest pattern restart running start stop allowdupe auths expiry gid groups home iterations key_membership keys managehome membership password password_max_age password_min_age profile_membership profiles project purge_ssh_keys role_membership roles salt shell uid baseurl cost descr enabled enablegroups exclude failovermethod gpgcheck gpgkey http_caching include includepkgs keepalive metadata_expire metalink mirrorlist priority protect proxy proxy_password proxy_username repo_gpgcheck s3_enabled skip_if_unavailable sslcacert sslclientcert sslclientkey sslverify mounted",built_in:"architecture augeasversion blockdevices boardmanufacturer boardproductname boardserialnumber cfkey dhcp_servers domain ec2_ ec2_userdata facterversion filesystems ldom fqdn gid hardwareisa hardwaremodel hostname id|0 interfaces ipaddress ipaddress_ ipaddress6 ipaddress6_ iphostnumber is_virtual kernel kernelmajversion kernelrelease kernelversion kernelrelease kernelversion lsbdistcodename lsbdistdescription lsbdistid lsbdistrelease lsbmajdistrelease lsbminordistrelease lsbrelease macaddress macaddress_ macosx_buildversion macosx_productname macosx_productversion macosx_productverson_major macosx_productversion_minor manufacturer memoryfree memorysize netmask metmask_ network_ operatingsystem operatingsystemmajrelease operatingsystemrelease osfamily partitions path physicalprocessorcount processor processorcount productname ps puppetversion rubysitedir rubyversion selinux selinux_config_mode selinux_config_policy selinux_current_mode selinux_current_mode selinux_enforced selinux_policyversion serialnumber sp_ sshdsakey sshecdsakey sshrsakey swapencrypted swapfree swapsize timezone type uniqueid uptime uptime_days uptime_hours uptime_seconds uuid virtual vlans xendomains zfs_version zonenae zones zpool_version"},relevance:0,contains:[r,t,{begin:"[a-zA-Z_]+\\s*=>",returnBegin:!0,end:"=>",contains:[{className:"attr",begin:e.IDENT_RE}]},{className:"number",begin:"(\\b0[0-7_]+)|(\\b0x[0-9a-fA-F_]+)|(\\b[1-9][0-9_]*(\\.[0-9_]+)?)|[0_]\\b",relevance:0},n]}],relevance:0}]}},Rs=function(e){return{name:"PureBASIC",aliases:["pb","pbi"],keywords:"Align And Array As Break CallDebugger Case CompilerCase CompilerDefault CompilerElse CompilerElseIf CompilerEndIf CompilerEndSelect CompilerError CompilerIf CompilerSelect CompilerWarning Continue Data DataSection Debug DebugLevel Declare DeclareC DeclareCDLL DeclareDLL DeclareModule Default Define Dim DisableASM DisableDebugger DisableExplicit Else ElseIf EnableASM EnableDebugger EnableExplicit End EndDataSection EndDeclareModule EndEnumeration EndIf EndImport EndInterface EndMacro EndModule EndProcedure EndSelect EndStructure EndStructureUnion EndWith Enumeration EnumerationBinary Extends FakeReturn For ForEach ForEver Global Gosub Goto If Import ImportC IncludeBinary IncludeFile IncludePath Interface List Macro MacroExpandedCount Map Module NewList NewMap Next Not Or Procedure ProcedureC ProcedureCDLL ProcedureDLL ProcedureReturn Protected Prototype PrototypeC ReDim Read Repeat Restore Return Runtime Select Shared Static Step Structure StructureUnion Swap Threaded To UndefineMacro Until Until UnuseModule UseModule Wend While With XIncludeFile XOr",contains:[e.COMMENT(";","$",{relevance:0}),{className:"function",begin:"\\b(Procedure|Declare)(C|CDLL|DLL)?\\b",end:"\\(",excludeEnd:!0,returnBegin:!0,contains:[{className:"keyword",begin:"(Procedure|Declare)(C|CDLL|DLL)?",excludeEnd:!0},{className:"type",begin:"\\.\\w*"},e.UNDERSCORE_TITLE_MODE]},{className:"string",begin:'(~)?"',end:'"',illegal:"\\n"},{className:"symbol",begin:"#[a-zA-Z_]\\w*\\$?"}]}},Ns=function(e){var t={keyword:"and elif is global as in if from raise for except finally print import pass return exec else break not with class assert yield try while continue del or def lambda async await nonlocal|10",built_in:"Ellipsis NotImplemented",literal:"False None True"},a={className:"meta",begin:/^(>>>|\.\.\.) /},n={className:"subst",begin:/\{/,end:/\}/,keywords:t,illegal:/#/},r={begin:/\{\{/,relevance:0},i={className:"string",contains:[e.BACKSLASH_ESCAPE],variants:[{begin:/(u|b)?r?'''/,end:/'''/,contains:[e.BACKSLASH_ESCAPE,a],relevance:10},{begin:/(u|b)?r?"""/,end:/"""/,contains:[e.BACKSLASH_ESCAPE,a],relevance:10},{begin:/(fr|rf|f)'''/,end:/'''/,contains:[e.BACKSLASH_ESCAPE,a,r,n]},{begin:/(fr|rf|f)"""/,end:/"""/,contains:[e.BACKSLASH_ESCAPE,a,r,n]},{begin:/(u|r|ur)'/,end:/'/,relevance:10},{begin:/(u|r|ur)"/,end:/"/,relevance:10},{begin:/(b|br)'/,end:/'/},{begin:/(b|br)"/,end:/"/},{begin:/(fr|rf|f)'/,end:/'/,contains:[e.BACKSLASH_ESCAPE,r,n]},{begin:/(fr|rf|f)"/,end:/"/,contains:[e.BACKSLASH_ESCAPE,r,n]},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]},o={className:"number",relevance:0,variants:[{begin:e.BINARY_NUMBER_RE+"[lLjJ]?"},{begin:"\\b(0o[0-7]+)[lLjJ]?"},{begin:e.C_NUMBER_RE+"[lLjJ]?"}]},s={className:"params",variants:[{begin:/\(\s*\)/,skip:!0,className:null},{begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,contains:["self",a,o,i,e.HASH_COMMENT_MODE]}]};return n.contains=[i,o,a],{name:"Python",aliases:["py","gyp","ipython"],keywords:t,illegal:/(<\/|->|\?)|=>/,contains:[a,o,{beginKeywords:"if",relevance:0},i,e.HASH_COMMENT_MODE,{variants:[{className:"function",beginKeywords:"def"},{className:"class",beginKeywords:"class"}],end:/:/,illegal:/[${=;\n,]/,contains:[e.UNDERSCORE_TITLE_MODE,s,{begin:/->/,endsWithParent:!0,keywords:"None"}]},{className:"meta",begin:/^[\t ]*@/,end:/$/},{begin:/\b(print|exec)\(/}]}},Os=function(){return{aliases:["pycon"],contains:[{className:"meta",starts:{end:/ |$/,starts:{end:"$",subLanguage:"python"}},variants:[{begin:/^>>>(?=[ ]|$)/},{begin:/^\.\.\.(?=[ ]|$)/}]}]}},vs=function(e){return{name:"Q",aliases:["k","kdb"],keywords:{keyword:"do while select delete by update from",literal:"0b 1b",built_in:"neg not null string reciprocal floor ceiling signum mod xbar xlog and or each scan over prior mmu lsq inv md5 ltime gtime count first var dev med cov cor all any rand sums prds mins maxs fills deltas ratios avgs differ prev next rank reverse iasc idesc asc desc msum mcount mavg mdev xrank mmin mmax xprev rotate distinct group where flip type key til get value attr cut set upsert raze union inter except cross sv vs sublist enlist read0 read1 hopen hclose hdel hsym hcount peach system ltrim rtrim trim lower upper ssr view tables views cols xcols keys xkey xcol xasc xdesc fkeys meta lj aj aj0 ij pj asof uj ww wj wj1 fby xgroup ungroup ej save load rsave rload show csv parse eval min max avg wavg wsum sin cos tan sum",type:"`float `double int `timestamp `timespan `datetime `time `boolean `symbol `char `byte `short `long `real `month `date `minute `second `guid"},lexemes:/(`?)[A-Za-z0-9_]+\b/,contains:[e.C_LINE_COMMENT_MODE,e.QUOTE_STRING_MODE,e.C_NUMBER_MODE]}},Is=function(e){var t="[a-zA-Z_][a-zA-Z0-9\\._]*",a={className:"attribute",begin:"\\bid\\s*:",starts:{className:"string",end:t,returnEnd:!1}},n={begin:t+"\\s*:",returnBegin:!0,contains:[{className:"attribute",begin:t,end:"\\s*:",excludeEnd:!0,relevance:0}],relevance:0},r={begin:t+"\\s*{",end:"{",returnBegin:!0,relevance:0,contains:[e.inherit(e.TITLE_MODE,{begin:t})]};return{name:"QML",aliases:["qt"],case_insensitive:!1,keywords:{keyword:"in of on if for while finally var new function do return void else break catch instanceof with throw case default try this switch continue typeof delete let yield const export super debugger as async await import",literal:"true false null undefined NaN Infinity",built_in:"eval isFinite isNaN parseFloat parseInt decodeURI decodeURIComponent encodeURI encodeURIComponent escape unescape Object Function Boolean Error EvalError InternalError RangeError ReferenceError StopIteration SyntaxError TypeError URIError Number Math Date String RegExp Array Float32Array Float64Array Int16Array Int32Array Int8Array Uint16Array Uint32Array Uint8Array Uint8ClampedArray ArrayBuffer DataView JSON Intl arguments require module console window document Symbol Set Map WeakSet WeakMap Proxy Reflect Behavior bool color coordinate date double enumeration font geocircle georectangle geoshape int list matrix4x4 parent point quaternion real rect size string url variant vector2d vector3d vector4d Promise"},contains:[{className:"meta",begin:/^\s*['"]use (strict|asm)['"]/},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{className:"string",begin:"`",end:"`",contains:[e.BACKSLASH_ESCAPE,{className:"subst",begin:"\\$\\{",end:"\\}"}]},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{className:"number",variants:[{begin:"\\b(0[bB][01]+)"},{begin:"\\b(0[oO][0-7]+)"},{begin:e.C_NUMBER_RE}],relevance:0},{begin:"("+e.RE_STARTERS_RE+"|\\b(case|return|throw)\\b)\\s*",keywords:"return throw case",contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.REGEXP_MODE,{begin:/\s*[);\]]/,relevance:0,subLanguage:"xml"}],relevance:0},{className:"keyword",begin:"\\bsignal\\b",starts:{className:"string",end:"(\\(|:|=|;|,|//|/\\*|$)",returnEnd:!0}},{className:"keyword",begin:"\\bproperty\\b",starts:{className:"string",end:"(:|=|;|,|//|/\\*|$)",returnEnd:!0}},{className:"function",beginKeywords:"function",end:/\{/,excludeEnd:!0,contains:[e.inherit(e.TITLE_MODE,{begin:/[A-Za-z$_][0-9A-Za-z$_]*/}),{className:"params",begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]}],illegal:/\[|%/},{begin:"\\."+e.IDENT_RE,relevance:0},a,n,r],illegal:/#/}},hs=function(e){var t="([a-zA-Z]|\\.[a-zA-Z.])[a-zA-Z0-9._]*";return{name:"R",contains:[e.HASH_COMMENT_MODE,{begin:t,lexemes:t,keywords:{keyword:"function if in break next repeat else for return switch while try tryCatch stop warning require library attach detach source setMethod setGeneric setGroupGeneric setClass ...",literal:"NULL NA TRUE FALSE T F Inf NaN NA_integer_|10 NA_real_|10 NA_character_|10 NA_complex_|10"},relevance:0},{className:"number",begin:"0[xX][0-9a-fA-F]+[Li]?\\b",relevance:0},{className:"number",begin:"\\d+(?:[eE][+\\-]?\\d*)?L\\b",relevance:0},{className:"number",begin:"\\d+\\.(?!\\d)(?:i\\b)?",relevance:0},{className:"number",begin:"\\d+(?:\\.\\d*)?(?:[eE][+\\-]?\\d*)?i?\\b",relevance:0},{className:"number",begin:"\\.\\d+(?:[eE][+\\-]?\\d*)?i?\\b",relevance:0},{begin:"`",end:"`",relevance:0},{className:"string",contains:[e.BACKSLASH_ESCAPE],variants:[{begin:'"',end:'"'},{begin:"'",end:"'"}]}]}},As=function(e){var t="~?[a-z$_][0-9a-zA-Z$_]*",a="`?[A-Z$_][0-9a-zA-Z$_]*",n="("+["||","&&","++","**","+.","*","/","*.","/.","...","|>"].map(function(e){return e.split("").map(function(e){return"\\"+e}).join("")}).join("|")+"|==|===)",r="\\s+"+n+"\\s+",i={keyword:"and as asr assert begin class constraint do done downto else end exception external for fun function functor if in include inherit initializer land lazy let lor lsl lsr lxor match method mod module mutable new nonrec object of open or private rec sig struct then to try type val virtual when while with",built_in:"array bool bytes char exn|5 float int int32 int64 list lazy_t|5 nativeint|5 ref string unit ",literal:"true false"},o="\\b(0[xX][a-fA-F0-9_]+[Lln]?|0[oO][0-7_]+[Lln]?|0[bB][01_]+[Lln]?|[0-9][0-9_]*([Lln]|(\\.[0-9_]*)?([eE][-+]?[0-9_]+)?)?)",s={className:"number",relevance:0,variants:[{begin:o},{begin:"\\(\\-"+o+"\\)"}]},l={className:"operator",relevance:0,begin:n},_=[{className:"identifier",relevance:0,begin:t},l,s],c=[e.QUOTE_STRING_MODE,l,{className:"module",begin:"\\b"+a,returnBegin:!0,end:".",contains:[{className:"identifier",begin:a,relevance:0}]}],d=[{className:"module",begin:"\\b"+a,returnBegin:!0,end:".",relevance:0,contains:[{className:"identifier",begin:a,relevance:0}]}],u={className:"function",relevance:0,keywords:i,variants:[{begin:"\\s(\\(\\.?.*?\\)|"+t+")\\s*=>",end:"\\s*=>",returnBegin:!0,relevance:0,contains:[{className:"params",variants:[{begin:t},{begin:"~?[a-z$_][0-9a-zA-Z$_]*(s*:s*[a-z$_][0-9a-z$_]*((s*('?[a-z$_][0-9a-z$_]*s*(,'?[a-z$_][0-9a-z$_]*)*)?s*))?)?(s*:s*[a-z$_][0-9a-z$_]*((s*('?[a-z$_][0-9a-z$_]*s*(,'?[a-z$_][0-9a-z$_]*)*)?s*))?)?"},{begin:/\(\s*\)/}]}]},{begin:"\\s\\(\\.?[^;\\|]*\\)\\s*=>",end:"\\s=>",returnBegin:!0,relevance:0,contains:[{className:"params",relevance:0,variants:[{begin:t,end:"(,|\\n|\\))",relevance:0,contains:[l,{className:"typing",begin:":",end:"(,|\\n)",returnBegin:!0,relevance:0,contains:d}]}]}]},{begin:"\\(\\.\\s"+t+"\\)\\s*=>"}]};c.push(u);var m={className:"constructor",begin:a+"\\(",end:"\\)",illegal:"\\n",keywords:i,contains:[e.QUOTE_STRING_MODE,l,{className:"params",begin:"\\b"+t}]},p={className:"pattern-match",begin:"\\|",returnBegin:!0,keywords:i,end:"=>",relevance:0,contains:[m,l,{relevance:0,className:"constructor",begin:a}]},E={className:"module-access",keywords:i,returnBegin:!0,variants:[{begin:"\\b("+a+"\\.)+"+t},{begin:"\\b("+a+"\\.)+\\(",end:"\\)",returnBegin:!0,contains:[u,{begin:"\\(",end:"\\)",skip:!0}].concat(c)},{begin:"\\b("+a+"\\.)+{",end:"}"}],contains:c};return d.push(E),{name:"ReasonML",aliases:["re"],keywords:i,illegal:"(:\\-|:=|\\${|\\+=)",contains:[e.COMMENT("/\\*","\\*/",{illegal:"^(\\#,\\/\\/)"}),{className:"character",begin:"'(\\\\[^']+|[^'])'",illegal:"\\n",relevance:0},e.QUOTE_STRING_MODE,{className:"literal",begin:"\\(\\)",relevance:0},{className:"literal",begin:"\\[\\|",end:"\\|\\]",relevance:0,contains:_},{className:"literal",begin:"\\[",end:"\\]",relevance:0,contains:_},m,{className:"operator",begin:r,illegal:"\\-\\->",relevance:0},s,e.C_LINE_COMMENT_MODE,p,u,{className:"module-def",begin:"\\bmodule\\s+"+t+"\\s+"+a+"\\s+=\\s+{",end:"}",returnBegin:!0,keywords:i,relevance:0,contains:[{className:"module",relevance:0,begin:a},{begin:"{",end:"}",skip:!0}].concat(c)},E]}},ys=function(e){return{name:"RenderMan RIB",keywords:"ArchiveRecord AreaLightSource Atmosphere Attribute AttributeBegin AttributeEnd Basis Begin Blobby Bound Clipping ClippingPlane Color ColorSamples ConcatTransform Cone CoordinateSystem CoordSysTransform CropWindow Curves Cylinder DepthOfField Detail DetailRange Disk Displacement Display End ErrorHandler Exposure Exterior Format FrameAspectRatio FrameBegin FrameEnd GeneralPolygon GeometricApproximation Geometry Hider Hyperboloid Identity Illuminate Imager Interior LightSource MakeCubeFaceEnvironment MakeLatLongEnvironment MakeShadow MakeTexture Matte MotionBegin MotionEnd NuPatch ObjectBegin ObjectEnd ObjectInstance Opacity Option Orientation Paraboloid Patch PatchMesh Perspective PixelFilter PixelSamples PixelVariance Points PointsGeneralPolygons PointsPolygons Polygon Procedural Projection Quantize ReadArchive RelativeDetail ReverseOrientation Rotate Scale ScreenWindow ShadingInterpolation ShadingRate Shutter Sides Skew SolidBegin SolidEnd Sphere SubdivisionMesh Surface TextureCoordinates Torus Transform TransformBegin TransformEnd TransformPoints Translate TrimCurve WorldBegin WorldEnd",illegal:"\]$/},{begin:/<\//,end:/>/},{begin:/^facet /,end:/\}/},{begin:"^1\\.\\.(\\d+)$",end:/$/}],illegal:/./},e.COMMENT("^#","$"),r,i,n,{begin:/[\w-]+\=([^\s\{\}\[\]\(\)]+)/,relevance:0,returnBegin:!0,contains:[{className:"attribute",begin:/[^=]+/},{begin:/=/,endsWithParent:!0,relevance:0,contains:[r,i,n,{className:"literal",begin:"\\b("+a.split(" ").join("|")+")\\b"},{begin:/("[^"]*"|[^\s\{\}\[\]]+)/}]}]},{className:"number",begin:/\*[0-9a-fA-F]+/},{begin:"\\b("+"add remove enable disable set get print export edit find run debug error info warning".split(" ").join("|")+")([\\s[(]|])",returnBegin:!0,contains:[{className:"builtin-name",begin:/\w+/}]},{className:"built_in",variants:[{begin:"(\\.\\./|/|\\s)(("+"traffic-flow traffic-generator firewall scheduler aaa accounting address-list address align area bandwidth-server bfd bgp bridge client clock community config connection console customer default dhcp-client dhcp-server discovery dns e-mail ethernet filter firewall firmware gps graphing group hardware health hotspot identity igmp-proxy incoming instance interface ip ipsec ipv6 irq l2tp-server lcd ldp logging mac-server mac-winbox mangle manual mirror mme mpls nat nd neighbor network note ntp ospf ospf-v3 ovpn-server page peer pim ping policy pool port ppp pppoe-client pptp-server prefix profile proposal proxy queue radius resource rip ripng route routing screen script security-profiles server service service-port settings shares smb sms sniffer snmp snooper socks sstp-server system tool tracking type upgrade upnp user-manager users user vlan secret vrrp watchdog web-access wireless pptp pppoe lan wan layer7-protocol lease simple raw".split(" ").join("|")+");?\\s)+",relevance:10},{begin:/\.\./}]}]}},Ls=function(e){return{name:"RenderMan RSL",keywords:{keyword:"float color point normal vector matrix while for if do return else break extern continue",built_in:"abs acos ambient area asin atan atmosphere attribute calculatenormal ceil cellnoise clamp comp concat cos degrees depth Deriv diffuse distance Du Dv environment exp faceforward filterstep floor format fresnel incident length lightsource log match max min mod noise normalize ntransform opposite option phong pnoise pow printf ptlined radians random reflect refract renderinfo round setcomp setxcomp setycomp setzcomp shadow sign sin smoothstep specular specularbrdf spline sqrt step tan texture textureinfo trace transform vtransform xcomp ycomp zcomp"},illegal:""}]}},Ps=function(e){return{name:"SAS",aliases:["sas","SAS"],case_insensitive:!0,keywords:{literal:"null missing _all_ _automatic_ _character_ _infile_ _n_ _name_ _null_ _numeric_ _user_ _webout_",meta:"do if then else end until while abort array attrib by call cards cards4 catname continue datalines datalines4 delete delim delimiter display dm drop endsas error file filename footnote format goto in infile informat input keep label leave length libname link list lostcard merge missing modify options output out page put redirect remove rename replace retain return select set skip startsas stop title update waitsas where window x systask add and alter as cascade check create delete describe distinct drop foreign from group having index insert into in key like message modify msgtype not null on or order primary references reset restrict select set table unique update validate view where"},contains:[{className:"keyword",begin:/^\s*(proc [\w\d_]+|data|run|quit)[\s\;]/},{className:"variable",begin:/\&[a-zA-Z_\&][a-zA-Z0-9_]*\.?/},{className:"emphasis",begin:/^\s*datalines|cards.*;/,end:/^\s*;\s*$/},{className:"built_in",begin:"%(bquote|nrbquote|cmpres|qcmpres|compstor|datatyp|display|do|else|end|eval|global|goto|if|index|input|keydef|label|left|length|let|local|lowcase|macro|mend|nrbquote|nrquote|nrstr|put|qcmpres|qleft|qlowcase|qscan|qsubstr|qsysfunc|qtrim|quote|qupcase|scan|str|substr|superq|syscall|sysevalf|sysexec|sysfunc|sysget|syslput|sysprod|sysrc|sysrput|then|to|trim|unquote|until|upcase|verify|while|window)"},{className:"name",begin:/%[a-zA-Z_][a-zA-Z_0-9]*/},{className:"meta",begin:"[^%](abs|addr|airy|arcos|arsin|atan|attrc|attrn|band|betainv|blshift|bnot|bor|brshift|bxor|byte|cdf|ceil|cexist|cinv|close|cnonct|collate|compbl|compound|compress|cos|cosh|css|curobs|cv|daccdb|daccdbsl|daccsl|daccsyd|dacctab|dairy|date|datejul|datepart|datetime|day|dclose|depdb|depdbsl|depdbsl|depsl|depsl|depsyd|depsyd|deptab|deptab|dequote|dhms|dif|digamma|dim|dinfo|dnum|dopen|doptname|doptnum|dread|dropnote|dsname|erf|erfc|exist|exp|fappend|fclose|fcol|fdelete|fetch|fetchobs|fexist|fget|fileexist|filename|fileref|finfo|finv|fipname|fipnamel|fipstate|floor|fnonct|fnote|fopen|foptname|foptnum|fpoint|fpos|fput|fread|frewind|frlen|fsep|fuzz|fwrite|gaminv|gamma|getoption|getvarc|getvarn|hbound|hms|hosthelp|hour|ibessel|index|indexc|indexw|input|inputc|inputn|int|intck|intnx|intrr|irr|jbessel|juldate|kurtosis|lag|lbound|left|length|lgamma|libname|libref|log|log10|log2|logpdf|logpmf|logsdf|lowcase|max|mdy|mean|min|minute|mod|month|mopen|mort|n|netpv|nmiss|normal|note|npv|open|ordinal|pathname|pdf|peek|peekc|pmf|point|poisson|poke|probbeta|probbnml|probchi|probf|probgam|probhypr|probit|probnegb|probnorm|probt|put|putc|putn|qtr|quote|ranbin|rancau|ranexp|rangam|range|rank|rannor|ranpoi|rantbl|rantri|ranuni|repeat|resolve|reverse|rewind|right|round|saving|scan|sdf|second|sign|sin|sinh|skewness|soundex|spedis|sqrt|std|stderr|stfips|stname|stnamel|substr|sum|symget|sysget|sysmsg|sysprod|sysrc|system|tan|tanh|time|timepart|tinv|tnonct|today|translate|tranwrd|trigamma|trim|trimn|trunc|uniform|upcase|uss|var|varfmt|varinfmt|varlabel|varlen|varname|varnum|varray|varrayx|vartype|verify|vformat|vformatd|vformatdx|vformatn|vformatnx|vformatw|vformatwx|vformatx|vinarray|vinarrayx|vinformat|vinformatd|vinformatdx|vinformatn|vinformatnx|vinformatw|vinformatwx|vinformatx|vlabel|vlabelx|vlength|vlengthx|vname|vnamex|vtype|vtypex|weekday|year|yyq|zipfips|zipname|zipnamel|zipstate)[(]"},{className:"string",variants:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]},e.COMMENT("\\*",";"),e.C_BLOCK_COMMENT_MODE]}},ks=function(e){var t={className:"subst",variants:[{begin:"\\$[A-Za-z0-9_]+"},{begin:"\\${",end:"}"}]},a={className:"string",variants:[{begin:'"',end:'"',illegal:"\\n",contains:[e.BACKSLASH_ESCAPE]},{begin:'"""',end:'"""',relevance:10},{begin:'[a-z]+"',end:'"',illegal:"\\n",contains:[e.BACKSLASH_ESCAPE,t]},{className:"string",begin:'[a-z]+"""',end:'"""',contains:[t],relevance:10}]},n={className:"type",begin:"\\b[A-Z][A-Za-z0-9_]*",relevance:0},r={className:"title",begin:/[^0-9\n\t "'(),.`{}\[\]:;][^\n\t "'(),.`{}\[\]:;]+|[^0-9\n\t "'(),.`{}\[\]:;=]/,relevance:0},i={className:"class",beginKeywords:"class object trait type",end:/[:={\[\n;]/,excludeEnd:!0,contains:[{beginKeywords:"extends with",relevance:10},{begin:/\[/,end:/\]/,excludeBegin:!0,excludeEnd:!0,relevance:0,contains:[n]},{className:"params",begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,relevance:0,contains:[n]},r]},o={className:"function",beginKeywords:"def",end:/[:={\[(\n;]/,excludeEnd:!0,contains:[r]};return{name:"Scala",keywords:{literal:"true false null",keyword:"type yield lazy override def with val var sealed abstract private trait object if forSome for while throw finally protected extends import final return else break new catch super class case package default try this match continue throws implicit"},contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,a,{className:"symbol",begin:"'\\w[\\w\\d_]*(?!')"},n,o,i,e.C_NUMBER_MODE,{className:"meta",begin:"@[A-Za-z]+"}]}},Us=function(e){var t="[^\\(\\)\\[\\]\\{\\}\",'`;#|\\\\\\s]+",a={className:"literal",begin:"(#t|#f|#\\\\"+t+"|#\\\\.)"},n={className:"number",variants:[{begin:"(\\-|\\+)?\\d+([./]\\d+)?",relevance:0},{begin:"(\\-|\\+)?\\d+([./]\\d+)?[+\\-](\\-|\\+)?\\d+([./]\\d+)?i",relevance:0},{begin:"#b[0-1]+(/[0-1]+)?"},{begin:"#o[0-7]+(/[0-7]+)?"},{begin:"#x[0-9a-f]+(/[0-9a-f]+)?"}]},r=e.QUOTE_STRING_MODE,i=[e.COMMENT(";","$",{relevance:0}),e.COMMENT("#\\|","\\|#")],o={begin:t,relevance:0},s={className:"symbol",begin:"'"+t},l={endsWithParent:!0,relevance:0},_={variants:[{begin:/'/},{begin:"`"}],contains:[{begin:"\\(",end:"\\)",contains:["self",a,r,n,o,s]}]},c={className:"name",begin:t,lexemes:t,keywords:{"builtin-name":"case-lambda call/cc class define-class exit-handler field import inherit init-field interface let*-values let-values let/ec mixin opt-lambda override protect provide public rename require require-for-syntax syntax syntax-case syntax-error unit/sig unless when with-syntax and begin call-with-current-continuation call-with-input-file call-with-output-file case cond define define-syntax delay do dynamic-wind else for-each if lambda let let* let-syntax letrec letrec-syntax map or syntax-rules ' * + , ,@ - ... / ; < <= = => > >= ` abs acos angle append apply asin assoc assq assv atan boolean? caar cadr call-with-input-file call-with-output-file call-with-values car cdddar cddddr cdr ceiling char->integer char-alphabetic? char-ci<=? char-ci=? char-ci>? char-downcase char-lower-case? char-numeric? char-ready? char-upcase char-upper-case? char-whitespace? char<=? char=? char>? char? close-input-port close-output-port complex? cons cos current-input-port current-output-port denominator display eof-object? eq? equal? eqv? eval even? exact->inexact exact? exp expt floor force gcd imag-part inexact->exact inexact? input-port? integer->char integer? interaction-environment lcm length list list->string list->vector list-ref list-tail list? load log magnitude make-polar make-rectangular make-string make-vector max member memq memv min modulo negative? newline not null-environment null? number->string number? numerator odd? open-input-file open-output-file output-port? pair? peek-char port? positive? procedure? quasiquote quote quotient rational? rationalize read read-char real-part real? remainder reverse round scheme-report-environment set! set-car! set-cdr! sin sqrt string string->list string->number string->symbol string-append string-ci<=? string-ci=? string-ci>? string-copy string-fill! string-length string-ref string-set! string<=? string=? string>? string? substring symbol->string symbol? tan transcript-off transcript-on truncate values vector vector->list vector-fill! vector-length vector-ref vector-set! with-input-from-file with-output-to-file write write-char zero?"}},d={variants:[{begin:"\\(",end:"\\)"},{begin:"\\[",end:"\\]"}],contains:[{begin:/lambda/,endsWithParent:!0,returnBegin:!0,contains:[c,{begin:/\(/,end:/\)/,endsParent:!0,contains:[o]}]},c,l]};return l.contains=[a,n,r,o,s,_,d].concat(i),{name:"Scheme",illegal:/\S/,contains:[{className:"meta",begin:"^#!",end:"$"},n,r,s,_,d].concat(i)}},Fs=function(e){var t=[e.C_NUMBER_MODE,{className:"string",begin:"'|\"",end:"'|\"",contains:[e.BACKSLASH_ESCAPE,{begin:"''"}]}];return{name:"Scilab",aliases:["sci"],lexemes:/%?\w+/,keywords:{keyword:"abort break case clear catch continue do elseif else endfunction end for function global if pause return resume select try then while",literal:"%f %F %t %T %pi %eps %inf %nan %e %i %z %s",built_in:"abs and acos asin atan ceil cd chdir clearglobal cosh cos cumprod deff disp error exec execstr exists exp eye gettext floor fprintf fread fsolve imag isdef isempty isinfisnan isvector lasterror length load linspace list listfiles log10 log2 log max min msprintf mclose mopen ones or pathconvert poly printf prod pwd rand real round sinh sin size gsort sprintf sqrt strcat strcmps tring sum system tanh tan type typename warning zeros matrix"},illegal:'("|#|/\\*|\\s+/\\w+)',contains:[{className:"function",beginKeywords:"function",end:"$",contains:[e.UNDERSCORE_TITLE_MODE,{className:"params",begin:"\\(",end:"\\)"}]},{begin:"[a-zA-Z_][a-zA-Z_0-9]*('+[\\.']*|[\\.']+)",end:"",relevance:0},{begin:"\\[",end:"\\]'*[\\.']*",relevance:0,contains:t},e.COMMENT("//","$")].concat(t)}},Bs=function(e){var t={className:"variable",begin:"(\\$[a-zA-Z-][a-zA-Z0-9_-]*)\\b"},a={className:"number",begin:"#[0-9A-Fa-f]+"};return e.CSS_NUMBER_MODE,e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,e.C_BLOCK_COMMENT_MODE,{name:"SCSS",case_insensitive:!0,illegal:"[=/|']",contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{className:"selector-id",begin:"\\#[A-Za-z0-9_-]+",relevance:0},{className:"selector-class",begin:"\\.[A-Za-z0-9_-]+",relevance:0},{className:"selector-attr",begin:"\\[",end:"\\]",illegal:"$"},{className:"selector-tag",begin:"\\b(a|abbr|acronym|address|area|article|aside|audio|b|base|big|blockquote|body|br|button|canvas|caption|cite|code|col|colgroup|command|datalist|dd|del|details|dfn|div|dl|dt|em|embed|fieldset|figcaption|figure|footer|form|frame|frameset|(h[1-6])|head|header|hgroup|hr|html|i|iframe|img|input|ins|kbd|keygen|label|legend|li|link|map|mark|meta|meter|nav|noframes|noscript|object|ol|optgroup|option|output|p|param|pre|progress|q|rp|rt|ruby|samp|script|section|select|small|span|strike|strong|style|sub|sup|table|tbody|td|textarea|tfoot|th|thead|time|title|tr|tt|ul|var|video)\\b",relevance:0},{className:"selector-pseudo",begin:":(visited|valid|root|right|required|read-write|read-only|out-range|optional|only-of-type|only-child|nth-of-type|nth-last-of-type|nth-last-child|nth-child|not|link|left|last-of-type|last-child|lang|invalid|indeterminate|in-range|hover|focus|first-of-type|first-line|first-letter|first-child|first|enabled|empty|disabled|default|checked|before|after|active)"},{className:"selector-pseudo",begin:"::(after|before|choices|first-letter|first-line|repeat-index|repeat-item|selection|value)"},t,{className:"attribute",begin:"\\b(src|z-index|word-wrap|word-spacing|word-break|width|widows|white-space|visibility|vertical-align|unicode-bidi|transition-timing-function|transition-property|transition-duration|transition-delay|transition|transform-style|transform-origin|transform|top|text-underline-position|text-transform|text-shadow|text-rendering|text-overflow|text-indent|text-decoration-style|text-decoration-line|text-decoration-color|text-decoration|text-align-last|text-align|tab-size|table-layout|right|resize|quotes|position|pointer-events|perspective-origin|perspective|page-break-inside|page-break-before|page-break-after|padding-top|padding-right|padding-left|padding-bottom|padding|overflow-y|overflow-x|overflow-wrap|overflow|outline-width|outline-style|outline-offset|outline-color|outline|orphans|order|opacity|object-position|object-fit|normal|none|nav-up|nav-right|nav-left|nav-index|nav-down|min-width|min-height|max-width|max-height|mask|marks|margin-top|margin-right|margin-left|margin-bottom|margin|list-style-type|list-style-position|list-style-image|list-style|line-height|letter-spacing|left|justify-content|initial|inherit|ime-mode|image-orientation|image-resolution|image-rendering|icon|hyphens|height|font-weight|font-variant-ligatures|font-variant|font-style|font-stretch|font-size-adjust|font-size|font-language-override|font-kerning|font-feature-settings|font-family|font|float|flex-wrap|flex-shrink|flex-grow|flex-flow|flex-direction|flex-basis|flex|filter|empty-cells|display|direction|cursor|counter-reset|counter-increment|content|column-width|column-span|column-rule-width|column-rule-style|column-rule-color|column-rule|column-gap|column-fill|column-count|columns|color|clip-path|clip|clear|caption-side|break-inside|break-before|break-after|box-sizing|box-shadow|box-decoration-break|bottom|border-width|border-top-width|border-top-style|border-top-right-radius|border-top-left-radius|border-top-color|border-top|border-style|border-spacing|border-right-width|border-right-style|border-right-color|border-right|border-radius|border-left-width|border-left-style|border-left-color|border-left|border-image-width|border-image-source|border-image-slice|border-image-repeat|border-image-outset|border-image|border-color|border-collapse|border-bottom-width|border-bottom-style|border-bottom-right-radius|border-bottom-left-radius|border-bottom-color|border-bottom|border|background-size|background-repeat|background-position|background-origin|background-image|background-color|background-clip|background-attachment|background-blend-mode|background|backface-visibility|auto|animation-timing-function|animation-play-state|animation-name|animation-iteration-count|animation-fill-mode|animation-duration|animation-direction|animation-delay|animation|align-self|align-items|align-content)\\b",illegal:"[^\\s]"},{begin:"\\b(whitespace|wait|w-resize|visible|vertical-text|vertical-ideographic|uppercase|upper-roman|upper-alpha|underline|transparent|top|thin|thick|text|text-top|text-bottom|tb-rl|table-header-group|table-footer-group|sw-resize|super|strict|static|square|solid|small-caps|separate|se-resize|scroll|s-resize|rtl|row-resize|ridge|right|repeat|repeat-y|repeat-x|relative|progress|pointer|overline|outside|outset|oblique|nowrap|not-allowed|normal|none|nw-resize|no-repeat|no-drop|newspaper|ne-resize|n-resize|move|middle|medium|ltr|lr-tb|lowercase|lower-roman|lower-alpha|loose|list-item|line|line-through|line-edge|lighter|left|keep-all|justify|italic|inter-word|inter-ideograph|inside|inset|inline|inline-block|inherit|inactive|ideograph-space|ideograph-parenthesis|ideograph-numeric|ideograph-alpha|horizontal|hidden|help|hand|groove|fixed|ellipsis|e-resize|double|dotted|distribute|distribute-space|distribute-letter|distribute-all-lines|disc|disabled|default|decimal|dashed|crosshair|collapse|col-resize|circle|char|center|capitalize|break-word|break-all|bottom|both|bolder|bold|block|bidi-override|below|baseline|auto|always|all-scroll|absolute|table|table-cell)\\b"},{begin:":",end:";",contains:[t,a,e.CSS_NUMBER_MODE,e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,{className:"meta",begin:"!important"}]},{begin:"@(page|font-face)",lexemes:"@[a-z-]+",keywords:"@page @font-face"},{begin:"@",end:"[{;]",returnBegin:!0,keywords:"and or not only",contains:[{begin:"@[a-z-]+",className:"keyword"},t,e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,a,e.CSS_NUMBER_MODE]}]}},Gs=function(){return{name:"Shell Session",aliases:["console"],contains:[{className:"meta",begin:"^\\s{0,3}[/\\w\\d\\[\\]()@-]*[>%$#]",starts:{end:"$",subLanguage:"bash"}}]}},Ys=function(e){var t=["add","and","cmp","cmpg","cmpl","const","div","double","float","goto","if","int","long","move","mul","neg","new","nop","not","or","rem","return","shl","shr","sput","sub","throw","ushr","xor"];return{name:"Smali",aliases:["smali"],contains:[{className:"string",begin:'"',end:'"',relevance:0},e.COMMENT("#","$",{relevance:0}),{className:"keyword",variants:[{begin:"\\s*\\.end\\s[a-zA-Z0-9]*"},{begin:"^[ ]*\\.[a-zA-Z]*",relevance:0},{begin:"\\s:[a-zA-Z_0-9]*",relevance:0},{begin:"\\s("+["transient","constructor","abstract","final","synthetic","public","private","protected","static","bridge","system"].join("|")+")"}]},{className:"built_in",variants:[{begin:"\\s("+t.join("|")+")\\s"},{begin:"\\s("+t.join("|")+")((\\-|/)[a-zA-Z0-9]+)+\\s",relevance:10},{begin:"\\s("+["aget","aput","array","check","execute","fill","filled","goto/16","goto/32","iget","instance","invoke","iput","monitor","packed","sget","sparse"].join("|")+")((\\-|/)[a-zA-Z0-9]+)*\\s",relevance:10}]},{className:"class",begin:"L[^(;:\n]*;",relevance:0},{begin:"[vp][0-9]+"}]}},Hs=function(e){var t={className:"string",begin:"\\$.{1}"},a={className:"symbol",begin:"#"+e.UNDERSCORE_IDENT_RE};return{name:"Smalltalk",aliases:["st"],keywords:"self super nil true false thisContext",contains:[e.COMMENT('"','"'),e.APOS_STRING_MODE,{className:"type",begin:"\\b[A-Z][A-Za-z0-9_]*",relevance:0},{begin:"[a-z][a-zA-Z0-9_]*:",relevance:0},e.C_NUMBER_MODE,a,t,{begin:"\\|[ ]*[a-z][a-zA-Z0-9_]*([ ]+[a-z][a-zA-Z0-9_]*)*[ ]*\\|",returnBegin:!0,end:/\|/,illegal:/\S/,contains:[{begin:"(\\|[ ]*)?[a-z][a-zA-Z0-9_]*"}]},{begin:"\\#\\(",end:"\\)",contains:[e.APOS_STRING_MODE,t,e.C_NUMBER_MODE,a]}]}},Vs=function(e){return{name:"SML (Standard ML)",aliases:["ml"],keywords:{keyword:"abstype and andalso as case datatype do else end eqtype exception fn fun functor handle if in include infix infixr let local nonfix of op open orelse raise rec sharing sig signature struct structure then type val with withtype where while",built_in:"array bool char exn int list option order real ref string substring vector unit word",literal:"true false NONE SOME LESS EQUAL GREATER nil"},illegal:/\/\/|>>/,lexemes:"[a-z_]\\w*!?",contains:[{className:"literal",begin:/\[(\|\|)?\]|\(\)/,relevance:0},e.COMMENT("\\(\\*","\\*\\)",{contains:["self"]}),{className:"symbol",begin:"'[A-Za-z_](?!')[\\w']*"},{className:"type",begin:"`[A-Z][\\w']*"},{className:"type",begin:"\\b[A-Z][\\w']*",relevance:0},{begin:"[a-z_]\\w*'[\\w']*"},e.inherit(e.APOS_STRING_MODE,{className:"string",relevance:0}),e.inherit(e.QUOTE_STRING_MODE,{illegal:null}),{className:"number",begin:"\\b(0[xX][a-fA-F0-9_]+[Lln]?|0[oO][0-7_]+[Lln]?|0[bB][01_]+[Lln]?|[0-9][0-9_]*([Lln]|(\\.[0-9_]*)?([eE][-+]?[0-9_]+)?)?)",relevance:0},{begin:/[-=]>/}]}},qs=function(e){var t={className:"string",variants:[{begin:'"',end:'"',contains:[{begin:'""',relevance:0}]},{begin:"'",end:"'",contains:[{begin:"''",relevance:0}]}]},a={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{"meta-keyword":"define undef ifdef ifndef else endif include"},contains:[{begin:/\\\n/,relevance:0},e.inherit(t,{className:"meta-string"}),{className:"meta-string",begin:/<[^\n>]*>/,end:/$/,illegal:"\\n"},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]};return{name:"SQF",aliases:["sqf"],case_insensitive:!0,keywords:{keyword:"case catch default do else exit exitWith for forEach from if private switch then throw to try waitUntil while with", +built_in:"abs accTime acos action actionIDs actionKeys actionKeysImages actionKeysNames actionKeysNamesArray actionName actionParams activateAddons activatedAddons activateKey add3DENConnection add3DENEventHandler add3DENLayer addAction addBackpack addBackpackCargo addBackpackCargoGlobal addBackpackGlobal addCamShake addCuratorAddons addCuratorCameraArea addCuratorEditableObjects addCuratorEditingArea addCuratorPoints addEditorObject addEventHandler addForce addGoggles addGroupIcon addHandgunItem addHeadgear addItem addItemCargo addItemCargoGlobal addItemPool addItemToBackpack addItemToUniform addItemToVest addLiveStats addMagazine addMagazineAmmoCargo addMagazineCargo addMagazineCargoGlobal addMagazineGlobal addMagazinePool addMagazines addMagazineTurret addMenu addMenuItem addMissionEventHandler addMPEventHandler addMusicEventHandler addOwnedMine addPlayerScores addPrimaryWeaponItem addPublicVariableEventHandler addRating addResources addScore addScoreSide addSecondaryWeaponItem addSwitchableUnit addTeamMember addToRemainsCollector addTorque addUniform addVehicle addVest addWaypoint addWeapon addWeaponCargo addWeaponCargoGlobal addWeaponGlobal addWeaponItem addWeaponPool addWeaponTurret admin agent agents AGLToASL aimedAtTarget aimPos airDensityRTD airplaneThrottle airportSide AISFinishHeal alive all3DENEntities allAirports allControls allCurators allCutLayers allDead allDeadMen allDisplays allGroups allMapMarkers allMines allMissionObjects allow3DMode allowCrewInImmobile allowCuratorLogicIgnoreAreas allowDamage allowDammage allowFileOperations allowFleeing allowGetIn allowSprint allPlayers allSimpleObjects allSites allTurrets allUnits allUnitsUAV allVariables ammo ammoOnPylon and animate animateBay animateDoor animatePylon animateSource animationNames animationPhase animationSourcePhase animationState append apply armoryPoints arrayIntersect asin ASLToAGL ASLToATL assert assignAsCargo assignAsCargoIndex assignAsCommander assignAsDriver assignAsGunner assignAsTurret assignCurator assignedCargo assignedCommander assignedDriver assignedGunner assignedItems assignedTarget assignedTeam assignedVehicle assignedVehicleRole assignItem assignTeam assignToAirport atan atan2 atg ATLToASL attachedObject attachedObjects attachedTo attachObject attachTo attackEnabled backpack backpackCargo backpackContainer backpackItems backpackMagazines backpackSpaceFor behaviour benchmark binocular boundingBox boundingBoxReal boundingCenter breakOut breakTo briefingName buildingExit buildingPos buttonAction buttonSetAction cadetMode call callExtension camCommand camCommit camCommitPrepared camCommitted camConstuctionSetParams camCreate camDestroy cameraEffect cameraEffectEnableHUD cameraInterest cameraOn cameraView campaignConfigFile camPreload camPreloaded camPrepareBank camPrepareDir camPrepareDive camPrepareFocus camPrepareFov camPrepareFovRange camPreparePos camPrepareRelPos camPrepareTarget camSetBank camSetDir camSetDive camSetFocus camSetFov camSetFovRange camSetPos camSetRelPos camSetTarget camTarget camUseNVG canAdd canAddItemToBackpack canAddItemToUniform canAddItemToVest cancelSimpleTaskDestination canFire canMove canSlingLoad canStand canSuspend canTriggerDynamicSimulation canUnloadInCombat canVehicleCargo captive captiveNum cbChecked cbSetChecked ceil channelEnabled cheatsEnabled checkAIFeature checkVisibility className clearAllItemsFromBackpack clearBackpackCargo clearBackpackCargoGlobal clearGroupIcons clearItemCargo clearItemCargoGlobal clearItemPool clearMagazineCargo clearMagazineCargoGlobal clearMagazinePool clearOverlay clearRadio clearWeaponCargo clearWeaponCargoGlobal clearWeaponPool clientOwner closeDialog closeDisplay closeOverlay collapseObjectTree collect3DENHistory collectiveRTD combatMode commandArtilleryFire commandChat commander commandFire commandFollow commandFSM commandGetOut commandingMenu commandMove commandRadio commandStop commandSuppressiveFire commandTarget commandWatch comment commitOverlay compile compileFinal completedFSM composeText configClasses configFile configHierarchy configName configProperties configSourceAddonList configSourceMod configSourceModList confirmSensorTarget connectTerminalToUAV controlsGroupCtrl copyFromClipboard copyToClipboard copyWaypoints cos count countEnemy countFriendly countSide countType countUnknown create3DENComposition create3DENEntity createAgent createCenter createDialog createDiaryLink createDiaryRecord createDiarySubject createDisplay createGearDialog createGroup createGuardedPoint createLocation createMarker createMarkerLocal createMenu createMine createMissionDisplay createMPCampaignDisplay createSimpleObject createSimpleTask createSite createSoundSource createTask createTeam createTrigger createUnit createVehicle createVehicleCrew createVehicleLocal crew ctAddHeader ctAddRow ctClear ctCurSel ctData ctFindHeaderRows ctFindRowHeader ctHeaderControls ctHeaderCount ctRemoveHeaders ctRemoveRows ctrlActivate ctrlAddEventHandler ctrlAngle ctrlAutoScrollDelay ctrlAutoScrollRewind ctrlAutoScrollSpeed ctrlChecked ctrlClassName ctrlCommit ctrlCommitted ctrlCreate ctrlDelete ctrlEnable ctrlEnabled ctrlFade ctrlHTMLLoaded ctrlIDC ctrlIDD ctrlMapAnimAdd ctrlMapAnimClear ctrlMapAnimCommit ctrlMapAnimDone ctrlMapCursor ctrlMapMouseOver ctrlMapScale ctrlMapScreenToWorld ctrlMapWorldToScreen ctrlModel ctrlModelDirAndUp ctrlModelScale ctrlParent ctrlParentControlsGroup ctrlPosition ctrlRemoveAllEventHandlers ctrlRemoveEventHandler ctrlScale ctrlSetActiveColor ctrlSetAngle ctrlSetAutoScrollDelay ctrlSetAutoScrollRewind ctrlSetAutoScrollSpeed ctrlSetBackgroundColor ctrlSetChecked ctrlSetEventHandler ctrlSetFade ctrlSetFocus ctrlSetFont ctrlSetFontH1 ctrlSetFontH1B ctrlSetFontH2 ctrlSetFontH2B ctrlSetFontH3 ctrlSetFontH3B ctrlSetFontH4 ctrlSetFontH4B ctrlSetFontH5 ctrlSetFontH5B ctrlSetFontH6 ctrlSetFontH6B ctrlSetFontHeight ctrlSetFontHeightH1 ctrlSetFontHeightH2 ctrlSetFontHeightH3 ctrlSetFontHeightH4 ctrlSetFontHeightH5 ctrlSetFontHeightH6 ctrlSetFontHeightSecondary ctrlSetFontP ctrlSetFontPB ctrlSetFontSecondary ctrlSetForegroundColor ctrlSetModel ctrlSetModelDirAndUp ctrlSetModelScale ctrlSetPixelPrecision ctrlSetPosition ctrlSetScale ctrlSetStructuredText ctrlSetText ctrlSetTextColor ctrlSetTooltip ctrlSetTooltipColorBox ctrlSetTooltipColorShade ctrlSetTooltipColorText ctrlShow ctrlShown ctrlText ctrlTextHeight ctrlTextWidth ctrlType ctrlVisible ctRowControls ctRowCount ctSetCurSel ctSetData ctSetHeaderTemplate ctSetRowTemplate ctSetValue ctValue curatorAddons curatorCamera curatorCameraArea curatorCameraAreaCeiling curatorCoef curatorEditableObjects curatorEditingArea curatorEditingAreaType curatorMouseOver curatorPoints curatorRegisteredObjects curatorSelected curatorWaypointCost current3DENOperation currentChannel currentCommand currentMagazine currentMagazineDetail currentMagazineDetailTurret currentMagazineTurret currentMuzzle currentNamespace currentTask currentTasks currentThrowable currentVisionMode currentWaypoint currentWeapon currentWeaponMode currentWeaponTurret currentZeroing cursorObject cursorTarget customChat customRadio cutFadeOut cutObj cutRsc cutText damage date dateToNumber daytime deActivateKey debriefingText debugFSM debugLog deg delete3DENEntities deleteAt deleteCenter deleteCollection deleteEditorObject deleteGroup deleteGroupWhenEmpty deleteIdentity deleteLocation deleteMarker deleteMarkerLocal deleteRange deleteResources deleteSite deleteStatus deleteTeam deleteVehicle deleteVehicleCrew deleteWaypoint detach detectedMines diag_activeMissionFSMs diag_activeScripts diag_activeSQFScripts diag_activeSQSScripts diag_captureFrame diag_captureFrameToFile diag_captureSlowFrame diag_codePerformance diag_drawMode diag_enable diag_enabled diag_fps diag_fpsMin diag_frameNo diag_lightNewLoad diag_list diag_log diag_logSlowFrame diag_mergeConfigFile diag_recordTurretLimits diag_setLightNew diag_tickTime diag_toggle dialog diarySubjectExists didJIP didJIPOwner difficulty difficultyEnabled difficultyEnabledRTD difficultyOption direction directSay disableAI disableCollisionWith disableConversation disableDebriefingStats disableMapIndicators disableNVGEquipment disableRemoteSensors disableSerialization disableTIEquipment disableUAVConnectability disableUserInput displayAddEventHandler displayCtrl displayParent displayRemoveAllEventHandlers displayRemoveEventHandler displaySetEventHandler dissolveTeam distance distance2D distanceSqr distributionRegion do3DENAction doArtilleryFire doFire doFollow doFSM doGetOut doMove doorPhase doStop doSuppressiveFire doTarget doWatch drawArrow drawEllipse drawIcon drawIcon3D drawLine drawLine3D drawLink drawLocation drawPolygon drawRectangle drawTriangle driver drop dynamicSimulationDistance dynamicSimulationDistanceCoef dynamicSimulationEnabled dynamicSimulationSystemEnabled echo edit3DENMissionAttributes editObject editorSetEventHandler effectiveCommander emptyPositions enableAI enableAIFeature enableAimPrecision enableAttack enableAudioFeature enableAutoStartUpRTD enableAutoTrimRTD enableCamShake enableCaustics enableChannel enableCollisionWith enableCopilot enableDebriefingStats enableDiagLegend enableDynamicSimulation enableDynamicSimulationSystem enableEndDialog enableEngineArtillery enableEnvironment enableFatigue enableGunLights enableInfoPanelComponent enableIRLasers enableMimics enablePersonTurret enableRadio enableReload enableRopeAttach enableSatNormalOnDetail enableSaving enableSentences enableSimulation enableSimulationGlobal enableStamina enableTeamSwitch enableTraffic enableUAVConnectability enableUAVWaypoints enableVehicleCargo enableVehicleSensor enableWeaponDisassembly endLoadingScreen endMission engineOn enginesIsOnRTD enginesRpmRTD enginesTorqueRTD entities environmentEnabled estimatedEndServerTime estimatedTimeLeft evalObjectArgument everyBackpack everyContainer exec execEditorScript execFSM execVM exp expectedDestination exportJIPMessages eyeDirection eyePos face faction fadeMusic fadeRadio fadeSound fadeSpeech failMission fillWeaponsFromPool find findCover findDisplay findEditorObject findEmptyPosition findEmptyPositionReady findIf findNearestEnemy finishMissionInit finite fire fireAtTarget firstBackpack flag flagAnimationPhase flagOwner flagSide flagTexture fleeing floor flyInHeight flyInHeightASL fog fogForecast fogParams forceAddUniform forcedMap forceEnd forceFlagTexture forceFollowRoad forceMap forceRespawn forceSpeed forceWalk forceWeaponFire forceWeatherChange forEachMember forEachMemberAgent forEachMemberTeam forgetTarget format formation formationDirection formationLeader formationMembers formationPosition formationTask formatText formLeader freeLook fromEditor fuel fullCrew gearIDCAmmoCount gearSlotAmmoCount gearSlotData get3DENActionState get3DENAttribute get3DENCamera get3DENConnections get3DENEntity get3DENEntityID get3DENGrid get3DENIconsVisible get3DENLayerEntities get3DENLinesVisible get3DENMissionAttribute get3DENMouseOver get3DENSelected getAimingCoef getAllEnvSoundControllers getAllHitPointsDamage getAllOwnedMines getAllSoundControllers getAmmoCargo getAnimAimPrecision getAnimSpeedCoef getArray getArtilleryAmmo getArtilleryComputerSettings getArtilleryETA getAssignedCuratorLogic getAssignedCuratorUnit getBackpackCargo getBleedingRemaining getBurningValue getCameraViewDirection getCargoIndex getCenterOfMass getClientState getClientStateNumber getCompatiblePylonMagazines getConnectedUAV getContainerMaxLoad getCursorObjectParams getCustomAimCoef getDammage getDescription getDir getDirVisual getDLCAssetsUsage getDLCAssetsUsageByName getDLCs getEditorCamera getEditorMode getEditorObjectScope getElevationOffset getEnvSoundController getFatigue getForcedFlagTexture getFriend getFSMVariable getFuelCargo getGroupIcon getGroupIconParams getGroupIcons getHideFrom getHit getHitIndex getHitPointDamage getItemCargo getMagazineCargo getMarkerColor getMarkerPos getMarkerSize getMarkerType getMass getMissionConfig getMissionConfigValue getMissionDLCs getMissionLayerEntities getModelInfo getMousePosition getMusicPlayedTime getNumber getObjectArgument getObjectChildren getObjectDLC getObjectMaterials getObjectProxy getObjectTextures getObjectType getObjectViewDistance getOxygenRemaining getPersonUsedDLCs getPilotCameraDirection getPilotCameraPosition getPilotCameraRotation getPilotCameraTarget getPlateNumber getPlayerChannel getPlayerScores getPlayerUID getPos getPosASL getPosASLVisual getPosASLW getPosATL getPosATLVisual getPosVisual getPosWorld getPylonMagazines getRelDir getRelPos getRemoteSensorsDisabled getRepairCargo getResolution getShadowDistance getShotParents getSlingLoad getSoundController getSoundControllerResult getSpeed getStamina getStatValue getSuppression getTerrainGrid getTerrainHeightASL getText getTotalDLCUsageTime getUnitLoadout getUnitTrait getUserMFDText getUserMFDvalue getVariable getVehicleCargo getWeaponCargo getWeaponSway getWingsOrientationRTD getWingsPositionRTD getWPPos glanceAt globalChat globalRadio goggles goto group groupChat groupFromNetId groupIconSelectable groupIconsVisible groupId groupOwner groupRadio groupSelectedUnits groupSelectUnit gunner gusts halt handgunItems handgunMagazine handgunWeapon handsHit hasInterface hasPilotCamera hasWeapon hcAllGroups hcGroupParams hcLeader hcRemoveAllGroups hcRemoveGroup hcSelected hcSelectGroup hcSetGroup hcShowBar hcShownBar headgear hideBody hideObject hideObjectGlobal hideSelection hint hintC hintCadet hintSilent hmd hostMission htmlLoad HUDMovementLevels humidity image importAllGroups importance in inArea inAreaArray incapacitatedState inflame inflamed infoPanel infoPanelComponentEnabled infoPanelComponents infoPanels inGameUISetEventHandler inheritsFrom initAmbientLife inPolygon inputAction inRangeOfArtillery insertEditorObject intersect is3DEN is3DENMultiplayer isAbleToBreathe isAgent isArray isAutoHoverOn isAutonomous isAutotest isBleeding isBurning isClass isCollisionLightOn isCopilotEnabled isDamageAllowed isDedicated isDLCAvailable isEngineOn isEqualTo isEqualType isEqualTypeAll isEqualTypeAny isEqualTypeArray isEqualTypeParams isFilePatchingEnabled isFlashlightOn isFlatEmpty isForcedWalk isFormationLeader isGroupDeletedWhenEmpty isHidden isInRemainsCollector isInstructorFigureEnabled isIRLaserOn isKeyActive isKindOf isLaserOn isLightOn isLocalized isManualFire isMarkedForCollection isMultiplayer isMultiplayerSolo isNil isNull isNumber isObjectHidden isObjectRTD isOnRoad isPipEnabled isPlayer isRealTime isRemoteExecuted isRemoteExecutedJIP isServer isShowing3DIcons isSimpleObject isSprintAllowed isStaminaEnabled isSteamMission isStreamFriendlyUIEnabled isText isTouchingGround isTurnedOut isTutHintsEnabled isUAVConnectable isUAVConnected isUIContext isUniformAllowed isVehicleCargo isVehicleRadarOn isVehicleSensorEnabled isWalking isWeaponDeployed isWeaponRested itemCargo items itemsWithMagazines join joinAs joinAsSilent joinSilent joinString kbAddDatabase kbAddDatabaseTargets kbAddTopic kbHasTopic kbReact kbRemoveTopic kbTell kbWasSaid keyImage keyName knowsAbout land landAt landResult language laserTarget lbAdd lbClear lbColor lbColorRight lbCurSel lbData lbDelete lbIsSelected lbPicture lbPictureRight lbSelection lbSetColor lbSetColorRight lbSetCurSel lbSetData lbSetPicture lbSetPictureColor lbSetPictureColorDisabled lbSetPictureColorSelected lbSetPictureRight lbSetPictureRightColor lbSetPictureRightColorDisabled lbSetPictureRightColorSelected lbSetSelectColor lbSetSelectColorRight lbSetSelected lbSetText lbSetTextRight lbSetTooltip lbSetValue lbSize lbSort lbSortByValue lbText lbTextRight lbValue leader leaderboardDeInit leaderboardGetRows leaderboardInit leaderboardRequestRowsFriends leaderboardsRequestUploadScore leaderboardsRequestUploadScoreKeepBest leaderboardState leaveVehicle libraryCredits libraryDisclaimers lifeState lightAttachObject lightDetachObject lightIsOn lightnings limitSpeed linearConversion lineIntersects lineIntersectsObjs lineIntersectsSurfaces lineIntersectsWith linkItem list listObjects listRemoteTargets listVehicleSensors ln lnbAddArray lnbAddColumn lnbAddRow lnbClear lnbColor lnbCurSelRow lnbData lnbDeleteColumn lnbDeleteRow lnbGetColumnsPosition lnbPicture lnbSetColor lnbSetColumnsPos lnbSetCurSelRow lnbSetData lnbSetPicture lnbSetText lnbSetValue lnbSize lnbSort lnbSortByValue lnbText lnbValue load loadAbs loadBackpack loadFile loadGame loadIdentity loadMagazine loadOverlay loadStatus loadUniform loadVest local localize locationPosition lock lockCameraTo lockCargo lockDriver locked lockedCargo lockedDriver lockedTurret lockIdentity lockTurret lockWP log logEntities logNetwork logNetworkTerminate lookAt lookAtPos magazineCargo magazines magazinesAllTurrets magazinesAmmo magazinesAmmoCargo magazinesAmmoFull magazinesDetail magazinesDetailBackpack magazinesDetailUniform magazinesDetailVest magazinesTurret magazineTurretAmmo mapAnimAdd mapAnimClear mapAnimCommit mapAnimDone mapCenterOnCamera mapGridPosition markAsFinishedOnSteam markerAlpha markerBrush markerColor markerDir markerPos markerShape markerSize markerText markerType max members menuAction menuAdd menuChecked menuClear menuCollapse menuData menuDelete menuEnable menuEnabled menuExpand menuHover menuPicture menuSetAction menuSetCheck menuSetData menuSetPicture menuSetValue menuShortcut menuShortcutText menuSize menuSort menuText menuURL menuValue min mineActive mineDetectedBy missionConfigFile missionDifficulty missionName missionNamespace missionStart missionVersion mod modelToWorld modelToWorldVisual modelToWorldVisualWorld modelToWorldWorld modParams moonIntensity moonPhase morale move move3DENCamera moveInAny moveInCargo moveInCommander moveInDriver moveInGunner moveInTurret moveObjectToEnd moveOut moveTime moveTo moveToCompleted moveToFailed musicVolume name nameSound nearEntities nearestBuilding nearestLocation nearestLocations nearestLocationWithDubbing nearestObject nearestObjects nearestTerrainObjects nearObjects nearObjectsReady nearRoads nearSupplies nearTargets needReload netId netObjNull newOverlay nextMenuItemIndex nextWeatherChange nMenuItems not numberOfEnginesRTD numberToDate objectCurators objectFromNetId objectParent objStatus onBriefingGroup onBriefingNotes onBriefingPlan onBriefingTeamSwitch onCommandModeChanged onDoubleClick onEachFrame onGroupIconClick onGroupIconOverEnter onGroupIconOverLeave onHCGroupSelectionChanged onMapSingleClick onPlayerConnected onPlayerDisconnected onPreloadFinished onPreloadStarted onShowNewObject onTeamSwitch openCuratorInterface openDLCPage openMap openSteamApp openYoutubeVideo or orderGetIn overcast overcastForecast owner param params parseNumber parseSimpleArray parseText parsingNamespace particlesQuality pickWeaponPool pitch pixelGrid pixelGridBase pixelGridNoUIScale pixelH pixelW playableSlotsNumber playableUnits playAction playActionNow player playerRespawnTime playerSide playersNumber playGesture playMission playMove playMoveNow playMusic playScriptedMission playSound playSound3D position positionCameraToWorld posScreenToWorld posWorldToScreen ppEffectAdjust ppEffectCommit ppEffectCommitted ppEffectCreate ppEffectDestroy ppEffectEnable ppEffectEnabled ppEffectForceInNVG precision preloadCamera preloadObject preloadSound preloadTitleObj preloadTitleRsc preprocessFile preprocessFileLineNumbers primaryWeapon primaryWeaponItems primaryWeaponMagazine priority processDiaryLink productVersion profileName profileNamespace profileNameSteam progressLoadingScreen progressPosition progressSetPosition publicVariable publicVariableClient publicVariableServer pushBack pushBackUnique putWeaponPool queryItemsPool queryMagazinePool queryWeaponPool rad radioChannelAdd radioChannelCreate radioChannelRemove radioChannelSetCallSign radioChannelSetLabel radioVolume rain rainbow random rank rankId rating rectangular registeredTasks registerTask reload reloadEnabled remoteControl remoteExec remoteExecCall remoteExecutedOwner remove3DENConnection remove3DENEventHandler remove3DENLayer removeAction removeAll3DENEventHandlers removeAllActions removeAllAssignedItems removeAllContainers removeAllCuratorAddons removeAllCuratorCameraAreas removeAllCuratorEditingAreas removeAllEventHandlers removeAllHandgunItems removeAllItems removeAllItemsWithMagazines removeAllMissionEventHandlers removeAllMPEventHandlers removeAllMusicEventHandlers removeAllOwnedMines removeAllPrimaryWeaponItems removeAllWeapons removeBackpack removeBackpackGlobal removeCuratorAddons removeCuratorCameraArea removeCuratorEditableObjects removeCuratorEditingArea removeDrawIcon removeDrawLinks removeEventHandler removeFromRemainsCollector removeGoggles removeGroupIcon removeHandgunItem removeHeadgear removeItem removeItemFromBackpack removeItemFromUniform removeItemFromVest removeItems removeMagazine removeMagazineGlobal removeMagazines removeMagazinesTurret removeMagazineTurret removeMenuItem removeMissionEventHandler removeMPEventHandler removeMusicEventHandler removeOwnedMine removePrimaryWeaponItem removeSecondaryWeaponItem removeSimpleTask removeSwitchableUnit removeTeamMember removeUniform removeVest removeWeapon removeWeaponAttachmentCargo removeWeaponCargo removeWeaponGlobal removeWeaponTurret reportRemoteTarget requiredVersion resetCamShake resetSubgroupDirection resize resources respawnVehicle restartEditorCamera reveal revealMine reverse reversedMouseY roadAt roadsConnectedTo roleDescription ropeAttachedObjects ropeAttachedTo ropeAttachEnabled ropeAttachTo ropeCreate ropeCut ropeDestroy ropeDetach ropeEndPosition ropeLength ropes ropeUnwind ropeUnwound rotorsForcesRTD rotorsRpmRTD round runInitScript safeZoneH safeZoneW safeZoneWAbs safeZoneX safeZoneXAbs safeZoneY save3DENInventory saveGame saveIdentity saveJoysticks saveOverlay saveProfileNamespace saveStatus saveVar savingEnabled say say2D say3D scopeName score scoreSide screenshot screenToWorld scriptDone scriptName scudState secondaryWeapon secondaryWeaponItems secondaryWeaponMagazine select selectBestPlaces selectDiarySubject selectedEditorObjects selectEditorObject selectionNames selectionPosition selectLeader selectMax selectMin selectNoPlayer selectPlayer selectRandom selectRandomWeighted selectWeapon selectWeaponTurret sendAUMessage sendSimpleCommand sendTask sendTaskResult sendUDPMessage serverCommand serverCommandAvailable serverCommandExecutable serverName serverTime set set3DENAttribute set3DENAttributes set3DENGrid set3DENIconsVisible set3DENLayer set3DENLinesVisible set3DENLogicType set3DENMissionAttribute set3DENMissionAttributes set3DENModelsVisible set3DENObjectType set3DENSelected setAccTime setActualCollectiveRTD setAirplaneThrottle setAirportSide setAmmo setAmmoCargo setAmmoOnPylon setAnimSpeedCoef setAperture setApertureNew setArmoryPoints setAttributes setAutonomous setBehaviour setBleedingRemaining setBrakesRTD setCameraInterest setCamShakeDefParams setCamShakeParams setCamUseTI setCaptive setCenterOfMass setCollisionLight setCombatMode setCompassOscillation setConvoySeparation setCuratorCameraAreaCeiling setCuratorCoef setCuratorEditingAreaType setCuratorWaypointCost setCurrentChannel setCurrentTask setCurrentWaypoint setCustomAimCoef setCustomWeightRTD setDamage setDammage setDate setDebriefingText setDefaultCamera setDestination setDetailMapBlendPars setDir setDirection setDrawIcon setDriveOnPath setDropInterval setDynamicSimulationDistance setDynamicSimulationDistanceCoef setEditorMode setEditorObjectScope setEffectCondition setEngineRPMRTD setFace setFaceAnimation setFatigue setFeatureType setFlagAnimationPhase setFlagOwner setFlagSide setFlagTexture setFog setFormation setFormationTask setFormDir setFriend setFromEditor setFSMVariable setFuel setFuelCargo setGroupIcon setGroupIconParams setGroupIconsSelectable setGroupIconsVisible setGroupId setGroupIdGlobal setGroupOwner setGusts setHideBehind setHit setHitIndex setHitPointDamage setHorizonParallaxCoef setHUDMovementLevels setIdentity setImportance setInfoPanel setLeader setLightAmbient setLightAttenuation setLightBrightness setLightColor setLightDayLight setLightFlareMaxDistance setLightFlareSize setLightIntensity setLightnings setLightUseFlare setLocalWindParams setMagazineTurretAmmo setMarkerAlpha setMarkerAlphaLocal setMarkerBrush setMarkerBrushLocal setMarkerColor setMarkerColorLocal setMarkerDir setMarkerDirLocal setMarkerPos setMarkerPosLocal setMarkerShape setMarkerShapeLocal setMarkerSize setMarkerSizeLocal setMarkerText setMarkerTextLocal setMarkerType setMarkerTypeLocal setMass setMimic setMousePosition setMusicEffect setMusicEventHandler setName setNameSound setObjectArguments setObjectMaterial setObjectMaterialGlobal setObjectProxy setObjectTexture setObjectTextureGlobal setObjectViewDistance setOvercast setOwner setOxygenRemaining setParticleCircle setParticleClass setParticleFire setParticleParams setParticleRandom setPilotCameraDirection setPilotCameraRotation setPilotCameraTarget setPilotLight setPiPEffect setPitch setPlateNumber setPlayable setPlayerRespawnTime setPos setPosASL setPosASL2 setPosASLW setPosATL setPosition setPosWorld setPylonLoadOut setPylonsPriority setRadioMsg setRain setRainbow setRandomLip setRank setRectangular setRepairCargo setRotorBrakeRTD setShadowDistance setShotParents setSide setSimpleTaskAlwaysVisible setSimpleTaskCustomData setSimpleTaskDescription setSimpleTaskDestination setSimpleTaskTarget setSimpleTaskType setSimulWeatherLayers setSize setSkill setSlingLoad setSoundEffect setSpeaker setSpeech setSpeedMode setStamina setStaminaScheme setStatValue setSuppression setSystemOfUnits setTargetAge setTaskMarkerOffset setTaskResult setTaskState setTerrainGrid setText setTimeMultiplier setTitleEffect setTrafficDensity setTrafficDistance setTrafficGap setTrafficSpeed setTriggerActivation setTriggerArea setTriggerStatements setTriggerText setTriggerTimeout setTriggerType setType setUnconscious setUnitAbility setUnitLoadout setUnitPos setUnitPosWeak setUnitRank setUnitRecoilCoefficient setUnitTrait setUnloadInCombat setUserActionText setUserMFDText setUserMFDvalue setVariable setVectorDir setVectorDirAndUp setVectorUp setVehicleAmmo setVehicleAmmoDef setVehicleArmor setVehicleCargo setVehicleId setVehicleLock setVehiclePosition setVehicleRadar setVehicleReceiveRemoteTargets setVehicleReportOwnPosition setVehicleReportRemoteTargets setVehicleTIPars setVehicleVarName setVelocity setVelocityModelSpace setVelocityTransformation setViewDistance setVisibleIfTreeCollapsed setWantedRPMRTD setWaves setWaypointBehaviour setWaypointCombatMode setWaypointCompletionRadius setWaypointDescription setWaypointForceBehaviour setWaypointFormation setWaypointHousePosition setWaypointLoiterRadius setWaypointLoiterType setWaypointName setWaypointPosition setWaypointScript setWaypointSpeed setWaypointStatements setWaypointTimeout setWaypointType setWaypointVisible setWeaponReloadingTime setWind setWindDir setWindForce setWindStr setWingForceScaleRTD setWPPos show3DIcons showChat showCinemaBorder showCommandingMenu showCompass showCuratorCompass showGPS showHUD showLegend showMap shownArtilleryComputer shownChat shownCompass shownCuratorCompass showNewEditorObject shownGPS shownHUD shownMap shownPad shownRadio shownScoretable shownUAVFeed shownWarrant shownWatch showPad showRadio showScoretable showSubtitles showUAVFeed showWarrant showWatch showWaypoint showWaypoints side sideChat sideEnemy sideFriendly sideRadio simpleTasks simulationEnabled simulCloudDensity simulCloudOcclusion simulInClouds simulWeatherSync sin size sizeOf skill skillFinal skipTime sleep sliderPosition sliderRange sliderSetPosition sliderSetRange sliderSetSpeed sliderSpeed slingLoadAssistantShown soldierMagazines someAmmo sort soundVolume spawn speaker speed speedMode splitString sqrt squadParams stance startLoadingScreen step stop stopEngineRTD stopped str sunOrMoon supportInfo suppressFor surfaceIsWater surfaceNormal surfaceType swimInDepth switchableUnits switchAction switchCamera switchGesture switchLight switchMove synchronizedObjects synchronizedTriggers synchronizedWaypoints synchronizeObjectsAdd synchronizeObjectsRemove synchronizeTrigger synchronizeWaypoint systemChat systemOfUnits tan targetKnowledge targets targetsAggregate targetsQuery taskAlwaysVisible taskChildren taskCompleted taskCustomData taskDescription taskDestination taskHint taskMarkerOffset taskParent taskResult taskState taskType teamMember teamName teams teamSwitch teamSwitchEnabled teamType terminate terrainIntersect terrainIntersectASL terrainIntersectAtASL text textLog textLogFormat tg time timeMultiplier titleCut titleFadeOut titleObj titleRsc titleText toArray toFixed toLower toString toUpper triggerActivated triggerActivation triggerArea triggerAttachedVehicle triggerAttachObject triggerAttachVehicle triggerDynamicSimulation triggerStatements triggerText triggerTimeout triggerTimeoutCurrent triggerType turretLocal turretOwner turretUnit tvAdd tvClear tvCollapse tvCollapseAll tvCount tvCurSel tvData tvDelete tvExpand tvExpandAll tvPicture tvSetColor tvSetCurSel tvSetData tvSetPicture tvSetPictureColor tvSetPictureColorDisabled tvSetPictureColorSelected tvSetPictureRight tvSetPictureRightColor tvSetPictureRightColorDisabled tvSetPictureRightColorSelected tvSetText tvSetTooltip tvSetValue tvSort tvSortByValue tvText tvTooltip tvValue type typeName typeOf UAVControl uiNamespace uiSleep unassignCurator unassignItem unassignTeam unassignVehicle underwater uniform uniformContainer uniformItems uniformMagazines unitAddons unitAimPosition unitAimPositionVisual unitBackpack unitIsUAV unitPos unitReady unitRecoilCoefficient units unitsBelowHeight unlinkItem unlockAchievement unregisterTask updateDrawIcon updateMenuItem updateObjectTree useAISteeringComponent useAudioTimeForMoves userInputDisabled vectorAdd vectorCos vectorCrossProduct vectorDiff vectorDir vectorDirVisual vectorDistance vectorDistanceSqr vectorDotProduct vectorFromTo vectorMagnitude vectorMagnitudeSqr vectorModelToWorld vectorModelToWorldVisual vectorMultiply vectorNormalized vectorUp vectorUpVisual vectorWorldToModel vectorWorldToModelVisual vehicle vehicleCargoEnabled vehicleChat vehicleRadio vehicleReceiveRemoteTargets vehicleReportOwnPosition vehicleReportRemoteTargets vehicles vehicleVarName velocity velocityModelSpace verifySignature vest vestContainer vestItems vestMagazines viewDistance visibleCompass visibleGPS visibleMap visiblePosition visiblePositionASL visibleScoretable visibleWatch waves waypointAttachedObject waypointAttachedVehicle waypointAttachObject waypointAttachVehicle waypointBehaviour waypointCombatMode waypointCompletionRadius waypointDescription waypointForceBehaviour waypointFormation waypointHousePosition waypointLoiterRadius waypointLoiterType waypointName waypointPosition waypoints waypointScript waypointsEnabledUAV waypointShow waypointSpeed waypointStatements waypointTimeout waypointTimeoutCurrent waypointType waypointVisible weaponAccessories weaponAccessoriesCargo weaponCargo weaponDirection weaponInertia weaponLowered weapons weaponsItems weaponsItemsCargo weaponState weaponsTurret weightRTD WFSideText wind ",literal:"blufor civilian configNull controlNull displayNull east endl false grpNull independent lineBreak locationNull nil objNull opfor pi resistance scriptNull sideAmbientLife sideEmpty sideLogic sideUnknown taskNull teamMemberNull true west"},contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.NUMBER_MODE,{className:"variable",begin:/\b_+[a-zA-Z_]\w*/},{className:"title",begin:/[a-zA-Z][a-zA-Z0-9]+_fnc_\w*/},t,a],illegal:/#|^\$ /}},zs=function(e){var t=e.COMMENT("--","$");return{name:"SQL",case_insensitive:!0,illegal:/[<>{}*]/,contains:[{beginKeywords:"begin end start commit rollback savepoint lock alter create drop rename call delete do handler insert load replace select truncate update set show pragma grant merge describe use explain help declare prepare execute deallocate release unlock purge reset change stop analyze cache flush optimize repair kill install uninstall checksum restore check backup revoke comment values with",end:/;/,endsWithParent:!0,lexemes:/[\w\.]+/,keywords:{ +keyword:"as abort abs absolute acc acce accep accept access accessed accessible account acos action activate add addtime admin administer advanced advise aes_decrypt aes_encrypt after agent aggregate ali alia alias all allocate allow alter always analyze ancillary and anti any anydata anydataset anyschema anytype apply archive archived archivelog are as asc ascii asin assembly assertion associate asynchronous at atan atn2 attr attri attrib attribu attribut attribute attributes audit authenticated authentication authid authors auto autoallocate autodblink autoextend automatic availability avg backup badfile basicfile before begin beginning benchmark between bfile bfile_base big bigfile bin binary_double binary_float binlog bit_and bit_count bit_length bit_or bit_xor bitmap blob_base block blocksize body both bound bucket buffer_cache buffer_pool build bulk by byte byteordermark bytes cache caching call calling cancel capacity cascade cascaded case cast catalog category ceil ceiling chain change changed char_base char_length character_length characters characterset charindex charset charsetform charsetid check checksum checksum_agg child choose chr chunk class cleanup clear client clob clob_base clone close cluster_id cluster_probability cluster_set clustering coalesce coercibility col collate collation collect colu colum column column_value columns columns_updated comment commit compact compatibility compiled complete composite_limit compound compress compute concat concat_ws concurrent confirm conn connec connect connect_by_iscycle connect_by_isleaf connect_by_root connect_time connection consider consistent constant constraint constraints constructor container content contents context contributors controlfile conv convert convert_tz corr corr_k corr_s corresponding corruption cos cost count count_big counted covar_pop covar_samp cpu_per_call cpu_per_session crc32 create creation critical cross cube cume_dist curdate current current_date current_time current_timestamp current_user cursor curtime customdatum cycle data database databases datafile datafiles datalength date_add date_cache date_format date_sub dateadd datediff datefromparts datename datepart datetime2fromparts day day_to_second dayname dayofmonth dayofweek dayofyear days db_role_change dbtimezone ddl deallocate declare decode decompose decrement decrypt deduplicate def defa defau defaul default defaults deferred defi defin define degrees delayed delegate delete delete_all delimited demand dense_rank depth dequeue des_decrypt des_encrypt des_key_file desc descr descri describ describe descriptor deterministic diagnostics difference dimension direct_load directory disable disable_all disallow disassociate discardfile disconnect diskgroup distinct distinctrow distribute distributed div do document domain dotnet double downgrade drop dumpfile duplicate duration each edition editionable editions element ellipsis else elsif elt empty enable enable_all enclosed encode encoding encrypt end end-exec endian enforced engine engines enqueue enterprise entityescaping eomonth error errors escaped evalname evaluate event eventdata events except exception exceptions exchange exclude excluding execu execut execute exempt exists exit exp expire explain explode export export_set extended extent external external_1 external_2 externally extract failed failed_login_attempts failover failure far fast feature_set feature_value fetch field fields file file_name_convert filesystem_like_logging final finish first first_value fixed flash_cache flashback floor flush following follows for forall force foreign form forma format found found_rows freelist freelists freepools fresh from from_base64 from_days ftp full function general generated get get_format get_lock getdate getutcdate global global_name globally go goto grant grants greatest group group_concat group_id grouping grouping_id groups gtid_subtract guarantee guard handler hash hashkeys having hea head headi headin heading heap help hex hierarchy high high_priority hosts hour hours http id ident_current ident_incr ident_seed identified identity idle_time if ifnull ignore iif ilike ilm immediate import in include including increment index indexes indexing indextype indicator indices inet6_aton inet6_ntoa inet_aton inet_ntoa infile initial initialized initially initrans inmemory inner innodb input insert install instance instantiable instr interface interleaved intersect into invalidate invisible is is_free_lock is_ipv4 is_ipv4_compat is_not is_not_null is_used_lock isdate isnull isolation iterate java join json json_exists keep keep_duplicates key keys kill language large last last_day last_insert_id last_value lateral lax lcase lead leading least leaves left len lenght length less level levels library like like2 like4 likec limit lines link list listagg little ln load load_file lob lobs local localtime localtimestamp locate locator lock locked log log10 log2 logfile logfiles logging logical logical_reads_per_call logoff logon logs long loop low low_priority lower lpad lrtrim ltrim main make_set makedate maketime managed management manual map mapping mask master master_pos_wait match matched materialized max maxextents maximize maxinstances maxlen maxlogfiles maxloghistory maxlogmembers maxsize maxtrans md5 measures median medium member memcompress memory merge microsecond mid migration min minextents minimum mining minus minute minutes minvalue missing mod mode model modification modify module monitoring month months mount move movement multiset mutex name name_const names nan national native natural nav nchar nclob nested never new newline next nextval no no_write_to_binlog noarchivelog noaudit nobadfile nocheck nocompress nocopy nocycle nodelay nodiscardfile noentityescaping noguarantee nokeep nologfile nomapping nomaxvalue nominimize nominvalue nomonitoring none noneditionable nonschema noorder nopr nopro noprom nopromp noprompt norely noresetlogs noreverse normal norowdependencies noschemacheck noswitch not nothing notice notnull notrim novalidate now nowait nth_value nullif nulls num numb numbe nvarchar nvarchar2 object ocicoll ocidate ocidatetime ociduration ociinterval ociloblocator ocinumber ociref ocirefcursor ocirowid ocistring ocitype oct octet_length of off offline offset oid oidindex old on online only opaque open operations operator optimal optimize option optionally or oracle oracle_date oradata ord ordaudio orddicom orddoc order ordimage ordinality ordvideo organization orlany orlvary out outer outfile outline output over overflow overriding package pad parallel parallel_enable parameters parent parse partial partition partitions pascal passing password password_grace_time password_lock_time password_reuse_max password_reuse_time password_verify_function patch path patindex pctincrease pctthreshold pctused pctversion percent percent_rank percentile_cont percentile_disc performance period period_add period_diff permanent physical pi pipe pipelined pivot pluggable plugin policy position post_transaction pow power pragma prebuilt precedes preceding precision prediction prediction_cost prediction_details prediction_probability prediction_set prepare present preserve prior priority private private_sga privileges procedural procedure procedure_analyze processlist profiles project prompt protection public publishingservername purge quarter query quick quiesce quota quotename radians raise rand range rank raw read reads readsize rebuild record records recover recovery recursive recycle redo reduced ref reference referenced references referencing refresh regexp_like register regr_avgx regr_avgy regr_count regr_intercept regr_r2 regr_slope regr_sxx regr_sxy reject rekey relational relative relaylog release release_lock relies_on relocate rely rem remainder rename repair repeat replace replicate replication required reset resetlogs resize resource respect restore restricted result result_cache resumable resume retention return returning returns reuse reverse revoke right rlike role roles rollback rolling rollup round row row_count rowdependencies rowid rownum rows rtrim rules safe salt sample save savepoint sb1 sb2 sb4 scan schema schemacheck scn scope scroll sdo_georaster sdo_topo_geometry search sec_to_time second seconds section securefile security seed segment select self semi sequence sequential serializable server servererror session session_user sessions_per_user set sets settings sha sha1 sha2 share shared shared_pool short show shrink shutdown si_averagecolor si_colorhistogram si_featurelist si_positionalcolor si_stillimage si_texture siblings sid sign sin size size_t sizes skip slave sleep smalldatetimefromparts smallfile snapshot some soname sort soundex source space sparse spfile split sql sql_big_result sql_buffer_result sql_cache sql_calc_found_rows sql_small_result sql_variant_property sqlcode sqldata sqlerror sqlname sqlstate sqrt square standalone standby start starting startup statement static statistics stats_binomial_test stats_crosstab stats_ks_test stats_mode stats_mw_test stats_one_way_anova stats_t_test_ stats_t_test_indep stats_t_test_one stats_t_test_paired stats_wsr_test status std stddev stddev_pop stddev_samp stdev stop storage store stored str str_to_date straight_join strcmp strict string struct stuff style subdate subpartition subpartitions substitutable substr substring subtime subtring_index subtype success sum suspend switch switchoffset switchover sync synchronous synonym sys sys_xmlagg sysasm sysaux sysdate sysdatetimeoffset sysdba sysoper system system_user sysutcdatetime table tables tablespace tablesample tan tdo template temporary terminated tertiary_weights test than then thread through tier ties time time_format time_zone timediff timefromparts timeout timestamp timestampadd timestampdiff timezone_abbr timezone_minute timezone_region to to_base64 to_date to_days to_seconds todatetimeoffset trace tracking transaction transactional translate translation treat trigger trigger_nestlevel triggers trim truncate try_cast try_convert try_parse type ub1 ub2 ub4 ucase unarchived unbounded uncompress under undo unhex unicode uniform uninstall union unique unix_timestamp unknown unlimited unlock unnest unpivot unrecoverable unsafe unsigned until untrusted unusable unused update updated upgrade upped upper upsert url urowid usable usage use use_stored_outlines user user_data user_resources users using utc_date utc_timestamp uuid uuid_short validate validate_password_strength validation valist value values var var_samp varcharc vari varia variab variabl variable variables variance varp varraw varrawc varray verify version versions view virtual visible void wait wallet warning warnings week weekday weekofyear wellformed when whene whenev wheneve whenever where while whitespace window with within without work wrapped xdb xml xmlagg xmlattributes xmlcast xmlcolattval xmlelement xmlexists xmlforest xmlindex xmlnamespaces xmlpi xmlquery xmlroot xmlschema xmlserialize xmltable xmltype xor year year_to_month years yearweek",literal:"true false null unknown",built_in:"array bigint binary bit blob bool boolean char character date dec decimal float int int8 integer interval number numeric real record serial serial8 smallint text time timestamp tinyint varchar varchar2 varying void"},contains:[{className:"string",begin:"'",end:"'",contains:[{begin:"''"}]},{className:"string",begin:'"',end:'"',contains:[{begin:'""'}]},{className:"string",begin:"`",end:"`"},e.C_NUMBER_MODE,e.C_BLOCK_COMMENT_MODE,t,e.HASH_COMMENT_MODE]},e.C_BLOCK_COMMENT_MODE,t,e.HASH_COMMENT_MODE]}},Ws=function(e){return{name:"Stan",aliases:["stanfuncs"],keywords:{title:["functions","model","data","parameters","quantities","transformed","generated"].join(" "),keyword:["for","in","if","else","while","break","continue","return"].concat(["int","real","vector","ordered","positive_ordered","simplex","unit_vector","row_vector","matrix","cholesky_factor_corr|10","cholesky_factor_cov|10","corr_matrix|10","cov_matrix|10","void"]).concat(["print","reject","increment_log_prob|10","integrate_ode|10","integrate_ode_rk45|10","integrate_ode_bdf|10","algebra_solver"]).join(" "),built_in:["Phi","Phi_approx","abs","acos","acosh","algebra_solver","append_array","append_col","append_row","asin","asinh","atan","atan2","atanh","bernoulli_cdf","bernoulli_lccdf","bernoulli_lcdf","bernoulli_logit_lpmf","bernoulli_logit_rng","bernoulli_lpmf","bernoulli_rng","bessel_first_kind","bessel_second_kind","beta_binomial_cdf","beta_binomial_lccdf","beta_binomial_lcdf","beta_binomial_lpmf","beta_binomial_rng","beta_cdf","beta_lccdf","beta_lcdf","beta_lpdf","beta_rng","binary_log_loss","binomial_cdf","binomial_coefficient_log","binomial_lccdf","binomial_lcdf","binomial_logit_lpmf","binomial_lpmf","binomial_rng","block","categorical_logit_lpmf","categorical_logit_rng","categorical_lpmf","categorical_rng","cauchy_cdf","cauchy_lccdf","cauchy_lcdf","cauchy_lpdf","cauchy_rng","cbrt","ceil","chi_square_cdf","chi_square_lccdf","chi_square_lcdf","chi_square_lpdf","chi_square_rng","cholesky_decompose","choose","col","cols","columns_dot_product","columns_dot_self","cos","cosh","cov_exp_quad","crossprod","csr_extract_u","csr_extract_v","csr_extract_w","csr_matrix_times_vector","csr_to_dense_matrix","cumulative_sum","determinant","diag_matrix","diag_post_multiply","diag_pre_multiply","diagonal","digamma","dims","dirichlet_lpdf","dirichlet_rng","distance","dot_product","dot_self","double_exponential_cdf","double_exponential_lccdf","double_exponential_lcdf","double_exponential_lpdf","double_exponential_rng","e","eigenvalues_sym","eigenvectors_sym","erf","erfc","exp","exp2","exp_mod_normal_cdf","exp_mod_normal_lccdf","exp_mod_normal_lcdf","exp_mod_normal_lpdf","exp_mod_normal_rng","expm1","exponential_cdf","exponential_lccdf","exponential_lcdf","exponential_lpdf","exponential_rng","fabs","falling_factorial","fdim","floor","fma","fmax","fmin","fmod","frechet_cdf","frechet_lccdf","frechet_lcdf","frechet_lpdf","frechet_rng","gamma_cdf","gamma_lccdf","gamma_lcdf","gamma_lpdf","gamma_p","gamma_q","gamma_rng","gaussian_dlm_obs_lpdf","get_lp","gumbel_cdf","gumbel_lccdf","gumbel_lcdf","gumbel_lpdf","gumbel_rng","head","hypergeometric_lpmf","hypergeometric_rng","hypot","inc_beta","int_step","integrate_ode","integrate_ode_bdf","integrate_ode_rk45","inv","inv_Phi","inv_chi_square_cdf","inv_chi_square_lccdf","inv_chi_square_lcdf","inv_chi_square_lpdf","inv_chi_square_rng","inv_cloglog","inv_gamma_cdf","inv_gamma_lccdf","inv_gamma_lcdf","inv_gamma_lpdf","inv_gamma_rng","inv_logit","inv_sqrt","inv_square","inv_wishart_lpdf","inv_wishart_rng","inverse","inverse_spd","is_inf","is_nan","lbeta","lchoose","lgamma","lkj_corr_cholesky_lpdf","lkj_corr_cholesky_rng","lkj_corr_lpdf","lkj_corr_rng","lmgamma","lmultiply","log","log10","log1m","log1m_exp","log1m_inv_logit","log1p","log1p_exp","log2","log_determinant","log_diff_exp","log_falling_factorial","log_inv_logit","log_mix","log_rising_factorial","log_softmax","log_sum_exp","logistic_cdf","logistic_lccdf","logistic_lcdf","logistic_lpdf","logistic_rng","logit","lognormal_cdf","lognormal_lccdf","lognormal_lcdf","lognormal_lpdf","lognormal_rng","machine_precision","matrix_exp","max","mdivide_left_spd","mdivide_left_tri_low","mdivide_right_spd","mdivide_right_tri_low","mean","min","modified_bessel_first_kind","modified_bessel_second_kind","multi_gp_cholesky_lpdf","multi_gp_lpdf","multi_normal_cholesky_lpdf","multi_normal_cholesky_rng","multi_normal_lpdf","multi_normal_prec_lpdf","multi_normal_rng","multi_student_t_lpdf","multi_student_t_rng","multinomial_lpmf","multinomial_rng","multiply_log","multiply_lower_tri_self_transpose","neg_binomial_2_cdf","neg_binomial_2_lccdf","neg_binomial_2_lcdf","neg_binomial_2_log_lpmf","neg_binomial_2_log_rng","neg_binomial_2_lpmf","neg_binomial_2_rng","neg_binomial_cdf","neg_binomial_lccdf","neg_binomial_lcdf","neg_binomial_lpmf","neg_binomial_rng","negative_infinity","normal_cdf","normal_lccdf","normal_lcdf","normal_lpdf","normal_rng","not_a_number","num_elements","ordered_logistic_lpmf","ordered_logistic_rng","owens_t","pareto_cdf","pareto_lccdf","pareto_lcdf","pareto_lpdf","pareto_rng","pareto_type_2_cdf","pareto_type_2_lccdf","pareto_type_2_lcdf","pareto_type_2_lpdf","pareto_type_2_rng","pi","poisson_cdf","poisson_lccdf","poisson_lcdf","poisson_log_lpmf","poisson_log_rng","poisson_lpmf","poisson_rng","positive_infinity","pow","print","prod","qr_Q","qr_R","quad_form","quad_form_diag","quad_form_sym","rank","rayleigh_cdf","rayleigh_lccdf","rayleigh_lcdf","rayleigh_lpdf","rayleigh_rng","reject","rep_array","rep_matrix","rep_row_vector","rep_vector","rising_factorial","round","row","rows","rows_dot_product","rows_dot_self","scaled_inv_chi_square_cdf","scaled_inv_chi_square_lccdf","scaled_inv_chi_square_lcdf","scaled_inv_chi_square_lpdf","scaled_inv_chi_square_rng","sd","segment","sin","singular_values","sinh","size","skew_normal_cdf","skew_normal_lccdf","skew_normal_lcdf","skew_normal_lpdf","skew_normal_rng","softmax","sort_asc","sort_desc","sort_indices_asc","sort_indices_desc","sqrt","sqrt2","square","squared_distance","step","student_t_cdf","student_t_lccdf","student_t_lcdf","student_t_lpdf","student_t_rng","sub_col","sub_row","sum","tail","tan","tanh","target","tcrossprod","tgamma","to_array_1d","to_array_2d","to_matrix","to_row_vector","to_vector","trace","trace_gen_quad_form","trace_quad_form","trigamma","trunc","uniform_cdf","uniform_lccdf","uniform_lcdf","uniform_lpdf","uniform_rng","variance","von_mises_lpdf","von_mises_rng","weibull_cdf","weibull_lccdf","weibull_lcdf","weibull_lpdf","weibull_rng","wiener_lpdf","wishart_lpdf","wishart_rng"].join(" ")},lexemes:e.IDENT_RE,contains:[e.C_LINE_COMMENT_MODE,e.COMMENT(/#/,/$/,{relevance:0,keywords:{"meta-keyword":"include"}}),e.COMMENT(/\/\*/,/\*\//,{relevance:0,contains:[{className:"doctag",begin:/@(return|param)/}]}),{begin:/<\s*lower\s*=/,keywords:"lower"},{begin:/[<,]\s*upper\s*=/,keywords:"upper"},{className:"keyword",begin:/\btarget\s*\+=/,relevance:10},{begin:"~\\s*("+e.IDENT_RE+")\\s*\\(",keywords:["bernoulli","bernoulli_logit","beta","beta_binomial","binomial","binomial_logit","categorical","categorical_logit","cauchy","chi_square","dirichlet","double_exponential","exp_mod_normal","exponential","frechet","gamma","gaussian_dlm_obs","gumbel","hypergeometric","inv_chi_square","inv_gamma","inv_wishart","lkj_corr","lkj_corr_cholesky","logistic","lognormal","multi_gp","multi_gp_cholesky","multi_normal","multi_normal_cholesky","multi_normal_prec","multi_student_t","multinomial","neg_binomial","neg_binomial_2","neg_binomial_2_log","normal","ordered_logistic","pareto","pareto_type_2","poisson","poisson_log","rayleigh","scaled_inv_chi_square","skew_normal","student_t","uniform","von_mises","weibull","wiener","wishart"].join(" ")},{className:"number",variants:[{begin:/\b\d+(?:\.\d*)?(?:[eE][+-]?\d+)?/},{begin:/\.\d+(?:[eE][+-]?\d+)?\b/}],relevance:0},{className:"string",begin:'"',end:'"',relevance:0}]}},Qs=function(e){return{name:"Stata",aliases:["do","ado"],case_insensitive:!0, +keywords:"if else in foreach for forv forva forval forvalu forvalue forvalues by bys bysort xi quietly qui capture about ac ac_7 acprplot acprplot_7 adjust ado adopath adoupdate alpha ameans an ano anov anova anova_estat anova_terms anovadef aorder ap app appe appen append arch arch_dr arch_estat arch_p archlm areg areg_p args arima arima_dr arima_estat arima_p as asmprobit asmprobit_estat asmprobit_lf asmprobit_mfx__dlg asmprobit_p ass asse asser assert avplot avplot_7 avplots avplots_7 bcskew0 bgodfrey bias binreg bip0_lf biplot bipp_lf bipr_lf bipr_p biprobit bitest bitesti bitowt blogit bmemsize boot bootsamp bootstrap bootstrap_8 boxco_l boxco_p boxcox boxcox_6 boxcox_p bprobit br break brier bro brow brows browse brr brrstat bs bs_7 bsampl_w bsample bsample_7 bsqreg bstat bstat_7 bstat_8 bstrap bstrap_7 bubble bubbleplot ca ca_estat ca_p cabiplot camat canon canon_8 canon_8_p canon_estat canon_p cap caprojection capt captu captur capture cat cc cchart cchart_7 cci cd censobs_table centile cf char chdir checkdlgfiles checkestimationsample checkhlpfiles checksum chelp ci cii cl class classutil clear cli clis clist clo clog clog_lf clog_p clogi clogi_sw clogit clogit_lf clogit_p clogitp clogl_sw cloglog clonevar clslistarray cluster cluster_measures cluster_stop cluster_tree cluster_tree_8 clustermat cmdlog cnr cnre cnreg cnreg_p cnreg_sw cnsreg codebook collaps4 collapse colormult_nb colormult_nw compare compress conf confi confir confirm conren cons const constr constra constrai constrain constraint continue contract copy copyright copysource cor corc corr corr2data corr_anti corr_kmo corr_smc corre correl correla correlat correlate corrgram cou coun count cox cox_p cox_sw coxbase coxhaz coxvar cprplot cprplot_7 crc cret cretu cretur creturn cross cs cscript cscript_log csi ct ct_is ctset ctst_5 ctst_st cttost cumsp cumsp_7 cumul cusum cusum_7 cutil d|0 datasig datasign datasigna datasignat datasignatu datasignatur datasignature datetof db dbeta de dec deco decod decode deff des desc descr descri describ describe destring dfbeta dfgls dfuller di di_g dir dirstats dis discard disp disp_res disp_s displ displa display distinct do doe doed doedi doedit dotplot dotplot_7 dprobit drawnorm drop ds ds_util dstdize duplicates durbina dwstat dydx e|0 ed edi edit egen eivreg emdef en enc enco encod encode eq erase ereg ereg_lf ereg_p ereg_sw ereghet ereghet_glf ereghet_glf_sh ereghet_gp ereghet_ilf ereghet_ilf_sh ereghet_ip eret eretu eretur ereturn err erro error esize est est_cfexist est_cfname est_clickable est_expand est_hold est_table est_unhold est_unholdok estat estat_default estat_summ estat_vce_only esti estimates etodow etof etomdy ex exi exit expand expandcl fac fact facto factor factor_estat factor_p factor_pca_rotated factor_rotate factormat fcast fcast_compute fcast_graph fdades fdadesc fdadescr fdadescri fdadescrib fdadescribe fdasav fdasave fdause fh_st file open file read file close file filefilter fillin find_hlp_file findfile findit findit_7 fit fl fli flis flist for5_0 forest forestplot form forma format fpredict frac_154 frac_adj frac_chk frac_cox frac_ddp frac_dis frac_dv frac_in frac_mun frac_pp frac_pq frac_pv frac_wgt frac_xo fracgen fracplot fracplot_7 fracpoly fracpred fron_ex fron_hn fron_p fron_tn fron_tn2 frontier ftodate ftoe ftomdy ftowdate funnel funnelplot g|0 gamhet_glf gamhet_gp gamhet_ilf gamhet_ip gamma gamma_d2 gamma_p gamma_sw gammahet gdi_hexagon gdi_spokes ge gen gene gener genera generat generate genrank genstd genvmean gettoken gl gladder gladder_7 glim_l01 glim_l02 glim_l03 glim_l04 glim_l05 glim_l06 glim_l07 glim_l08 glim_l09 glim_l10 glim_l11 glim_l12 glim_lf glim_mu glim_nw1 glim_nw2 glim_nw3 glim_p glim_v1 glim_v2 glim_v3 glim_v4 glim_v5 glim_v6 glim_v7 glm glm_6 glm_p glm_sw glmpred glo glob globa global glogit glogit_8 glogit_p gmeans gnbre_lf gnbreg gnbreg_5 gnbreg_p gomp_lf gompe_sw gomper_p gompertz gompertzhet gomphet_glf gomphet_glf_sh gomphet_gp gomphet_ilf gomphet_ilf_sh gomphet_ip gphdot gphpen gphprint gprefs gprobi_p gprobit gprobit_8 gr gr7 gr_copy gr_current gr_db gr_describe gr_dir gr_draw gr_draw_replay gr_drop gr_edit gr_editviewopts gr_example gr_example2 gr_export gr_print gr_qscheme gr_query gr_read gr_rename gr_replay gr_save gr_set gr_setscheme gr_table gr_undo gr_use graph graph7 grebar greigen greigen_7 greigen_8 grmeanby grmeanby_7 gs_fileinfo gs_filetype gs_graphinfo gs_stat gsort gwood h|0 hadimvo hareg hausman haver he heck_d2 heckma_p heckman heckp_lf heckpr_p heckprob hel help hereg hetpr_lf hetpr_p hetprob hettest hexdump hilite hist hist_7 histogram hlogit hlu hmeans hotel hotelling hprobit hreg hsearch icd9 icd9_ff icd9p iis impute imtest inbase include inf infi infil infile infix inp inpu input ins insheet insp inspe inspec inspect integ inten intreg intreg_7 intreg_p intrg2_ll intrg_ll intrg_ll2 ipolate iqreg ir irf irf_create irfm iri is_svy is_svysum isid istdize ivprob_1_lf ivprob_lf ivprobit ivprobit_p ivreg ivreg_footnote ivtob_1_lf ivtob_lf ivtobit ivtobit_p jackknife jacknife jknife jknife_6 jknife_8 jkstat joinby kalarma1 kap kap_3 kapmeier kappa kapwgt kdensity kdensity_7 keep ksm ksmirnov ktau kwallis l|0 la lab labbe labbeplot labe label labelbook ladder levels levelsof leverage lfit lfit_p li lincom line linktest lis list lloghet_glf lloghet_glf_sh lloghet_gp lloghet_ilf lloghet_ilf_sh lloghet_ip llogi_sw llogis_p llogist llogistic llogistichet lnorm_lf lnorm_sw lnorma_p lnormal lnormalhet lnormhet_glf lnormhet_glf_sh lnormhet_gp lnormhet_ilf lnormhet_ilf_sh lnormhet_ip lnskew0 loadingplot loc loca local log logi logis_lf logistic logistic_p logit logit_estat logit_p loglogs logrank loneway lookfor lookup lowess lowess_7 lpredict lrecomp lroc lroc_7 lrtest ls lsens lsens_7 lsens_x lstat ltable ltable_7 ltriang lv lvr2plot lvr2plot_7 m|0 ma mac macr macro makecns man manova manova_estat manova_p manovatest mantel mark markin markout marksample mat mat_capp mat_order mat_put_rr mat_rapp mata mata_clear mata_describe mata_drop mata_matdescribe mata_matsave mata_matuse mata_memory mata_mlib mata_mosave mata_rename mata_which matalabel matcproc matlist matname matr matri matrix matrix_input__dlg matstrik mcc mcci md0_ md1_ md1debug_ md2_ md2debug_ mds mds_estat mds_p mdsconfig mdslong mdsmat mdsshepard mdytoe mdytof me_derd mean means median memory memsize menl meqparse mer merg merge meta mfp mfx mhelp mhodds minbound mixed_ll mixed_ll_reparm mkassert mkdir mkmat mkspline ml ml_5 ml_adjs ml_bhhhs ml_c_d ml_check ml_clear ml_cnt ml_debug ml_defd ml_e0 ml_e0_bfgs ml_e0_cycle ml_e0_dfp ml_e0i ml_e1 ml_e1_bfgs ml_e1_bhhh ml_e1_cycle ml_e1_dfp ml_e2 ml_e2_cycle ml_ebfg0 ml_ebfr0 ml_ebfr1 ml_ebh0q ml_ebhh0 ml_ebhr0 ml_ebr0i ml_ecr0i ml_edfp0 ml_edfr0 ml_edfr1 ml_edr0i ml_eds ml_eer0i ml_egr0i ml_elf ml_elf_bfgs ml_elf_bhhh ml_elf_cycle ml_elf_dfp ml_elfi ml_elfs ml_enr0i ml_enrr0 ml_erdu0 ml_erdu0_bfgs ml_erdu0_bhhh ml_erdu0_bhhhq ml_erdu0_cycle ml_erdu0_dfp ml_erdu0_nrbfgs ml_exde ml_footnote ml_geqnr ml_grad0 ml_graph ml_hbhhh ml_hd0 ml_hold ml_init ml_inv ml_log ml_max ml_mlout ml_mlout_8 ml_model ml_nb0 ml_opt ml_p ml_plot ml_query ml_rdgrd ml_repor ml_s_e ml_score ml_searc ml_technique ml_unhold mleval mlf_ mlmatbysum mlmatsum mlog mlogi mlogit mlogit_footnote mlogit_p mlopts mlsum mlvecsum mnl0_ mor more mov move mprobit mprobit_lf mprobit_p mrdu0_ mrdu1_ mvdecode mvencode mvreg mvreg_estat n|0 nbreg nbreg_al nbreg_lf nbreg_p nbreg_sw nestreg net newey newey_7 newey_p news nl nl_7 nl_9 nl_9_p nl_p nl_p_7 nlcom nlcom_p nlexp2 nlexp2_7 nlexp2a nlexp2a_7 nlexp3 nlexp3_7 nlgom3 nlgom3_7 nlgom4 nlgom4_7 nlinit nllog3 nllog3_7 nllog4 nllog4_7 nlog_rd nlogit nlogit_p nlogitgen nlogittree nlpred no nobreak noi nois noisi noisil noisily note notes notes_dlg nptrend numlabel numlist odbc old_ver olo olog ologi ologi_sw ologit ologit_p ologitp on one onew onewa oneway op_colnm op_comp op_diff op_inv op_str opr opro oprob oprob_sw oprobi oprobi_p oprobit oprobitp opts_exclusive order orthog orthpoly ou out outf outfi outfil outfile outs outsh outshe outshee outsheet ovtest pac pac_7 palette parse parse_dissim pause pca pca_8 pca_display pca_estat pca_p pca_rotate pcamat pchart pchart_7 pchi pchi_7 pcorr pctile pentium pergram pergram_7 permute permute_8 personal peto_st pkcollapse pkcross pkequiv pkexamine pkexamine_7 pkshape pksumm pksumm_7 pl plo plot plugin pnorm pnorm_7 poisgof poiss_lf poiss_sw poisso_p poisson poisson_estat post postclose postfile postutil pperron pr prais prais_e prais_e2 prais_p predict predictnl preserve print pro prob probi probit probit_estat probit_p proc_time procoverlay procrustes procrustes_estat procrustes_p profiler prog progr progra program prop proportion prtest prtesti pwcorr pwd q\\s qby qbys qchi qchi_7 qladder qladder_7 qnorm qnorm_7 qqplot qqplot_7 qreg qreg_c qreg_p qreg_sw qu quadchk quantile quantile_7 que quer query range ranksum ratio rchart rchart_7 rcof recast reclink recode reg reg3 reg3_p regdw regr regre regre_p2 regres regres_p regress regress_estat regriv_p remap ren rena renam rename renpfix repeat replace report reshape restore ret retu retur return rm rmdir robvar roccomp roccomp_7 roccomp_8 rocf_lf rocfit rocfit_8 rocgold rocplot rocplot_7 roctab roctab_7 rolling rologit rologit_p rot rota rotat rotate rotatemat rreg rreg_p ru run runtest rvfplot rvfplot_7 rvpplot rvpplot_7 sa safesum sample sampsi sav save savedresults saveold sc sca scal scala scalar scatter scm_mine sco scob_lf scob_p scobi_sw scobit scor score scoreplot scoreplot_help scree screeplot screeplot_help sdtest sdtesti se search separate seperate serrbar serrbar_7 serset set set_defaults sfrancia sh she shel shell shewhart shewhart_7 signestimationsample signrank signtest simul simul_7 simulate simulate_8 sktest sleep slogit slogit_d2 slogit_p smooth snapspan so sor sort spearman spikeplot spikeplot_7 spikeplt spline_x split sqreg sqreg_p sret sretu sretur sreturn ssc st st_ct st_hc st_hcd st_hcd_sh st_is st_issys st_note st_promo st_set st_show st_smpl st_subid stack statsby statsby_8 stbase stci stci_7 stcox stcox_estat stcox_fr stcox_fr_ll stcox_p stcox_sw stcoxkm stcoxkm_7 stcstat stcurv stcurve stcurve_7 stdes stem stepwise stereg stfill stgen stir stjoin stmc stmh stphplot stphplot_7 stphtest stphtest_7 stptime strate strate_7 streg streg_sw streset sts sts_7 stset stsplit stsum sttocc sttoct stvary stweib su suest suest_8 sum summ summa summar summari summariz summarize sunflower sureg survcurv survsum svar svar_p svmat svy svy_disp svy_dreg svy_est svy_est_7 svy_estat svy_get svy_gnbreg_p svy_head svy_header svy_heckman_p svy_heckprob_p svy_intreg_p svy_ivreg_p svy_logistic_p svy_logit_p svy_mlogit_p svy_nbreg_p svy_ologit_p svy_oprobit_p svy_poisson_p svy_probit_p svy_regress_p svy_sub svy_sub_7 svy_x svy_x_7 svy_x_p svydes svydes_8 svygen svygnbreg svyheckman svyheckprob svyintreg svyintreg_7 svyintrg svyivreg svylc svylog_p svylogit svymarkout svymarkout_8 svymean svymlog svymlogit svynbreg svyolog svyologit svyoprob svyoprobit svyopts svypois svypois_7 svypoisson svyprobit svyprobt svyprop svyprop_7 svyratio svyreg svyreg_p svyregress svyset svyset_7 svyset_8 svytab svytab_7 svytest svytotal sw sw_8 swcnreg swcox swereg swilk swlogis swlogit swologit swoprbt swpois swprobit swqreg swtobit swweib symmetry symmi symplot symplot_7 syntax sysdescribe sysdir sysuse szroeter ta tab tab1 tab2 tab_or tabd tabdi tabdis tabdisp tabi table tabodds tabodds_7 tabstat tabu tabul tabula tabulat tabulate te tempfile tempname tempvar tes test testnl testparm teststd tetrachoric time_it timer tis tob tobi tobit tobit_p tobit_sw token tokeni tokeniz tokenize tostring total translate translator transmap treat_ll treatr_p treatreg trim trimfill trnb_cons trnb_mean trpoiss_d2 trunc_ll truncr_p truncreg tsappend tset tsfill tsline tsline_ex tsreport tsrevar tsrline tsset tssmooth tsunab ttest ttesti tut_chk tut_wait tutorial tw tware_st two twoway twoway__fpfit_serset twoway__function_gen twoway__histogram_gen twoway__ipoint_serset twoway__ipoints_serset twoway__kdensity_gen twoway__lfit_serset twoway__normgen_gen twoway__pci_serset twoway__qfit_serset twoway__scatteri_serset twoway__sunflower_gen twoway_ksm_serset ty typ type typeof u|0 unab unabbrev unabcmd update us use uselabel var var_mkcompanion var_p varbasic varfcast vargranger varirf varirf_add varirf_cgraph varirf_create varirf_ctable varirf_describe varirf_dir varirf_drop varirf_erase varirf_graph varirf_ograph varirf_rename varirf_set varirf_table varlist varlmar varnorm varsoc varstable varstable_w varstable_w2 varwle vce vec vec_fevd vec_mkphi vec_p vec_p_w vecirf_create veclmar veclmar_w vecnorm vecnorm_w vecrank vecstable verinst vers versi versio version view viewsource vif vwls wdatetof webdescribe webseek webuse weib1_lf weib2_lf weib_lf weib_lf0 weibhet_glf weibhet_glf_sh weibhet_glfa weibhet_glfa_sh weibhet_gp weibhet_ilf weibhet_ilf_sh weibhet_ilfa weibhet_ilfa_sh weibhet_ip weibu_sw weibul_p weibull weibull_c weibull_s weibullhet wh whelp whi which whil while wilc_st wilcoxon win wind windo window winexec wntestb wntestb_7 wntestq xchart xchart_7 xcorr xcorr_7 xi xi_6 xmlsav xmlsave xmluse xpose xsh xshe xshel xshell xt_iis xt_tis xtab_p xtabond xtbin_p xtclog xtcloglog xtcloglog_8 xtcloglog_d2 xtcloglog_pa_p xtcloglog_re_p xtcnt_p xtcorr xtdata xtdes xtfront_p xtfrontier xtgee xtgee_elink xtgee_estat xtgee_makeivar xtgee_p xtgee_plink xtgls xtgls_p xthaus xthausman xtht_p xthtaylor xtile xtint_p xtintreg xtintreg_8 xtintreg_d2 xtintreg_p xtivp_1 xtivp_2 xtivreg xtline xtline_ex xtlogit xtlogit_8 xtlogit_d2 xtlogit_fe_p xtlogit_pa_p xtlogit_re_p xtmixed xtmixed_estat xtmixed_p xtnb_fe xtnb_lf xtnbreg xtnbreg_pa_p xtnbreg_refe_p xtpcse xtpcse_p xtpois xtpoisson xtpoisson_d2 xtpoisson_pa_p xtpoisson_refe_p xtpred xtprobit xtprobit_8 xtprobit_d2 xtprobit_re_p xtps_fe xtps_lf xtps_ren xtps_ren_8 xtrar_p xtrc xtrc_p xtrchh xtrefe_p xtreg xtreg_be xtreg_fe xtreg_ml xtreg_pa_p xtreg_re xtregar xtrere_p xtset xtsf_ll xtsf_llti xtsum xttab xttest0 xttobit xttobit_8 xttobit_p xttrans yx yxview__barlike_draw yxview_area_draw yxview_bar_draw yxview_dot_draw yxview_dropline_draw yxview_function_draw yxview_iarrow_draw yxview_ilabels_draw yxview_normal_draw yxview_pcarrow_draw yxview_pcbarrow_draw yxview_pccapsym_draw yxview_pcscatter_draw yxview_pcspike_draw yxview_rarea_draw yxview_rbar_draw yxview_rbarm_draw yxview_rcap_draw yxview_rcapsym_draw yxview_rconnected_draw yxview_rline_draw yxview_rscatter_draw yxview_rspike_draw yxview_spike_draw yxview_sunflower_draw zap_s zinb zinb_llf zinb_plf zip zip_llf zip_p zip_plf zt_ct_5 zt_hc_5 zt_hcd_5 zt_is_5 zt_iss_5 zt_sho_5 zt_smp_5 ztbase_5 ztcox_5 ztdes_5 ztereg_5 ztfill_5 ztgen_5 ztir_5 ztjoin_5 ztnb ztnb_p ztp ztp_p zts_5 ztset_5 ztspli_5 ztsum_5 zttoct_5 ztvary_5 ztweib_5",contains:[{className:"symbol",begin:/`[a-zA-Z0-9_]+'/},{className:"variable",begin:/\$\{?[a-zA-Z0-9_]+\}?/},{className:"string",variants:[{begin:'`"[^\r\n]*?"\''},{begin:'"[^\r\n"]*"'}]},{className:"built_in",variants:[{begin:"\\b(abs|acos|asin|atan|atan2|atanh|ceil|cloglog|comb|cos|digamma|exp|floor|invcloglog|invlogit|ln|lnfact|lnfactorial|lngamma|log|log10|max|min|mod|reldif|round|sign|sin|sqrt|sum|tan|tanh|trigamma|trunc|betaden|Binomial|binorm|binormal|chi2|chi2tail|dgammapda|dgammapdada|dgammapdadx|dgammapdx|dgammapdxdx|F|Fden|Ftail|gammaden|gammap|ibeta|invbinomial|invchi2|invchi2tail|invF|invFtail|invgammap|invibeta|invnchi2|invnFtail|invnibeta|invnorm|invnormal|invttail|nbetaden|nchi2|nFden|nFtail|nibeta|norm|normal|normalden|normd|npnchi2|tden|ttail|uniform|abbrev|char|index|indexnot|length|lower|ltrim|match|plural|proper|real|regexm|regexr|regexs|reverse|rtrim|string|strlen|strlower|strltrim|strmatch|strofreal|strpos|strproper|strreverse|strrtrim|strtrim|strupper|subinstr|subinword|substr|trim|upper|word|wordcount|_caller|autocode|byteorder|chop|clip|cond|e|epsdouble|epsfloat|group|inlist|inrange|irecode|matrix|maxbyte|maxdouble|maxfloat|maxint|maxlong|mi|minbyte|mindouble|minfloat|minint|minlong|missing|r|recode|replay|return|s|scalar|d|date|day|dow|doy|halfyear|mdy|month|quarter|week|year|d|daily|dofd|dofh|dofm|dofq|dofw|dofy|h|halfyearly|hofd|m|mofd|monthly|q|qofd|quarterly|tin|twithin|w|weekly|wofd|y|yearly|yh|ym|yofd|yq|yw|cholesky|colnumb|colsof|corr|det|diag|diag0cnt|el|get|hadamard|I|inv|invsym|issym|issymmetric|J|matmissing|matuniform|mreldif|nullmat|rownumb|rowsof|sweep|syminv|trace|vec|vecdiag)(?=\\()"}]},e.COMMENT("^[ \t]*\\*.*$",!1),e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]}},$s=function(e){return{name:"STEP Part 21",aliases:["p21","step","stp"],case_insensitive:!0,lexemes:"[A-Z_][A-Z0-9_.]*",keywords:{keyword:"HEADER ENDSEC DATA"},contains:[{className:"meta",begin:"ISO-10303-21;",relevance:10},{className:"meta",begin:"END-ISO-10303-21;",relevance:10},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.COMMENT("/\\*\\*!","\\*/"),e.C_NUMBER_MODE,e.inherit(e.APOS_STRING_MODE,{illegal:null}),e.inherit(e.QUOTE_STRING_MODE,{illegal:null}),{className:"string",begin:"'",end:"'"},{className:"symbol",variants:[{begin:"#",end:"\\d+",illegal:"\\W"}]}]}},Ks=function(e){var t={className:"variable",begin:"\\$"+e.IDENT_RE},a={className:"number",begin:"#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})"};return{name:"Stylus",aliases:["styl"],case_insensitive:!1,keywords:"if else for in",illegal:"("+["\\?","(\\bReturn\\b)","(\\bEnd\\b)","(\\bend\\b)","(\\bdef\\b)",";","#\\s","\\*\\s","===\\s","\\|","%"].join("|")+")",contains:[e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,a,{begin:"\\.[a-zA-Z][a-zA-Z0-9_-]*(?=[\\.\\s\\n\\[\\:,])",className:"selector-class"},{begin:"\\#[a-zA-Z][a-zA-Z0-9_-]*(?=[\\.\\s\\n\\[\\:,])",className:"selector-id"},{begin:"\\b("+["a","abbr","address","article","aside","audio","b","blockquote","body","button","canvas","caption","cite","code","dd","del","details","dfn","div","dl","dt","em","fieldset","figcaption","figure","footer","form","h1","h2","h3","h4","h5","h6","header","hgroup","html","i","iframe","img","input","ins","kbd","label","legend","li","mark","menu","nav","object","ol","p","q","quote","samp","section","span","strong","summary","sup","table","tbody","td","textarea","tfoot","th","thead","time","tr","ul","var","video"].join("|")+")(?=[\\.\\s\\n\\[\\:,])",className:"selector-tag"},{begin:"&?:?:\\b("+["after","before","first-letter","first-line","active","first-child","focus","hover","lang","link","visited"].join("|")+")(?=[\\.\\s\\n\\[\\:,])"},{begin:"@("+["charset","css","debug","extend","font-face","for","import","include","media","mixin","page","warn","while"].join("|")+")\\b"},t,e.CSS_NUMBER_MODE,e.NUMBER_MODE,{className:"function",begin:"^[a-zA-Z][a-zA-Z0-9_-]*\\(.*\\)",illegal:"[\\n]",returnBegin:!0,contains:[{className:"title",begin:"\\b[a-zA-Z][a-zA-Z0-9_-]*"},{className:"params",begin:/\(/,end:/\)/,contains:[a,t,e.APOS_STRING_MODE,e.CSS_NUMBER_MODE,e.NUMBER_MODE,e.QUOTE_STRING_MODE]}]},{className:"attribute",begin:"\\b("+["align-content","align-items","align-self","animation","animation-delay","animation-direction","animation-duration","animation-fill-mode","animation-iteration-count","animation-name","animation-play-state","animation-timing-function","auto","backface-visibility","background","background-attachment","background-clip","background-color","background-image","background-origin","background-position","background-repeat","background-size","border","border-bottom","border-bottom-color","border-bottom-left-radius","border-bottom-right-radius","border-bottom-style","border-bottom-width","border-collapse","border-color","border-image","border-image-outset","border-image-repeat","border-image-slice","border-image-source","border-image-width","border-left","border-left-color","border-left-style","border-left-width","border-radius","border-right","border-right-color","border-right-style","border-right-width","border-spacing","border-style","border-top","border-top-color","border-top-left-radius","border-top-right-radius","border-top-style","border-top-width","border-width","bottom","box-decoration-break","box-shadow","box-sizing","break-after","break-before","break-inside","caption-side","clear","clip","clip-path","color","column-count","column-fill","column-gap","column-rule","column-rule-color","column-rule-style","column-rule-width","column-span","column-width","columns","content","counter-increment","counter-reset","cursor","direction","display","empty-cells","filter","flex","flex-basis","flex-direction","flex-flow","flex-grow","flex-shrink","flex-wrap","float","font","font-family","font-feature-settings","font-kerning","font-language-override","font-size","font-size-adjust","font-stretch","font-style","font-variant","font-variant-ligatures","font-weight","height","hyphens","icon","image-orientation","image-rendering","image-resolution","ime-mode","inherit","initial","justify-content","left","letter-spacing","line-height","list-style","list-style-image","list-style-position","list-style-type","margin","margin-bottom","margin-left","margin-right","margin-top","marks","mask","max-height","max-width","min-height","min-width","nav-down","nav-index","nav-left","nav-right","nav-up","none","normal","object-fit","object-position","opacity","order","orphans","outline","outline-color","outline-offset","outline-style","outline-width","overflow","overflow-wrap","overflow-x","overflow-y","padding","padding-bottom","padding-left","padding-right","padding-top","page-break-after","page-break-before","page-break-inside","perspective","perspective-origin","pointer-events","position","quotes","resize","right","tab-size","table-layout","text-align","text-align-last","text-decoration","text-decoration-color","text-decoration-line","text-decoration-style","text-indent","text-overflow","text-rendering","text-shadow","text-transform","text-underline-position","top","transform","transform-origin","transform-style","transition","transition-delay","transition-duration","transition-property","transition-timing-function","unicode-bidi","vertical-align","visibility","white-space","widows","width","word-break","word-spacing","word-wrap","z-index"].reverse().join("|")+")\\b",starts:{end:/;|$/,contains:[a,t,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,e.CSS_NUMBER_MODE,e.NUMBER_MODE,e.C_BLOCK_COMMENT_MODE],illegal:/\./,relevance:0}}]}},js=function(){return{name:"SubUnit",case_insensitive:!0,contains:[{className:"string",begin:"\\[\n(multipart)?",end:"\\]\n"},{className:"string",begin:"\\d{4}-\\d{2}-\\d{2}(\\s+)\\d{2}:\\d{2}:\\d{2}.\\d+Z"},{className:"string",begin:"(\\+|-)\\d+"},{className:"keyword",relevance:10,variants:[{begin:"^(test|testing|success|successful|failure|error|skip|xfail|uxsuccess)(:?)\\s+(test)?"},{begin:"^progress(:?)(\\s+)?(pop|push)?"},{begin:"^tags:"},{begin:"^time:"}]}]}},Xs=function(e){var t={keyword:"#available #colorLiteral #column #else #elseif #endif #file #fileLiteral #function #if #imageLiteral #line #selector #sourceLocation _ __COLUMN__ __FILE__ __FUNCTION__ __LINE__ Any as as! as? associatedtype associativity break case catch class continue convenience default defer deinit didSet do dynamic dynamicType else enum extension fallthrough false fileprivate final for func get guard if import in indirect infix init inout internal is lazy left let mutating nil none nonmutating open operator optional override postfix precedence prefix private protocol Protocol public repeat required rethrows return right self Self set static struct subscript super switch throw throws true try try! try? Type typealias unowned var weak where while willSet",literal:"true false nil",built_in:"abs advance alignof alignofValue anyGenerator assert assertionFailure bridgeFromObjectiveC bridgeFromObjectiveCUnconditional bridgeToObjectiveC bridgeToObjectiveCUnconditional c compactMap contains count countElements countLeadingZeros debugPrint debugPrintln distance dropFirst dropLast dump encodeBitsAsWords enumerate equal fatalError filter find getBridgedObjectiveCType getVaList indices insertionSort isBridgedToObjectiveC isBridgedVerbatimToObjectiveC isUniquelyReferenced isUniquelyReferencedNonObjC join lazy lexicographicalCompare map max maxElement min minElement numericCast overlaps partition posix precondition preconditionFailure print println quickSort readLine reduce reflect reinterpretCast reverse roundUpToAlignment sizeof sizeofValue sort split startsWith stride strideof strideofValue swap toString transcode underestimateCount unsafeAddressOf unsafeBitCast unsafeDowncast unsafeUnwrap unsafeReflect withExtendedLifetime withObjectAtPlusZero withUnsafePointer withUnsafePointerToObject withUnsafeMutablePointer withUnsafeMutablePointers withUnsafePointer withUnsafePointers withVaList zip"},a=e.COMMENT("/\\*","\\*/",{contains:["self"]}),n={className:"subst",begin:/\\\(/,end:"\\)",keywords:t,contains:[]},r={className:"string",contains:[e.BACKSLASH_ESCAPE,n],variants:[{begin:/"""/,end:/"""/},{begin:/"/,end:/"/}]},i={className:"number",begin:"\\b([\\d_]+(\\.[\\deE_]+)?|0x[a-fA-F0-9_]+(\\.[a-fA-F0-9p_]+)?|0b[01_]+|0o[0-7_]+)\\b",relevance:0};return n.contains=[i],{name:"Swift",keywords:t,contains:[r,e.C_LINE_COMMENT_MODE,a,{className:"type",begin:"\\b[A-Z][\\w\xc0-\u02b8']*[!?]"},{className:"type",begin:"\\b[A-Z][\\w\xc0-\u02b8']*",relevance:0},i,{className:"function",beginKeywords:"func",end:"{",excludeEnd:!0,contains:[e.inherit(e.TITLE_MODE,{begin:/[A-Za-z$_][0-9A-Za-z$_]*/}),{begin://},{className:"params",begin:/\(/,end:/\)/,endsParent:!0,keywords:t,contains:["self",i,r,e.C_BLOCK_COMMENT_MODE,{begin:":"}],illegal:/["']/}],illegal:/\[|%/},{className:"class",beginKeywords:"struct protocol class extension enum",keywords:t,end:"\\{",excludeEnd:!0,contains:[e.inherit(e.TITLE_MODE,{begin:/[A-Za-z$_][\u00C0-\u02B80-9A-Za-z$_]*/})]},{className:"meta",begin:"(@discardableResult|@warn_unused_result|@exported|@lazy|@noescape|@NSCopying|@NSManaged|@objc|@objcMembers|@convention|@required|@noreturn|@IBAction|@IBDesignable|@IBInspectable|@IBOutlet|@infix|@prefix|@postfix|@autoclosure|@testable|@available|@nonobjc|@NSApplicationMain|@UIApplicationMain|@dynamicMemberLookup|@propertyWrapper)"},{beginKeywords:"import",end:/$/,contains:[e.C_LINE_COMMENT_MODE,a]}]}},Zs=function(){return{name:"Tagger Script",contains:[{className:"comment",begin:/\$noop\(/,end:/\)/,contains:[{begin:/\(/,end:/\)/,contains:["self",{begin:/\\./}]}],relevance:10},{className:"keyword",begin:/\$(?!noop)[a-zA-Z][_a-zA-Z0-9]*/,end:/\(/,excludeEnd:!0},{className:"variable",begin:/%[_a-zA-Z0-9:]*/,end:"%"},{className:"symbol",begin:/\\./}]}},Js=function(e){var t={className:"string",relevance:0,variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/\S+/}],contains:[e.BACKSLASH_ESCAPE,{className:"template-variable",variants:[{begin:"{{",end:"}}"},{begin:"%{",end:"}"}]}]},a={className:"number",begin:"\\b[0-9]{4}(-[0-9][0-9]){0,2}([Tt \\t][0-9][0-9]?(:[0-9][0-9]){2})?(\\.[0-9]*)?([ \\t])*(Z|[-+][0-9][0-9]?(:[0-9][0-9])?)?\\b"};return{name:"YAML",case_insensitive:!0,aliases:["yml","YAML"],contains:[{className:"attr",variants:[{begin:"\\w[\\w :\\/.-]*:(?=[ \t]|$)"},{begin:'"\\w[\\w :\\/.-]*":(?=[ \t]|$)'},{begin:"'\\w[\\w :\\/.-]*':(?=[ \t]|$)"}]},{className:"meta",begin:"^---s*$",relevance:10},{className:"string",begin:"[\\|>]([0-9]?[+-])?[ ]*\\n( *)[\\S ]+\\n(\\2[\\S ]+\\n?)*"},{begin:"<%[%=-]?",end:"[%-]?%>",subLanguage:"ruby",excludeBegin:!0,excludeEnd:!0,relevance:0},{className:"type",begin:"!"+e.UNDERSCORE_IDENT_RE},{className:"type",begin:"!!"+e.UNDERSCORE_IDENT_RE},{className:"meta",begin:"&"+e.UNDERSCORE_IDENT_RE+"$"},{className:"meta",begin:"\\*"+e.UNDERSCORE_IDENT_RE+"$"},{className:"bullet",begin:"\\-(?=[ ]|$)",relevance:0},e.HASH_COMMENT_MODE,{beginKeywords:"true false yes no null",keywords:{literal:"true false yes no null"}},a,{className:"number",begin:e.C_NUMBER_RE+"\\b"},t]}},el=function(e){return{name:"Test Anything Protocol",case_insensitive:!0,contains:[e.HASH_COMMENT_MODE,{className:"meta",variants:[{begin:"^TAP version (\\d+)$"},{begin:"^1\\.\\.(\\d+)$"}]},{begin:"(s+)?---$",end:"\\.\\.\\.$",subLanguage:"yaml",relevance:0},{className:"number",begin:" (\\d+) "},{className:"symbol",variants:[{begin:"^ok"},{begin:"^not ok"}]}]}},tl=function(e){return{name:"Tcl",aliases:["tk"],keywords:"after append apply array auto_execok auto_import auto_load auto_mkindex auto_mkindex_old auto_qualify auto_reset bgerror binary break catch cd chan clock close concat continue dde dict encoding eof error eval exec exit expr fblocked fconfigure fcopy file fileevent filename flush for foreach format gets glob global history http if incr info interp join lappend|10 lassign|10 lindex|10 linsert|10 list llength|10 load lrange|10 lrepeat|10 lreplace|10 lreverse|10 lsearch|10 lset|10 lsort|10 mathfunc mathop memory msgcat namespace open package parray pid pkg::create pkg_mkIndex platform platform::shell proc puts pwd read refchan regexp registry regsub|10 rename return safe scan seek set socket source split string subst switch tcl_endOfWord tcl_findLibrary tcl_startOfNextWord tcl_startOfPreviousWord tcl_wordBreakAfter tcl_wordBreakBefore tcltest tclvars tell time tm trace unknown unload unset update uplevel upvar variable vwait while",contains:[e.COMMENT(";[ \\t]*#","$"),e.COMMENT("^[ \\t]*#","$"),{beginKeywords:"proc",end:"[\\{]",excludeEnd:!0,contains:[{className:"title",begin:"[ \\t\\n\\r]+(::)?[a-zA-Z_]((::)?[a-zA-Z0-9_])*",end:"[ \\t\\n\\r]",endsWithParent:!0,excludeEnd:!0}]},{excludeEnd:!0,variants:[{begin:"\\$(\\{)?(::)?[a-zA-Z_]((::)?[a-zA-Z0-9_])*\\(([a-zA-Z0-9_])*\\)",end:"[^a-zA-Z0-9_\\}\\$]"},{begin:"\\$(\\{)?(::)?[a-zA-Z_]((::)?[a-zA-Z0-9_])*",end:"(\\))?[^a-zA-Z0-9_\\}\\$]"}]},{className:"string",contains:[e.BACKSLASH_ESCAPE],variants:[e.inherit(e.QUOTE_STRING_MODE,{illegal:null})]},{className:"number",variants:[e.BINARY_NUMBER_MODE,e.C_NUMBER_MODE]}]}},al=function(e){return{name:"Thrift",keywords:{keyword:"namespace const typedef struct enum service exception void oneway set list map required optional",built_in:"bool byte i16 i32 i64 double string binary",literal:"true false"},contains:[e.QUOTE_STRING_MODE,e.NUMBER_MODE,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{className:"class",beginKeywords:"struct enum service exception",end:/\{/,illegal:/\n/,contains:[e.inherit(e.TITLE_MODE,{starts:{endsWithParent:!0,excludeEnd:!0}})]},{begin:"\\b(set|list|map)\\s*<",end:">",keywords:"bool byte i16 i32 i64 double string binary",contains:["self"]}]}},nl=function(e){var t={className:"number",begin:"[1-9][0-9]*",relevance:0},a={className:"symbol",begin:":[^\\]]+"};return{name:"TP",keywords:{keyword:"ABORT ACC ADJUST AND AP_LD BREAK CALL CNT COL CONDITION CONFIG DA DB DIV DETECT ELSE END ENDFOR ERR_NUM ERROR_PROG FINE FOR GP GUARD INC IF JMP LINEAR_MAX_SPEED LOCK MOD MONITOR OFFSET Offset OR OVERRIDE PAUSE PREG PTH RT_LD RUN SELECT SKIP Skip TA TB TO TOOL_OFFSET Tool_Offset UF UT UFRAME_NUM UTOOL_NUM UNLOCK WAIT X Y Z W P R STRLEN SUBSTR FINDSTR VOFFSET PROG ATTR MN POS",literal:"ON OFF max_speed LPOS JPOS ENABLE DISABLE START STOP RESET"},contains:[{className:"built_in",begin:"(AR|P|PAYLOAD|PR|R|SR|RSR|LBL|VR|UALM|MESSAGE|UTOOL|UFRAME|TIMER|TIMER_OVERFLOW|JOINT_MAX_SPEED|RESUME_PROG|DIAG_REC)\\[",end:"\\]",contains:["self",t,a]},{className:"built_in",begin:"(AI|AO|DI|DO|F|RI|RO|UI|UO|GI|GO|SI|SO)\\[",end:"\\]",contains:["self",t,e.QUOTE_STRING_MODE,a]},{className:"keyword",begin:"/(PROG|ATTR|MN|POS|END)\\b"},{className:"keyword",begin:"(CALL|RUN|POINT_LOGIC|LBL)\\b"},{className:"keyword",begin:"\\b(ACC|CNT|Skip|Offset|PSPD|RT_LD|AP_LD|Tool_Offset)"},{className:"number",begin:"\\d+(sec|msec|mm/sec|cm/min|inch/min|deg/sec|mm|in|cm)?\\b",relevance:0},e.COMMENT("//","[;$]"),e.COMMENT("!","[;$]"),e.COMMENT("--eg:","$"),e.QUOTE_STRING_MODE,{className:"string",begin:"'",end:"'"},e.C_NUMBER_MODE,{className:"variable",begin:"\\$[A-Za-z0-9_]+"}]}},rl=function(e){var t="attribute block constant cycle date dump include max min parent random range source template_from_string",a={beginKeywords:t,keywords:{name:t},relevance:0,contains:[{className:"params",begin:"\\(",end:"\\)"}]},n={begin:/\|[A-Za-z_]+:?/,keywords:"abs batch capitalize column convert_encoding date date_modify default escape filter first format inky_to_html inline_css join json_encode keys last length lower map markdown merge nl2br number_format raw reduce replace reverse round slice sort spaceless split striptags title trim upper url_encode",contains:[a]},r="apply autoescape block deprecated do embed extends filter flush for from if import include macro sandbox set use verbatim with";return r=r+" "+r.split(" ").map(function(e){return"end"+e}).join(" "),{name:"Twig",aliases:["craftcms"],case_insensitive:!0,subLanguage:"xml", +contains:[e.COMMENT(/\{#/,/#}/),{className:"template-tag",begin:/\{%/,end:/%}/,contains:[{className:"name",begin:/\w+/,keywords:r,starts:{endsWithParent:!0,contains:[n,a],relevance:0}}]},{className:"template-variable",begin:/\{\{/,end:/}}/,contains:["self",n,a]}]}},il=function(e){var t={keyword:"in if for while finally var new function do return void else break catch instanceof with throw case default try this switch continue typeof delete let yield const class public private protected get set super static implements enum export import declare type namespace abstract as from extends async await",literal:"true false null undefined NaN Infinity",built_in:"eval isFinite isNaN parseFloat parseInt decodeURI decodeURIComponent encodeURI encodeURIComponent escape unescape Object Function Boolean Error EvalError InternalError RangeError ReferenceError StopIteration SyntaxError TypeError URIError Number Math Date String RegExp Array Float32Array Float64Array Int16Array Int32Array Int8Array Uint16Array Uint32Array Uint8Array Uint8ClampedArray ArrayBuffer DataView JSON Intl arguments require module console window document any number boolean string void Promise"},a={className:"meta",begin:"@[A-Za-z$_][0-9A-Za-z$_]*"},n={begin:"\\(",end:/\)/,keywords:t,contains:["self",e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,e.NUMBER_MODE]},r={className:"params",begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:t,contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,a,n]},i={className:"number",variants:[{begin:"\\b(0[bB][01]+)n?"},{begin:"\\b(0[oO][0-7]+)n?"},{begin:e.C_NUMBER_RE+"n?"}],relevance:0},o={className:"subst",begin:"\\$\\{",end:"\\}",keywords:t,contains:[]},s={begin:"html`",end:"",starts:{end:"`",returnEnd:!1,contains:[e.BACKSLASH_ESCAPE,o],subLanguage:"xml"}},l={begin:"css`",end:"",starts:{end:"`",returnEnd:!1,contains:[e.BACKSLASH_ESCAPE,o],subLanguage:"css"}},_={className:"string",begin:"`",end:"`",contains:[e.BACKSLASH_ESCAPE,o]};return o.contains=[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,s,l,_,i,e.REGEXP_MODE],{name:"TypeScript",aliases:["ts"],keywords:t,contains:[{className:"meta",begin:/^\s*['"]use strict['"]/},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,s,l,_,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,i,{begin:"("+e.RE_STARTERS_RE+"|\\b(case|return|throw)\\b)\\s*",keywords:"return throw case",contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.REGEXP_MODE,{className:"function",begin:"(\\(.*?\\)|"+e.IDENT_RE+")\\s*=>",returnBegin:!0,end:"\\s*=>",contains:[{className:"params",variants:[{begin:e.IDENT_RE},{begin:/\(\s*\)/},{begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:t,contains:["self",e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]}]}]}],relevance:0},{className:"function",beginKeywords:"function",end:/[\{;]/,excludeEnd:!0,keywords:t,contains:["self",e.inherit(e.TITLE_MODE,{begin:"[A-Za-z$_][0-9A-Za-z$_]*"}),r],illegal:/%/,relevance:0},{beginKeywords:"constructor",end:/[\{;]/,excludeEnd:!0,contains:["self",r]},{begin:/module\./,keywords:{built_in:"module"},relevance:0},{beginKeywords:"module",end:/\{/,excludeEnd:!0},{beginKeywords:"interface",end:/\{/,excludeEnd:!0,keywords:"interface extends"},{begin:/\$[(.]/},{begin:"\\."+e.IDENT_RE,relevance:0},a,n]}},ol=function(e){return{name:"Vala",keywords:{keyword:"char uchar unichar int uint long ulong short ushort int8 int16 int32 int64 uint8 uint16 uint32 uint64 float double bool struct enum string void weak unowned owned async signal static abstract interface override virtual delegate if while do for foreach else switch case break default return try catch public private protected internal using new this get set const stdout stdin stderr var",built_in:"DBus GLib CCode Gee Object Gtk Posix",literal:"false true null"},contains:[{className:"class",beginKeywords:"class interface namespace",end:"{",excludeEnd:!0,illegal:"[^,:\\n\\s\\.]",contains:[e.UNDERSCORE_TITLE_MODE]},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{className:"string",begin:'"""',end:'"""',relevance:5},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,e.C_NUMBER_MODE,{className:"meta",begin:"^#",end:"$",relevance:2}]}},sl=function(e){return{name:"Visual Basic .NET",aliases:["vb"],case_insensitive:!0,keywords:{keyword:"addhandler addressof alias and andalso aggregate ansi as async assembly auto await binary by byref byval call case catch class compare const continue custom declare default delegate dim distinct do each equals else elseif end enum erase error event exit explicit finally for friend from function get global goto group handles if implements imports in inherits interface into is isfalse isnot istrue iterator join key let lib like loop me mid mod module mustinherit mustoverride mybase myclass nameof namespace narrowing new next not notinheritable notoverridable of off on operator option optional or order orelse overloads overridable overrides paramarray partial preserve private property protected public raiseevent readonly redim rem removehandler resume return select set shadows shared skip static step stop structure strict sub synclock take text then throw to try unicode until using when where while widening with withevents writeonly xor yield",built_in:"boolean byte cbool cbyte cchar cdate cdec cdbl char cint clng cobj csbyte cshort csng cstr ctype date decimal directcast double gettype getxmlnamespace iif integer long object sbyte short single string trycast typeof uinteger ulong ushort",literal:"true false nothing"},illegal:"//|{|}|endif|gosub|variant|wend|^\\$ ",contains:[e.inherit(e.QUOTE_STRING_MODE,{contains:[{begin:'""'}]}),e.COMMENT("'","$",{returnBegin:!0,contains:[{className:"doctag",begin:"'''|",contains:[e.PHRASAL_WORDS_MODE]},{className:"doctag",begin:"",contains:[e.PHRASAL_WORDS_MODE]}]}),e.C_NUMBER_MODE,{className:"meta",begin:"#",end:"$",keywords:{"meta-keyword":"if else elseif end region externalsource"}}]}},ll=function(e){return{name:"VBScript",aliases:["vbs"],case_insensitive:!0,keywords:{keyword:"call class const dim do loop erase execute executeglobal exit for each next function if then else on error option explicit new private property let get public randomize redim rem select case set stop sub while wend with end to elseif is or xor and not class_initialize class_terminate default preserve in me byval byref step resume goto",built_in:"lcase month vartype instrrev ubound setlocale getobject rgb getref string weekdayname rnd dateadd monthname now day minute isarray cbool round formatcurrency conversions csng timevalue second year space abs clng timeserial fixs len asc isempty maths dateserial atn timer isobject filter weekday datevalue ccur isdate instr datediff formatdatetime replace isnull right sgn array snumeric log cdbl hex chr lbound msgbox ucase getlocale cos cdate cbyte rtrim join hour oct typename trim strcomp int createobject loadpicture tan formatnumber mid scriptenginebuildversion scriptengine split scriptengineminorversion cint sin datepart ltrim sqr scriptenginemajorversion time derived eval date formatpercent exp inputbox left ascw chrw regexp server response request cstr err",literal:"true false null nothing empty"},illegal:"//",contains:[e.inherit(e.QUOTE_STRING_MODE,{contains:[{begin:'""'}]}),e.COMMENT(/'/,/$/,{relevance:0}),e.C_NUMBER_MODE]}},_l=function(){return{name:"VBScript in HTML",subLanguage:"xml",contains:[{begin:"<%",end:"%>",subLanguage:"vbscript"}]}},cl=function(e){return{name:"Verilog",aliases:["v","sv","svh"],case_insensitive:!1,keywords:{keyword:"accept_on alias always always_comb always_ff always_latch and assert assign assume automatic before begin bind bins binsof bit break buf|0 bufif0 bufif1 byte case casex casez cell chandle checker class clocking cmos config const constraint context continue cover covergroup coverpoint cross deassign default defparam design disable dist do edge else end endcase endchecker endclass endclocking endconfig endfunction endgenerate endgroup endinterface endmodule endpackage endprimitive endprogram endproperty endspecify endsequence endtable endtask enum event eventually expect export extends extern final first_match for force foreach forever fork forkjoin function generate|5 genvar global highz0 highz1 if iff ifnone ignore_bins illegal_bins implements implies import incdir include initial inout input inside instance int integer interconnect interface intersect join join_any join_none large let liblist library local localparam logic longint macromodule matches medium modport module nand negedge nettype new nexttime nmos nor noshowcancelled not notif0 notif1 or output package packed parameter pmos posedge primitive priority program property protected pull0 pull1 pulldown pullup pulsestyle_ondetect pulsestyle_onevent pure rand randc randcase randsequence rcmos real realtime ref reg reject_on release repeat restrict return rnmos rpmos rtran rtranif0 rtranif1 s_always s_eventually s_nexttime s_until s_until_with scalared sequence shortint shortreal showcancelled signed small soft solve specify specparam static string strong strong0 strong1 struct super supply0 supply1 sync_accept_on sync_reject_on table tagged task this throughout time timeprecision timeunit tran tranif0 tranif1 tri tri0 tri1 triand trior trireg type typedef union unique unique0 unsigned until until_with untyped use uwire var vectored virtual void wait wait_order wand weak weak0 weak1 while wildcard wire with within wor xnor xor",literal:"null",built_in:"$finish $stop $exit $fatal $error $warning $info $realtime $time $printtimescale $bitstoreal $bitstoshortreal $itor $signed $cast $bits $stime $timeformat $realtobits $shortrealtobits $rtoi $unsigned $asserton $assertkill $assertpasson $assertfailon $assertnonvacuouson $assertoff $assertcontrol $assertpassoff $assertfailoff $assertvacuousoff $isunbounded $sampled $fell $changed $past_gclk $fell_gclk $changed_gclk $rising_gclk $steady_gclk $coverage_control $coverage_get $coverage_save $set_coverage_db_name $rose $stable $past $rose_gclk $stable_gclk $future_gclk $falling_gclk $changing_gclk $display $coverage_get_max $coverage_merge $get_coverage $load_coverage_db $typename $unpacked_dimensions $left $low $increment $clog2 $ln $log10 $exp $sqrt $pow $floor $ceil $sin $cos $tan $countbits $onehot $isunknown $fatal $warning $dimensions $right $high $size $asin $acos $atan $atan2 $hypot $sinh $cosh $tanh $asinh $acosh $atanh $countones $onehot0 $error $info $random $dist_chi_square $dist_erlang $dist_exponential $dist_normal $dist_poisson $dist_t $dist_uniform $q_initialize $q_remove $q_exam $async$and$array $async$nand$array $async$or$array $async$nor$array $sync$and$array $sync$nand$array $sync$or$array $sync$nor$array $q_add $q_full $psprintf $async$and$plane $async$nand$plane $async$or$plane $async$nor$plane $sync$and$plane $sync$nand$plane $sync$or$plane $sync$nor$plane $system $display $displayb $displayh $displayo $strobe $strobeb $strobeh $strobeo $write $readmemb $readmemh $writememh $value$plusargs $dumpvars $dumpon $dumplimit $dumpports $dumpportson $dumpportslimit $writeb $writeh $writeo $monitor $monitorb $monitorh $monitoro $writememb $dumpfile $dumpoff $dumpall $dumpflush $dumpportsoff $dumpportsall $dumpportsflush $fclose $fdisplay $fdisplayb $fdisplayh $fdisplayo $fstrobe $fstrobeb $fstrobeh $fstrobeo $swrite $swriteb $swriteh $swriteo $fscanf $fread $fseek $fflush $feof $fopen $fwrite $fwriteb $fwriteh $fwriteo $fmonitor $fmonitorb $fmonitorh $fmonitoro $sformat $sformatf $fgetc $ungetc $fgets $sscanf $rewind $ftell $ferror"},lexemes:/[\w\$]+/,contains:[e.C_BLOCK_COMMENT_MODE,e.C_LINE_COMMENT_MODE,e.QUOTE_STRING_MODE,{className:"number",contains:[e.BACKSLASH_ESCAPE],variants:[{begin:"\\b((\\d+'(b|h|o|d|B|H|O|D))[0-9xzXZa-fA-F_]+)"},{begin:"\\B(('(b|h|o|d|B|H|O|D))[0-9xzXZa-fA-F_]+)"},{begin:"\\b([0-9_])+",relevance:0}]},{className:"variable",variants:[{begin:"#\\((?!parameter).+\\)"},{begin:"\\.\\w+",relevance:0}]},{className:"meta",begin:"`",end:"$",keywords:{"meta-keyword":"define __FILE__ __LINE__ begin_keywords celldefine default_nettype define else elsif end_keywords endcelldefine endif ifdef ifndef include line nounconnected_drive pragma resetall timescale unconnected_drive undef undefineall"},relevance:0}]}},dl=function(e){return{name:"VHDL",case_insensitive:!0,keywords:{keyword:"abs access after alias all and architecture array assert assume assume_guarantee attribute begin block body buffer bus case component configuration constant context cover disconnect downto default else elsif end entity exit fairness file for force function generate generic group guarded if impure in inertial inout is label library linkage literal loop map mod nand new next nor not null of on open or others out package parameter port postponed procedure process property protected pure range record register reject release rem report restrict restrict_guarantee return rol ror select sequence severity shared signal sla sll sra srl strong subtype then to transport type unaffected units until use variable view vmode vprop vunit wait when while with xnor xor",built_in:"boolean bit character integer time delay_length natural positive string bit_vector file_open_kind file_open_status std_logic std_logic_vector unsigned signed boolean_vector integer_vector std_ulogic std_ulogic_vector unresolved_unsigned u_unsigned unresolved_signed u_signed real_vector time_vector",literal:"false true note warning error failure line text side width"},illegal:"{",contains:[e.C_BLOCK_COMMENT_MODE,e.COMMENT("--","$"),e.QUOTE_STRING_MODE,{className:"number",begin:"\\b(\\d(_|\\d)*#\\w+(\\.\\w+)?#([eE][-+]?\\d(_|\\d)*)?|\\d(_|\\d)*(\\.\\d(_|\\d)*)?([eE][-+]?\\d(_|\\d)*)?)",relevance:0},{className:"string",begin:"'(U|X|0|1|Z|W|L|H|-)'",contains:[e.BACKSLASH_ESCAPE]},{className:"symbol",begin:"'[A-Za-z](_?[A-Za-z0-9])*",contains:[e.BACKSLASH_ESCAPE]}]}},ul=function(e){return{name:"Vim Script",lexemes:/[!#@\w]+/,keywords:{keyword:"N|0 P|0 X|0 a|0 ab abc abo al am an|0 ar arga argd arge argdo argg argl argu as au aug aun b|0 bN ba bad bd be bel bf bl bm bn bo bp br brea breaka breakd breakl bro bufdo buffers bun bw c|0 cN cNf ca cabc caddb cad caddf cal cat cb cc ccl cd ce cex cf cfir cgetb cgete cg changes chd che checkt cl cla clo cm cmapc cme cn cnew cnf cno cnorea cnoreme co col colo com comc comp con conf cope cp cpf cq cr cs cst cu cuna cunme cw delm deb debugg delc delf dif diffg diffo diffp diffpu diffs diffthis dig di dl dell dj dli do doautoa dp dr ds dsp e|0 ea ec echoe echoh echom echon el elsei em en endfo endf endt endw ene ex exe exi exu f|0 files filet fin fina fini fir fix fo foldc foldd folddoc foldo for fu go gr grepa gu gv ha helpf helpg helpt hi hid his ia iabc if ij il im imapc ime ino inorea inoreme int is isp iu iuna iunme j|0 ju k|0 keepa kee keepj lN lNf l|0 lad laddb laddf la lan lat lb lc lch lcl lcs le lefta let lex lf lfir lgetb lgete lg lgr lgrepa lh ll lla lli lmak lm lmapc lne lnew lnf ln loadk lo loc lockv lol lope lp lpf lr ls lt lu lua luad luaf lv lvimgrepa lw m|0 ma mak map mapc marks mat me menut mes mk mks mksp mkv mkvie mod mz mzf nbc nb nbs new nm nmapc nme nn nnoreme noa no noh norea noreme norm nu nun nunme ol o|0 om omapc ome on ono onoreme opt ou ounme ow p|0 profd prof pro promptr pc ped pe perld po popu pp pre prev ps pt ptN ptf ptj ptl ptn ptp ptr pts pu pw py3 python3 py3d py3f py pyd pyf quita qa rec red redi redr redraws reg res ret retu rew ri rightb rub rubyd rubyf rund ru rv sN san sa sal sav sb sbN sba sbf sbl sbm sbn sbp sbr scrip scripte scs se setf setg setl sf sfir sh sim sig sil sl sla sm smap smapc sme sn sni sno snor snoreme sor so spelld spe spelli spellr spellu spellw sp spr sre st sta startg startr star stopi stj sts sun sunm sunme sus sv sw sy synti sync tN tabN tabc tabdo tabe tabf tabfir tabl tabm tabnew tabn tabo tabp tabr tabs tab ta tags tc tcld tclf te tf th tj tl tm tn to tp tr try ts tu u|0 undoj undol una unh unl unlo unm unme uns up ve verb vert vim vimgrepa vi viu vie vm vmapc vme vne vn vnoreme vs vu vunme windo w|0 wN wa wh wi winc winp wn wp wq wqa ws wu wv x|0 xa xmapc xm xme xn xnoreme xu xunme y|0 z|0 ~ Next Print append abbreviate abclear aboveleft all amenu anoremenu args argadd argdelete argedit argglobal arglocal argument ascii autocmd augroup aunmenu buffer bNext ball badd bdelete behave belowright bfirst blast bmodified bnext botright bprevious brewind break breakadd breakdel breaklist browse bunload bwipeout change cNext cNfile cabbrev cabclear caddbuffer caddexpr caddfile call catch cbuffer cclose center cexpr cfile cfirst cgetbuffer cgetexpr cgetfile chdir checkpath checktime clist clast close cmap cmapclear cmenu cnext cnewer cnfile cnoremap cnoreabbrev cnoremenu copy colder colorscheme command comclear compiler continue confirm copen cprevious cpfile cquit crewind cscope cstag cunmap cunabbrev cunmenu cwindow delete delmarks debug debuggreedy delcommand delfunction diffupdate diffget diffoff diffpatch diffput diffsplit digraphs display deletel djump dlist doautocmd doautoall deletep drop dsearch dsplit edit earlier echo echoerr echohl echomsg else elseif emenu endif endfor endfunction endtry endwhile enew execute exit exusage file filetype find finally finish first fixdel fold foldclose folddoopen folddoclosed foldopen function global goto grep grepadd gui gvim hardcopy help helpfind helpgrep helptags highlight hide history insert iabbrev iabclear ijump ilist imap imapclear imenu inoremap inoreabbrev inoremenu intro isearch isplit iunmap iunabbrev iunmenu join jumps keepalt keepmarks keepjumps lNext lNfile list laddexpr laddbuffer laddfile last language later lbuffer lcd lchdir lclose lcscope left leftabove lexpr lfile lfirst lgetbuffer lgetexpr lgetfile lgrep lgrepadd lhelpgrep llast llist lmake lmap lmapclear lnext lnewer lnfile lnoremap loadkeymap loadview lockmarks lockvar lolder lopen lprevious lpfile lrewind ltag lunmap luado luafile lvimgrep lvimgrepadd lwindow move mark make mapclear match menu menutranslate messages mkexrc mksession mkspell mkvimrc mkview mode mzscheme mzfile nbclose nbkey nbsart next nmap nmapclear nmenu nnoremap nnoremenu noautocmd noremap nohlsearch noreabbrev noremenu normal number nunmap nunmenu oldfiles open omap omapclear omenu only onoremap onoremenu options ounmap ounmenu ownsyntax print profdel profile promptfind promptrepl pclose pedit perl perldo pop popup ppop preserve previous psearch ptag ptNext ptfirst ptjump ptlast ptnext ptprevious ptrewind ptselect put pwd py3do py3file python pydo pyfile quit quitall qall read recover redo redir redraw redrawstatus registers resize retab return rewind right rightbelow ruby rubydo rubyfile rundo runtime rviminfo substitute sNext sandbox sargument sall saveas sbuffer sbNext sball sbfirst sblast sbmodified sbnext sbprevious sbrewind scriptnames scriptencoding scscope set setfiletype setglobal setlocal sfind sfirst shell simalt sign silent sleep slast smagic smapclear smenu snext sniff snomagic snoremap snoremenu sort source spelldump spellgood spellinfo spellrepall spellundo spellwrong split sprevious srewind stop stag startgreplace startreplace startinsert stopinsert stjump stselect sunhide sunmap sunmenu suspend sview swapname syntax syntime syncbind tNext tabNext tabclose tabedit tabfind tabfirst tablast tabmove tabnext tabonly tabprevious tabrewind tag tcl tcldo tclfile tearoff tfirst throw tjump tlast tmenu tnext topleft tprevious trewind tselect tunmenu undo undojoin undolist unabbreviate unhide unlet unlockvar unmap unmenu unsilent update vglobal version verbose vertical vimgrep vimgrepadd visual viusage view vmap vmapclear vmenu vnew vnoremap vnoremenu vsplit vunmap vunmenu write wNext wall while winsize wincmd winpos wnext wprevious wqall wsverb wundo wviminfo xit xall xmapclear xmap xmenu xnoremap xnoremenu xunmap xunmenu yank",built_in:"synIDtrans atan2 range matcharg did_filetype asin feedkeys xor argv complete_check add getwinposx getqflist getwinposy screencol clearmatches empty extend getcmdpos mzeval garbagecollect setreg ceil sqrt diff_hlID inputsecret get getfperm getpid filewritable shiftwidth max sinh isdirectory synID system inputrestore winline atan visualmode inputlist tabpagewinnr round getregtype mapcheck hasmapto histdel argidx findfile sha256 exists toupper getcmdline taglist string getmatches bufnr strftime winwidth bufexists strtrans tabpagebuflist setcmdpos remote_read printf setloclist getpos getline bufwinnr float2nr len getcmdtype diff_filler luaeval resolve libcallnr foldclosedend reverse filter has_key bufname str2float strlen setline getcharmod setbufvar index searchpos shellescape undofile foldclosed setqflist buflisted strchars str2nr virtcol floor remove undotree remote_expr winheight gettabwinvar reltime cursor tabpagenr finddir localtime acos getloclist search tanh matchend rename gettabvar strdisplaywidth type abs py3eval setwinvar tolower wildmenumode log10 spellsuggest bufloaded synconcealed nextnonblank server2client complete settabwinvar executable input wincol setmatches getftype hlID inputsave searchpair or screenrow line settabvar histadd deepcopy strpart remote_peek and eval getftime submatch screenchar winsaveview matchadd mkdir screenattr getfontname libcall reltimestr getfsize winnr invert pow getbufline byte2line soundfold repeat fnameescape tagfiles sin strwidth spellbadword trunc maparg log lispindent hostname setpos globpath remote_foreground getchar synIDattr fnamemodify cscope_connection stridx winbufnr indent min complete_add nr2char searchpairpos inputdialog values matchlist items hlexists strridx browsedir expand fmod pathshorten line2byte argc count getwinvar glob foldtextresult getreg foreground cosh matchdelete has char2nr simplify histget searchdecl iconv winrestcmd pumvisible writefile foldlevel haslocaldir keys cos matchstr foldtext histnr tan tempname getcwd byteidx getbufvar islocked escape eventhandler remote_send serverlist winrestview synstack pyeval prevnonblank readfile cindent filereadable changenr exp"},illegal:/;/,contains:[e.NUMBER_MODE,{className:"string",begin:"'",end:"'",illegal:"\\n"},{className:"string",begin:/"(\\"|\n\\|[^"\n])*"/},e.COMMENT('"',"$"),{className:"variable",begin:/[bwtglsav]:[\w\d_]*/},{className:"function",beginKeywords:"function function!",end:"$",relevance:0,contains:[e.TITLE_MODE,{className:"params",begin:"\\(",end:"\\)"}]},{className:"symbol",begin:/<[\w-]+>/}]}},ml=function(e){return{name:"Intel x86 Assembly",case_insensitive:!0,lexemes:"[.%]?"+e.IDENT_RE,keywords:{ +keyword:"lock rep repe repz repne repnz xaquire xrelease bnd nobnd aaa aad aam aas adc add and arpl bb0_reset bb1_reset bound bsf bsr bswap bt btc btr bts call cbw cdq cdqe clc cld cli clts cmc cmp cmpsb cmpsd cmpsq cmpsw cmpxchg cmpxchg486 cmpxchg8b cmpxchg16b cpuid cpu_read cpu_write cqo cwd cwde daa das dec div dmint emms enter equ f2xm1 fabs fadd faddp fbld fbstp fchs fclex fcmovb fcmovbe fcmove fcmovnb fcmovnbe fcmovne fcmovnu fcmovu fcom fcomi fcomip fcomp fcompp fcos fdecstp fdisi fdiv fdivp fdivr fdivrp femms feni ffree ffreep fiadd ficom ficomp fidiv fidivr fild fimul fincstp finit fist fistp fisttp fisub fisubr fld fld1 fldcw fldenv fldl2e fldl2t fldlg2 fldln2 fldpi fldz fmul fmulp fnclex fndisi fneni fninit fnop fnsave fnstcw fnstenv fnstsw fpatan fprem fprem1 fptan frndint frstor fsave fscale fsetpm fsin fsincos fsqrt fst fstcw fstenv fstp fstsw fsub fsubp fsubr fsubrp ftst fucom fucomi fucomip fucomp fucompp fxam fxch fxtract fyl2x fyl2xp1 hlt ibts icebp idiv imul in inc incbin insb insd insw int int01 int1 int03 int3 into invd invpcid invlpg invlpga iret iretd iretq iretw jcxz jecxz jrcxz jmp jmpe lahf lar lds lea leave les lfence lfs lgdt lgs lidt lldt lmsw loadall loadall286 lodsb lodsd lodsq lodsw loop loope loopne loopnz loopz lsl lss ltr mfence monitor mov movd movq movsb movsd movsq movsw movsx movsxd movzx mul mwait neg nop not or out outsb outsd outsw packssdw packsswb packuswb paddb paddd paddsb paddsiw paddsw paddusb paddusw paddw pand pandn pause paveb pavgusb pcmpeqb pcmpeqd pcmpeqw pcmpgtb pcmpgtd pcmpgtw pdistib pf2id pfacc pfadd pfcmpeq pfcmpge pfcmpgt pfmax pfmin pfmul pfrcp pfrcpit1 pfrcpit2 pfrsqit1 pfrsqrt pfsub pfsubr pi2fd pmachriw pmaddwd pmagw pmulhriw pmulhrwa pmulhrwc pmulhw pmullw pmvgezb pmvlzb pmvnzb pmvzb pop popa popad popaw popf popfd popfq popfw por prefetch prefetchw pslld psllq psllw psrad psraw psrld psrlq psrlw psubb psubd psubsb psubsiw psubsw psubusb psubusw psubw punpckhbw punpckhdq punpckhwd punpcklbw punpckldq punpcklwd push pusha pushad pushaw pushf pushfd pushfq pushfw pxor rcl rcr rdshr rdmsr rdpmc rdtsc rdtscp ret retf retn rol ror rdm rsdc rsldt rsm rsts sahf sal salc sar sbb scasb scasd scasq scasw sfence sgdt shl shld shr shrd sidt sldt skinit smi smint smintold smsw stc std sti stosb stosd stosq stosw str sub svdc svldt svts swapgs syscall sysenter sysexit sysret test ud0 ud1 ud2b ud2 ud2a umov verr verw fwait wbinvd wrshr wrmsr xadd xbts xchg xlatb xlat xor cmove cmovz cmovne cmovnz cmova cmovnbe cmovae cmovnb cmovb cmovnae cmovbe cmovna cmovg cmovnle cmovge cmovnl cmovl cmovnge cmovle cmovng cmovc cmovnc cmovo cmovno cmovs cmovns cmovp cmovpe cmovnp cmovpo je jz jne jnz ja jnbe jae jnb jb jnae jbe jna jg jnle jge jnl jl jnge jle jng jc jnc jo jno js jns jpo jnp jpe jp sete setz setne setnz seta setnbe setae setnb setnc setb setnae setcset setbe setna setg setnle setge setnl setl setnge setle setng sets setns seto setno setpe setp setpo setnp addps addss andnps andps cmpeqps cmpeqss cmpleps cmpless cmpltps cmpltss cmpneqps cmpneqss cmpnleps cmpnless cmpnltps cmpnltss cmpordps cmpordss cmpunordps cmpunordss cmpps cmpss comiss cvtpi2ps cvtps2pi cvtsi2ss cvtss2si cvttps2pi cvttss2si divps divss ldmxcsr maxps maxss minps minss movaps movhps movlhps movlps movhlps movmskps movntps movss movups mulps mulss orps rcpps rcpss rsqrtps rsqrtss shufps sqrtps sqrtss stmxcsr subps subss ucomiss unpckhps unpcklps xorps fxrstor fxrstor64 fxsave fxsave64 xgetbv xsetbv xsave xsave64 xsaveopt xsaveopt64 xrstor xrstor64 prefetchnta prefetcht0 prefetcht1 prefetcht2 maskmovq movntq pavgb pavgw pextrw pinsrw pmaxsw pmaxub pminsw pminub pmovmskb pmulhuw psadbw pshufw pf2iw pfnacc pfpnacc pi2fw pswapd maskmovdqu clflush movntdq movnti movntpd movdqa movdqu movdq2q movq2dq paddq pmuludq pshufd pshufhw pshuflw pslldq psrldq psubq punpckhqdq punpcklqdq addpd addsd andnpd andpd cmpeqpd cmpeqsd cmplepd cmplesd cmpltpd cmpltsd cmpneqpd cmpneqsd cmpnlepd cmpnlesd cmpnltpd cmpnltsd cmpordpd cmpordsd cmpunordpd cmpunordsd cmppd comisd cvtdq2pd cvtdq2ps cvtpd2dq cvtpd2pi cvtpd2ps cvtpi2pd cvtps2dq cvtps2pd cvtsd2si cvtsd2ss cvtsi2sd cvtss2sd cvttpd2pi cvttpd2dq cvttps2dq cvttsd2si divpd divsd maxpd maxsd minpd minsd movapd movhpd movlpd movmskpd movupd mulpd mulsd orpd shufpd sqrtpd sqrtsd subpd subsd ucomisd unpckhpd unpcklpd xorpd addsubpd addsubps haddpd haddps hsubpd hsubps lddqu movddup movshdup movsldup clgi stgi vmcall vmclear vmfunc vmlaunch vmload vmmcall vmptrld vmptrst vmread vmresume vmrun vmsave vmwrite vmxoff vmxon invept invvpid pabsb pabsw pabsd palignr phaddw phaddd phaddsw phsubw phsubd phsubsw pmaddubsw pmulhrsw pshufb psignb psignw psignd extrq insertq movntsd movntss lzcnt blendpd blendps blendvpd blendvps dppd dpps extractps insertps movntdqa mpsadbw packusdw pblendvb pblendw pcmpeqq pextrb pextrd pextrq phminposuw pinsrb pinsrd pinsrq pmaxsb pmaxsd pmaxud pmaxuw pminsb pminsd pminud pminuw pmovsxbw pmovsxbd pmovsxbq pmovsxwd pmovsxwq pmovsxdq pmovzxbw pmovzxbd pmovzxbq pmovzxwd pmovzxwq pmovzxdq pmuldq pmulld ptest roundpd roundps roundsd roundss crc32 pcmpestri pcmpestrm pcmpistri pcmpistrm pcmpgtq popcnt getsec pfrcpv pfrsqrtv movbe aesenc aesenclast aesdec aesdeclast aesimc aeskeygenassist vaesenc vaesenclast vaesdec vaesdeclast vaesimc vaeskeygenassist vaddpd vaddps vaddsd vaddss vaddsubpd vaddsubps vandpd vandps vandnpd vandnps vblendpd vblendps vblendvpd vblendvps vbroadcastss vbroadcastsd vbroadcastf128 vcmpeq_ospd vcmpeqpd vcmplt_ospd vcmpltpd vcmple_ospd vcmplepd vcmpunord_qpd vcmpunordpd vcmpneq_uqpd vcmpneqpd vcmpnlt_uspd vcmpnltpd vcmpnle_uspd vcmpnlepd vcmpord_qpd vcmpordpd vcmpeq_uqpd vcmpnge_uspd vcmpngepd vcmpngt_uspd vcmpngtpd vcmpfalse_oqpd vcmpfalsepd vcmpneq_oqpd vcmpge_ospd vcmpgepd vcmpgt_ospd vcmpgtpd vcmptrue_uqpd vcmptruepd vcmplt_oqpd vcmple_oqpd vcmpunord_spd vcmpneq_uspd vcmpnlt_uqpd vcmpnle_uqpd vcmpord_spd vcmpeq_uspd vcmpnge_uqpd vcmpngt_uqpd vcmpfalse_ospd vcmpneq_ospd vcmpge_oqpd vcmpgt_oqpd vcmptrue_uspd vcmppd vcmpeq_osps vcmpeqps vcmplt_osps vcmpltps vcmple_osps vcmpleps vcmpunord_qps vcmpunordps vcmpneq_uqps vcmpneqps vcmpnlt_usps vcmpnltps vcmpnle_usps vcmpnleps vcmpord_qps vcmpordps vcmpeq_uqps vcmpnge_usps vcmpngeps vcmpngt_usps vcmpngtps vcmpfalse_oqps vcmpfalseps vcmpneq_oqps vcmpge_osps vcmpgeps vcmpgt_osps vcmpgtps vcmptrue_uqps vcmptrueps vcmplt_oqps vcmple_oqps vcmpunord_sps vcmpneq_usps vcmpnlt_uqps vcmpnle_uqps vcmpord_sps vcmpeq_usps vcmpnge_uqps vcmpngt_uqps vcmpfalse_osps vcmpneq_osps vcmpge_oqps vcmpgt_oqps vcmptrue_usps vcmpps vcmpeq_ossd vcmpeqsd vcmplt_ossd vcmpltsd vcmple_ossd vcmplesd vcmpunord_qsd vcmpunordsd vcmpneq_uqsd vcmpneqsd vcmpnlt_ussd vcmpnltsd vcmpnle_ussd vcmpnlesd vcmpord_qsd vcmpordsd vcmpeq_uqsd vcmpnge_ussd vcmpngesd vcmpngt_ussd vcmpngtsd vcmpfalse_oqsd vcmpfalsesd vcmpneq_oqsd vcmpge_ossd vcmpgesd vcmpgt_ossd vcmpgtsd vcmptrue_uqsd vcmptruesd vcmplt_oqsd vcmple_oqsd vcmpunord_ssd vcmpneq_ussd vcmpnlt_uqsd vcmpnle_uqsd vcmpord_ssd vcmpeq_ussd vcmpnge_uqsd vcmpngt_uqsd vcmpfalse_ossd vcmpneq_ossd vcmpge_oqsd vcmpgt_oqsd vcmptrue_ussd vcmpsd vcmpeq_osss vcmpeqss vcmplt_osss vcmpltss vcmple_osss vcmpless vcmpunord_qss vcmpunordss vcmpneq_uqss vcmpneqss vcmpnlt_usss vcmpnltss vcmpnle_usss vcmpnless vcmpord_qss vcmpordss vcmpeq_uqss vcmpnge_usss vcmpngess vcmpngt_usss vcmpngtss vcmpfalse_oqss vcmpfalsess vcmpneq_oqss vcmpge_osss vcmpgess vcmpgt_osss vcmpgtss vcmptrue_uqss vcmptruess vcmplt_oqss vcmple_oqss vcmpunord_sss vcmpneq_usss vcmpnlt_uqss vcmpnle_uqss vcmpord_sss vcmpeq_usss vcmpnge_uqss vcmpngt_uqss vcmpfalse_osss vcmpneq_osss vcmpge_oqss vcmpgt_oqss vcmptrue_usss vcmpss vcomisd vcomiss vcvtdq2pd vcvtdq2ps vcvtpd2dq vcvtpd2ps vcvtps2dq vcvtps2pd vcvtsd2si vcvtsd2ss vcvtsi2sd vcvtsi2ss vcvtss2sd vcvtss2si vcvttpd2dq vcvttps2dq vcvttsd2si vcvttss2si vdivpd vdivps vdivsd vdivss vdppd vdpps vextractf128 vextractps vhaddpd vhaddps vhsubpd vhsubps vinsertf128 vinsertps vlddqu vldqqu vldmxcsr vmaskmovdqu vmaskmovps vmaskmovpd vmaxpd vmaxps vmaxsd vmaxss vminpd vminps vminsd vminss vmovapd vmovaps vmovd vmovq vmovddup vmovdqa vmovqqa vmovdqu vmovqqu vmovhlps vmovhpd vmovhps vmovlhps vmovlpd vmovlps vmovmskpd vmovmskps vmovntdq vmovntqq vmovntdqa vmovntpd vmovntps vmovsd vmovshdup vmovsldup vmovss vmovupd vmovups vmpsadbw vmulpd vmulps vmulsd vmulss vorpd vorps vpabsb vpabsw vpabsd vpacksswb vpackssdw vpackuswb vpackusdw vpaddb vpaddw vpaddd vpaddq vpaddsb vpaddsw vpaddusb vpaddusw vpalignr vpand vpandn vpavgb vpavgw vpblendvb vpblendw vpcmpestri vpcmpestrm vpcmpistri vpcmpistrm vpcmpeqb vpcmpeqw vpcmpeqd vpcmpeqq vpcmpgtb vpcmpgtw vpcmpgtd vpcmpgtq vpermilpd vpermilps vperm2f128 vpextrb vpextrw vpextrd vpextrq vphaddw vphaddd vphaddsw vphminposuw vphsubw vphsubd vphsubsw vpinsrb vpinsrw vpinsrd vpinsrq vpmaddwd vpmaddubsw vpmaxsb vpmaxsw vpmaxsd vpmaxub vpmaxuw vpmaxud vpminsb vpminsw vpminsd vpminub vpminuw vpminud vpmovmskb vpmovsxbw vpmovsxbd vpmovsxbq vpmovsxwd vpmovsxwq vpmovsxdq vpmovzxbw vpmovzxbd vpmovzxbq vpmovzxwd vpmovzxwq vpmovzxdq vpmulhuw vpmulhrsw vpmulhw vpmullw vpmulld vpmuludq vpmuldq vpor vpsadbw vpshufb vpshufd vpshufhw vpshuflw vpsignb vpsignw vpsignd vpslldq vpsrldq vpsllw vpslld vpsllq vpsraw vpsrad vpsrlw vpsrld vpsrlq vptest vpsubb vpsubw vpsubd vpsubq vpsubsb vpsubsw vpsubusb vpsubusw vpunpckhbw vpunpckhwd vpunpckhdq vpunpckhqdq vpunpcklbw vpunpcklwd vpunpckldq vpunpcklqdq vpxor vrcpps vrcpss vrsqrtps vrsqrtss vroundpd vroundps vroundsd vroundss vshufpd vshufps vsqrtpd vsqrtps vsqrtsd vsqrtss vstmxcsr vsubpd vsubps vsubsd vsubss vtestps vtestpd vucomisd vucomiss vunpckhpd vunpckhps vunpcklpd vunpcklps vxorpd vxorps vzeroall vzeroupper pclmullqlqdq pclmulhqlqdq pclmullqhqdq pclmulhqhqdq pclmulqdq vpclmullqlqdq vpclmulhqlqdq vpclmullqhqdq vpclmulhqhqdq vpclmulqdq vfmadd132ps vfmadd132pd vfmadd312ps vfmadd312pd vfmadd213ps vfmadd213pd vfmadd123ps vfmadd123pd vfmadd231ps vfmadd231pd vfmadd321ps vfmadd321pd vfmaddsub132ps vfmaddsub132pd vfmaddsub312ps vfmaddsub312pd vfmaddsub213ps vfmaddsub213pd vfmaddsub123ps vfmaddsub123pd vfmaddsub231ps vfmaddsub231pd vfmaddsub321ps vfmaddsub321pd vfmsub132ps vfmsub132pd vfmsub312ps vfmsub312pd vfmsub213ps vfmsub213pd vfmsub123ps vfmsub123pd vfmsub231ps vfmsub231pd vfmsub321ps vfmsub321pd vfmsubadd132ps vfmsubadd132pd vfmsubadd312ps vfmsubadd312pd vfmsubadd213ps vfmsubadd213pd vfmsubadd123ps vfmsubadd123pd vfmsubadd231ps vfmsubadd231pd vfmsubadd321ps vfmsubadd321pd vfnmadd132ps vfnmadd132pd vfnmadd312ps vfnmadd312pd vfnmadd213ps vfnmadd213pd vfnmadd123ps vfnmadd123pd vfnmadd231ps vfnmadd231pd vfnmadd321ps vfnmadd321pd vfnmsub132ps vfnmsub132pd vfnmsub312ps vfnmsub312pd vfnmsub213ps vfnmsub213pd vfnmsub123ps vfnmsub123pd vfnmsub231ps vfnmsub231pd vfnmsub321ps vfnmsub321pd vfmadd132ss vfmadd132sd vfmadd312ss vfmadd312sd vfmadd213ss vfmadd213sd vfmadd123ss vfmadd123sd vfmadd231ss vfmadd231sd vfmadd321ss vfmadd321sd vfmsub132ss vfmsub132sd vfmsub312ss vfmsub312sd vfmsub213ss vfmsub213sd vfmsub123ss vfmsub123sd vfmsub231ss vfmsub231sd vfmsub321ss vfmsub321sd vfnmadd132ss vfnmadd132sd vfnmadd312ss vfnmadd312sd vfnmadd213ss vfnmadd213sd vfnmadd123ss vfnmadd123sd vfnmadd231ss vfnmadd231sd vfnmadd321ss vfnmadd321sd vfnmsub132ss vfnmsub132sd vfnmsub312ss vfnmsub312sd vfnmsub213ss vfnmsub213sd vfnmsub123ss vfnmsub123sd vfnmsub231ss vfnmsub231sd vfnmsub321ss vfnmsub321sd rdfsbase rdgsbase rdrand wrfsbase wrgsbase vcvtph2ps vcvtps2ph adcx adox rdseed clac stac xstore xcryptecb xcryptcbc xcryptctr xcryptcfb xcryptofb montmul xsha1 xsha256 llwpcb slwpcb lwpval lwpins vfmaddpd vfmaddps vfmaddsd vfmaddss vfmaddsubpd vfmaddsubps vfmsubaddpd vfmsubaddps vfmsubpd vfmsubps vfmsubsd vfmsubss vfnmaddpd vfnmaddps vfnmaddsd vfnmaddss vfnmsubpd vfnmsubps vfnmsubsd vfnmsubss vfrczpd vfrczps vfrczsd vfrczss vpcmov vpcomb vpcomd vpcomq vpcomub vpcomud vpcomuq vpcomuw vpcomw vphaddbd vphaddbq vphaddbw vphadddq vphaddubd vphaddubq vphaddubw vphaddudq vphadduwd vphadduwq vphaddwd vphaddwq vphsubbw vphsubdq vphsubwd vpmacsdd vpmacsdqh vpmacsdql vpmacssdd vpmacssdqh vpmacssdql vpmacsswd vpmacssww vpmacswd vpmacsww vpmadcsswd vpmadcswd vpperm vprotb vprotd vprotq vprotw vpshab vpshad vpshaq vpshaw vpshlb vpshld vpshlq vpshlw vbroadcasti128 vpblendd vpbroadcastb vpbroadcastw vpbroadcastd vpbroadcastq vpermd vpermpd vpermps vpermq vperm2i128 vextracti128 vinserti128 vpmaskmovd vpmaskmovq vpsllvd vpsllvq vpsravd vpsrlvd vpsrlvq vgatherdpd vgatherqpd vgatherdps vgatherqps vpgatherdd vpgatherqd vpgatherdq vpgatherqq xabort xbegin xend xtest andn bextr blci blcic blsi blsic blcfill blsfill blcmsk blsmsk blsr blcs bzhi mulx pdep pext rorx sarx shlx shrx tzcnt tzmsk t1mskc valignd valignq vblendmpd vblendmps vbroadcastf32x4 vbroadcastf64x4 vbroadcasti32x4 vbroadcasti64x4 vcompresspd vcompressps vcvtpd2udq vcvtps2udq vcvtsd2usi vcvtss2usi vcvttpd2udq vcvttps2udq vcvttsd2usi vcvttss2usi vcvtudq2pd vcvtudq2ps vcvtusi2sd vcvtusi2ss vexpandpd vexpandps vextractf32x4 vextractf64x4 vextracti32x4 vextracti64x4 vfixupimmpd vfixupimmps vfixupimmsd vfixupimmss vgetexppd vgetexpps vgetexpsd vgetexpss vgetmantpd vgetmantps vgetmantsd vgetmantss vinsertf32x4 vinsertf64x4 vinserti32x4 vinserti64x4 vmovdqa32 vmovdqa64 vmovdqu32 vmovdqu64 vpabsq vpandd vpandnd vpandnq vpandq vpblendmd vpblendmq vpcmpltd vpcmpled vpcmpneqd vpcmpnltd vpcmpnled vpcmpd vpcmpltq vpcmpleq vpcmpneqq vpcmpnltq vpcmpnleq vpcmpq vpcmpequd vpcmpltud vpcmpleud vpcmpnequd vpcmpnltud vpcmpnleud vpcmpud vpcmpequq vpcmpltuq vpcmpleuq vpcmpnequq vpcmpnltuq vpcmpnleuq vpcmpuq vpcompressd vpcompressq vpermi2d vpermi2pd vpermi2ps vpermi2q vpermt2d vpermt2pd vpermt2ps vpermt2q vpexpandd vpexpandq vpmaxsq vpmaxuq vpminsq vpminuq vpmovdb vpmovdw vpmovqb vpmovqd vpmovqw vpmovsdb vpmovsdw vpmovsqb vpmovsqd vpmovsqw vpmovusdb vpmovusdw vpmovusqb vpmovusqd vpmovusqw vpord vporq vprold vprolq vprolvd vprolvq vprord vprorq vprorvd vprorvq vpscatterdd vpscatterdq vpscatterqd vpscatterqq vpsraq vpsravq vpternlogd vpternlogq vptestmd vptestmq vptestnmd vptestnmq vpxord vpxorq vrcp14pd vrcp14ps vrcp14sd vrcp14ss vrndscalepd vrndscaleps vrndscalesd vrndscaless vrsqrt14pd vrsqrt14ps vrsqrt14sd vrsqrt14ss vscalefpd vscalefps vscalefsd vscalefss vscatterdpd vscatterdps vscatterqpd vscatterqps vshuff32x4 vshuff64x2 vshufi32x4 vshufi64x2 kandnw kandw kmovw knotw kortestw korw kshiftlw kshiftrw kunpckbw kxnorw kxorw vpbroadcastmb2q vpbroadcastmw2d vpconflictd vpconflictq vplzcntd vplzcntq vexp2pd vexp2ps vrcp28pd vrcp28ps vrcp28sd vrcp28ss vrsqrt28pd vrsqrt28ps vrsqrt28sd vrsqrt28ss vgatherpf0dpd vgatherpf0dps vgatherpf0qpd vgatherpf0qps vgatherpf1dpd vgatherpf1dps vgatherpf1qpd vgatherpf1qps vscatterpf0dpd vscatterpf0dps vscatterpf0qpd vscatterpf0qps vscatterpf1dpd vscatterpf1dps vscatterpf1qpd vscatterpf1qps prefetchwt1 bndmk bndcl bndcu bndcn bndmov bndldx bndstx sha1rnds4 sha1nexte sha1msg1 sha1msg2 sha256rnds2 sha256msg1 sha256msg2 hint_nop0 hint_nop1 hint_nop2 hint_nop3 hint_nop4 hint_nop5 hint_nop6 hint_nop7 hint_nop8 hint_nop9 hint_nop10 hint_nop11 hint_nop12 hint_nop13 hint_nop14 hint_nop15 hint_nop16 hint_nop17 hint_nop18 hint_nop19 hint_nop20 hint_nop21 hint_nop22 hint_nop23 hint_nop24 hint_nop25 hint_nop26 hint_nop27 hint_nop28 hint_nop29 hint_nop30 hint_nop31 hint_nop32 hint_nop33 hint_nop34 hint_nop35 hint_nop36 hint_nop37 hint_nop38 hint_nop39 hint_nop40 hint_nop41 hint_nop42 hint_nop43 hint_nop44 hint_nop45 hint_nop46 hint_nop47 hint_nop48 hint_nop49 hint_nop50 hint_nop51 hint_nop52 hint_nop53 hint_nop54 hint_nop55 hint_nop56 hint_nop57 hint_nop58 hint_nop59 hint_nop60 hint_nop61 hint_nop62 hint_nop63",built_in:"ip eip rip al ah bl bh cl ch dl dh sil dil bpl spl r8b r9b r10b r11b r12b r13b r14b r15b ax bx cx dx si di bp sp r8w r9w r10w r11w r12w r13w r14w r15w eax ebx ecx edx esi edi ebp esp eip r8d r9d r10d r11d r12d r13d r14d r15d rax rbx rcx rdx rsi rdi rbp rsp r8 r9 r10 r11 r12 r13 r14 r15 cs ds es fs gs ss st st0 st1 st2 st3 st4 st5 st6 st7 mm0 mm1 mm2 mm3 mm4 mm5 mm6 mm7 xmm0 xmm1 xmm2 xmm3 xmm4 xmm5 xmm6 xmm7 xmm8 xmm9 xmm10 xmm11 xmm12 xmm13 xmm14 xmm15 xmm16 xmm17 xmm18 xmm19 xmm20 xmm21 xmm22 xmm23 xmm24 xmm25 xmm26 xmm27 xmm28 xmm29 xmm30 xmm31 ymm0 ymm1 ymm2 ymm3 ymm4 ymm5 ymm6 ymm7 ymm8 ymm9 ymm10 ymm11 ymm12 ymm13 ymm14 ymm15 ymm16 ymm17 ymm18 ymm19 ymm20 ymm21 ymm22 ymm23 ymm24 ymm25 ymm26 ymm27 ymm28 ymm29 ymm30 ymm31 zmm0 zmm1 zmm2 zmm3 zmm4 zmm5 zmm6 zmm7 zmm8 zmm9 zmm10 zmm11 zmm12 zmm13 zmm14 zmm15 zmm16 zmm17 zmm18 zmm19 zmm20 zmm21 zmm22 zmm23 zmm24 zmm25 zmm26 zmm27 zmm28 zmm29 zmm30 zmm31 k0 k1 k2 k3 k4 k5 k6 k7 bnd0 bnd1 bnd2 bnd3 cr0 cr1 cr2 cr3 cr4 cr8 dr0 dr1 dr2 dr3 dr8 tr3 tr4 tr5 tr6 tr7 r0 r1 r2 r3 r4 r5 r6 r7 r0b r1b r2b r3b r4b r5b r6b r7b r0w r1w r2w r3w r4w r5w r6w r7w r0d r1d r2d r3d r4d r5d r6d r7d r0h r1h r2h r3h r0l r1l r2l r3l r4l r5l r6l r7l r8l r9l r10l r11l r12l r13l r14l r15l db dw dd dq dt ddq do dy dz resb resw resd resq rest resdq reso resy resz incbin equ times byte word dword qword nosplit rel abs seg wrt strict near far a32 ptr",meta:"%define %xdefine %+ %undef %defstr %deftok %assign %strcat %strlen %substr %rotate %elif %else %endif %if %ifmacro %ifctx %ifidn %ifidni %ifid %ifnum %ifstr %iftoken %ifempty %ifenv %error %warning %fatal %rep %endrep %include %push %pop %repl %pathsearch %depend %use %arg %stacksize %local %line %comment %endcomment .nolist __FILE__ __LINE__ __SECT__ __BITS__ __OUTPUT_FORMAT__ __DATE__ __TIME__ __DATE_NUM__ __TIME_NUM__ __UTC_DATE__ __UTC_TIME__ __UTC_DATE_NUM__ __UTC_TIME_NUM__ __PASS__ struc endstruc istruc at iend align alignb sectalign daz nodaz up down zero default option assume public bits use16 use32 use64 default section segment absolute extern global common cpu float __utf16__ __utf16le__ __utf16be__ __utf32__ __utf32le__ __utf32be__ __float8__ __float16__ __float32__ __float64__ __float80m__ __float80e__ __float128l__ __float128h__ __Infinity__ __QNaN__ __SNaN__ Inf NaN QNaN SNaN float8 float16 float32 float64 float80m float80e float128l float128h __FLOAT_DAZ__ __FLOAT_ROUND__ __FLOAT__"},contains:[e.COMMENT(";","$",{relevance:0}),{className:"number",variants:[{begin:"\\b(?:([0-9][0-9_]*)?\\.[0-9_]*(?:[eE][+-]?[0-9_]+)?|(0[Xx])?[0-9][0-9_]*\\.?[0-9_]*(?:[pP](?:[+-]?[0-9_]+)?)?)\\b",relevance:0},{begin:"\\$[0-9][0-9A-Fa-f]*",relevance:0},{begin:"\\b(?:[0-9A-Fa-f][0-9A-Fa-f_]*[Hh]|[0-9][0-9_]*[DdTt]?|[0-7][0-7_]*[QqOo]|[0-1][0-1_]*[BbYy])\\b"},{begin:"\\b(?:0[Xx][0-9A-Fa-f_]+|0[DdTt][0-9_]+|0[QqOo][0-7_]+|0[BbYy][0-1_]+)\\b"}]},e.QUOTE_STRING_MODE,{className:"string",variants:[{begin:"'",end:"[^\\\\]'"},{begin:"`",end:"[^\\\\]`"}],relevance:0},{className:"symbol",variants:[{begin:"^\\s*[A-Za-z._?][A-Za-z0-9_$#@~.?]*(:|\\s+label)"},{begin:"^\\s*%%[A-Za-z0-9_$#@~.?]*:"}],relevance:0},{className:"subst",begin:"%[0-9]+",relevance:0},{className:"subst",begin:"%!S+",relevance:0},{className:"meta",begin:/^\s*\.[\w_-]+/}]}},pl=function(e){var t={keyword:"if then else do while until for loop import with is as where when by data constant integer real text name boolean symbol infix prefix postfix block tree",literal:"true false nil",built_in:"in mod rem and or xor not abs sign floor ceil sqrt sin cos tan asin acos atan exp expm1 log log2 log10 log1p pi at text_length text_range text_find text_replace contains page slide basic_slide title_slide title subtitle fade_in fade_out fade_at clear_color color line_color line_width texture_wrap texture_transform texture scale_?x scale_?y scale_?z? translate_?x translate_?y translate_?z? rotate_?x rotate_?y rotate_?z? rectangle circle ellipse sphere path line_to move_to quad_to curve_to theme background contents locally time mouse_?x mouse_?y mouse_buttons ObjectLoader Animate MovieCredits Slides Filters Shading Materials LensFlare Mapping VLCAudioVideo StereoDecoder PointCloud NetworkAccess RemoteControl RegExp ChromaKey Snowfall NodeJS Speech Charts"},a={className:"string",begin:'"',end:'"',illegal:"\\n"},n={beginKeywords:"import",end:"$",keywords:t,contains:[a]},r={className:"function",begin:/[a-z][^\n]*->/,returnBegin:!0,end:/->/,contains:[e.inherit(e.TITLE_MODE,{starts:{endsWithParent:!0,keywords:t}})]};return{name:"XL",aliases:["tao"],lexemes:/[a-zA-Z][a-zA-Z0-9_?]*/,keywords:t,contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,a,{className:"string",begin:"'",end:"'",illegal:"\\n"},{className:"string",begin:"<<",end:">>"},r,n,{className:"number",begin:"[0-9]+#[0-9A-Z_]+(\\.[0-9-A-Z_]+)?#?([Ee][+-]?[0-9]+)?"},e.NUMBER_MODE]}},El=function(){return{name:"XQuery",aliases:["xpath","xq"],case_insensitive:!1,lexemes:/[a-zA-Z\$][a-zA-Z0-9_:\-]*/,illegal:/(proc)|(abstract)|(extends)|(until)|(#)/,keywords:{keyword:"module schema namespace boundary-space preserve no-preserve strip default collation base-uri ordering context decimal-format decimal-separator copy-namespaces empty-sequence except exponent-separator external grouping-separator inherit no-inherit lax minus-sign per-mille percent schema-attribute schema-element strict unordered zero-digit declare import option function validate variable for at in let where order group by return if then else tumbling sliding window start when only end previous next stable ascending descending allowing empty greatest least some every satisfies switch case typeswitch try catch and or to union intersect instance of treat as castable cast map array delete insert into replace value rename copy modify update",type:"item document-node node attribute document element comment namespace namespace-node processing-instruction text construction xs:anyAtomicType xs:untypedAtomic xs:duration xs:time xs:decimal xs:float xs:double xs:gYearMonth xs:gYear xs:gMonthDay xs:gMonth xs:gDay xs:boolean xs:base64Binary xs:hexBinary xs:anyURI xs:QName xs:NOTATION xs:dateTime xs:dateTimeStamp xs:date xs:string xs:normalizedString xs:token xs:language xs:NMTOKEN xs:Name xs:NCName xs:ID xs:IDREF xs:ENTITY xs:integer xs:nonPositiveInteger xs:negativeInteger xs:long xs:int xs:short xs:byte xs:nonNegativeInteger xs:unisignedLong xs:unsignedInt xs:unsignedShort xs:unsignedByte xs:positiveInteger xs:yearMonthDuration xs:dayTimeDuration",literal:"eq ne lt le gt ge is self:: child:: descendant:: descendant-or-self:: attribute:: following:: following-sibling:: parent:: ancestor:: ancestor-or-self:: preceding:: preceding-sibling:: NaN"},contains:[{className:"variable",begin:/[\$][\w-:]+/},{className:"built_in",variants:[{begin:/\barray\:/,end:/(?:append|filter|flatten|fold\-(?:left|right)|for-each(?:\-pair)?|get|head|insert\-before|join|put|remove|reverse|size|sort|subarray|tail)\b/},{begin:/\bmap\:/,end:/(?:contains|entry|find|for\-each|get|keys|merge|put|remove|size)\b/},{begin:/\bmath\:/,end:/(?:a(?:cos|sin|tan[2]?)|cos|exp(?:10)?|log(?:10)?|pi|pow|sin|sqrt|tan)\b/},{begin:/\bop\:/,end:/\(/,excludeEnd:!0},{begin:/\bfn\:/,end:/\(/,excludeEnd:!0},{begin:/[^<\/\$\:'"-]\b(?:abs|accumulator\-(?:after|before)|adjust\-(?:date(?:Time)?|time)\-to\-timezone|analyze\-string|apply|available\-(?:environment\-variables|system\-properties)|avg|base\-uri|boolean|ceiling|codepoints?\-(?:equal|to\-string)|collation\-key|collection|compare|concat|contains(?:\-token)?|copy\-of|count|current(?:\-)?(?:date(?:Time)?|time|group(?:ing\-key)?|output\-uri|merge\-(?:group|key))?data|dateTime|days?\-from\-(?:date(?:Time)?|duration)|deep\-equal|default\-(?:collation|language)|distinct\-values|document(?:\-uri)?|doc(?:\-available)?|element\-(?:available|with\-id)|empty|encode\-for\-uri|ends\-with|environment\-variable|error|escape\-html\-uri|exactly\-one|exists|false|filter|floor|fold\-(?:left|right)|for\-each(?:\-pair)?|format\-(?:date(?:Time)?|time|integer|number)|function\-(?:arity|available|lookup|name)|generate\-id|has\-children|head|hours\-from\-(?:dateTime|duration|time)|id(?:ref)?|implicit\-timezone|in\-scope\-prefixes|index\-of|innermost|insert\-before|iri\-to\-uri|json\-(?:doc|to\-xml)|key|lang|last|load\-xquery\-module|local\-name(?:\-from\-QName)?|(?:lower|upper)\-case|matches|max|minutes\-from\-(?:dateTime|duration|time)|min|months?\-from\-(?:date(?:Time)?|duration)|name(?:space\-uri\-?(?:for\-prefix|from\-QName)?)?|nilled|node\-name|normalize\-(?:space|unicode)|not|number|one\-or\-more|outermost|parse\-(?:ietf\-date|json)|path|position|(?:prefix\-from\-)?QName|random\-number\-generator|regex\-group|remove|replace|resolve\-(?:QName|uri)|reverse|root|round(?:\-half\-to\-even)?|seconds\-from\-(?:dateTime|duration|time)|snapshot|sort|starts\-with|static\-base\-uri|stream\-available|string\-?(?:join|length|to\-codepoints)?|subsequence|substring\-?(?:after|before)?|sum|system\-property|tail|timezone\-from\-(?:date(?:Time)?|time)|tokenize|trace|trans(?:form|late)|true|type\-available|unordered|unparsed\-(?:entity|text)?\-?(?:public\-id|uri|available|lines)?|uri\-collection|xml\-to\-json|years?\-from\-(?:date(?:Time)?|duration)|zero\-or\-one)\b/},{begin:/\blocal\:/,end:/\(/,excludeEnd:!0},{begin:/\bzip\:/,end:/(?:zip\-file|(?:xml|html|text|binary)\-entry| (?:update\-)?entries)\b/},{begin:/\b(?:util|db|functx|app|xdmp|xmldb)\:/,end:/\(/,excludeEnd:!0}]},{className:"string",variants:[{begin:/"/,end:/"/,contains:[{begin:/""/,relevance:0}]},{begin:/'/,end:/'/,contains:[{begin:/''/,relevance:0}]}]},{className:"number",begin:"(\\b0[0-7_]+)|(\\b0x[0-9a-fA-F_]+)|(\\b[1-9][0-9_]*(\\.[0-9_]+)?)|[0_]\\b",relevance:0},{className:"comment",begin:"\\(:",end:":\\)",relevance:10,contains:[{className:"doctag",begin:"@\\w+"}]},{className:"meta",begin:/%[\w-:]+/},{className:"title",begin:/\bxquery version "[13]\.[01]"\s?(?:encoding ".+")?/,end:/;/},{beginKeywords:"element attribute comment document processing-instruction",end:"{",excludeEnd:!0},{begin:/<([\w\._:\-]+)((\s*.*)=('|").*('|"))?>/,end:/(\/[\w\._:\-]+>)/,subLanguage:"xml",contains:[{begin:"{",end:"}",subLanguage:"xquery"},"self"]}]}},gl=function(e){var t={className:"string",contains:[e.BACKSLASH_ESCAPE],variants:[e.inherit(e.APOS_STRING_MODE,{illegal:null}),e.inherit(e.QUOTE_STRING_MODE,{illegal:null})]},a=e.UNDERSCORE_TITLE_MODE,n={variants:[e.BINARY_NUMBER_MODE,e.C_NUMBER_MODE]},r="namespace class interface use extends function return abstract final public protected private static deprecated throw try catch Exception echo empty isset instanceof unset let var new const self require if else elseif switch case default do while loop for continue break likely unlikely __LINE__ __FILE__ __DIR__ __FUNCTION__ __CLASS__ __TRAIT__ __METHOD__ __NAMESPACE__ array boolean float double integer object resource string char long unsigned bool int uint ulong uchar true false null undefined";return{name:"Zephir",aliases:["zep"],keywords:r,contains:[e.C_LINE_COMMENT_MODE,e.COMMENT("/\\*","\\*/",{contains:[{className:"doctag",begin:"@[A-Za-z]+"}]}),{className:"string",begin:"<<<['\"]?\\w+['\"]?$",end:"^\\w+;",contains:[e.BACKSLASH_ESCAPE]},{begin:/(::|->)+[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*/},{className:"function",beginKeywords:"function fn",end:/[;{]/,excludeEnd:!0,illegal:"\\$|\\[|%",contains:[a,{className:"params",begin:"\\(",end:"\\)",keywords:r,contains:["self",e.C_BLOCK_COMMENT_MODE,t,n]}]},{className:"class",beginKeywords:"class interface",end:"{",excludeEnd:!0,illegal:/[:\(\$"]/,contains:[{beginKeywords:"extends implements"},a]},{beginKeywords:"namespace",end:";",illegal:/[\.']/,contains:[a]},{beginKeywords:"use",end:";",contains:[a]},{begin:"=>"},t,n]}};Hr.registerLanguage("1c",Vr),Hr.registerLanguage("abnf",qr),Hr.registerLanguage("accesslog",zr),Hr.registerLanguage("actionscript",Wr),Hr.registerLanguage("ada",Qr),Hr.registerLanguage("angelscript",$r),Hr.registerLanguage("apache",Kr),Hr.registerLanguage("applescript",jr),Hr.registerLanguage("arcade",Xr),Hr.registerLanguage("c-like",Zr),Hr.registerLanguage("cpp",Jr),Hr.registerLanguage("arduino",ei),Hr.registerLanguage("armasm",ti),Hr.registerLanguage("xml",ai),Hr.registerLanguage("asciidoc",ni),Hr.registerLanguage("aspectj",ri),Hr.registerLanguage("autohotkey",ii),Hr.registerLanguage("autoit",oi),Hr.registerLanguage("avrasm",si),Hr.registerLanguage("awk",li),Hr.registerLanguage("axapta",_i),Hr.registerLanguage("bash",ci),Hr.registerLanguage("basic",di),Hr.registerLanguage("bnf",ui),Hr.registerLanguage("brainfuck",mi),Hr.registerLanguage("c",pi),Hr.registerLanguage("cal",Ei),Hr.registerLanguage("capnproto",gi),Hr.registerLanguage("ceylon",Si),Hr.registerLanguage("clean",Ti),Hr.registerLanguage("clojure",bi),Hr.registerLanguage("clojure-repl",fi),Hr.registerLanguage("cmake",Ci),Hr.registerLanguage("coffeescript",Ri),Hr.registerLanguage("coq",Ni),Hr.registerLanguage("cos",Oi),Hr.registerLanguage("crmsh",vi),Hr.registerLanguage("crystal",Ii),Hr.registerLanguage("csharp",hi),Hr.registerLanguage("csp",Ai),Hr.registerLanguage("css",yi),Hr.registerLanguage("d",Di),Hr.registerLanguage("markdown",Mi),Hr.registerLanguage("dart",Li),Hr.registerLanguage("delphi",xi),Hr.registerLanguage("diff",wi),Hr.registerLanguage("django",Pi),Hr.registerLanguage("dns",ki),Hr.registerLanguage("dockerfile",Ui),Hr.registerLanguage("dos",Fi),Hr.registerLanguage("dsconfig",Bi),Hr.registerLanguage("dts",Gi),Hr.registerLanguage("dust",Yi),Hr.registerLanguage("ebnf",Hi),Hr.registerLanguage("elixir",Vi),Hr.registerLanguage("elm",qi),Hr.registerLanguage("ruby",zi),Hr.registerLanguage("erb",Wi),Hr.registerLanguage("erlang-repl",Qi),Hr.registerLanguage("erlang",$i),Hr.registerLanguage("excel",Ki),Hr.registerLanguage("fix",ji),Hr.registerLanguage("flix",Xi),Hr.registerLanguage("fortran",Zi),Hr.registerLanguage("fsharp",Ji),Hr.registerLanguage("gams",eo),Hr.registerLanguage("gauss",to),Hr.registerLanguage("gcode",ao),Hr.registerLanguage("gherkin",no),Hr.registerLanguage("glsl",ro),Hr.registerLanguage("gml",io),Hr.registerLanguage("go",oo),Hr.registerLanguage("golo",so),Hr.registerLanguage("gradle",lo),Hr.registerLanguage("groovy",_o),Hr.registerLanguage("haml",co),Hr.registerLanguage("handlebars",uo),Hr.registerLanguage("haskell",mo),Hr.registerLanguage("haxe",po),Hr.registerLanguage("hsp",Eo),Hr.registerLanguage("htmlbars",go),Hr.registerLanguage("http",So),Hr.registerLanguage("hy",To),Hr.registerLanguage("inform7",bo),Hr.registerLanguage("ini",fo),Hr.registerLanguage("irpf90",Co),Hr.registerLanguage("isbl",Ro),Hr.registerLanguage("java",No),Hr.registerLanguage("javascript",Oo),Hr.registerLanguage("jboss-cli",vo),Hr.registerLanguage("json",Io),Hr.registerLanguage("julia",ho),Hr.registerLanguage("julia-repl",Ao),Hr.registerLanguage("kotlin",yo),Hr.registerLanguage("lasso",Do),Hr.registerLanguage("latex",Mo),Hr.registerLanguage("ldif",Lo),Hr.registerLanguage("leaf",xo),Hr.registerLanguage("less",wo),Hr.registerLanguage("lisp",Po),Hr.registerLanguage("livecodeserver",ko),Hr.registerLanguage("livescript",Uo),Hr.registerLanguage("llvm",Fo),Hr.registerLanguage("lsl",Bo),Hr.registerLanguage("lua",Go),Hr.registerLanguage("makefile",Yo),Hr.registerLanguage("mathematica",Ho),Hr.registerLanguage("matlab",Vo),Hr.registerLanguage("maxima",qo),Hr.registerLanguage("mel",zo),Hr.registerLanguage("mercury",Wo),Hr.registerLanguage("mipsasm",Qo),Hr.registerLanguage("mizar",$o),Hr.registerLanguage("perl",Ko),Hr.registerLanguage("mojolicious",jo),Hr.registerLanguage("monkey",Xo),Hr.registerLanguage("moonscript",Zo),Hr.registerLanguage("n1ql",Jo),Hr.registerLanguage("nginx",es),Hr.registerLanguage("nim",ts),Hr.registerLanguage("nix",as),Hr.registerLanguage("nsis",ns),Hr.registerLanguage("objectivec",rs),Hr.registerLanguage("ocaml",is),Hr.registerLanguage("openscad",os),Hr.registerLanguage("oxygene",ss),Hr.registerLanguage("parser3",ls),Hr.registerLanguage("pf",_s),Hr.registerLanguage("pgsql",cs),Hr.registerLanguage("php",ds),Hr.registerLanguage("php-template",us),Hr.registerLanguage("plaintext",ms),Hr.registerLanguage("pony",ps),Hr.registerLanguage("powershell",Es),Hr.registerLanguage("processing",gs),Hr.registerLanguage("profile",Ss),Hr.registerLanguage("prolog",Ts),Hr.registerLanguage("properties",bs),Hr.registerLanguage("protobuf",fs),Hr.registerLanguage("puppet",Cs),Hr.registerLanguage("purebasic",Rs),Hr.registerLanguage("python",Ns),Hr.registerLanguage("python-repl",Os),Hr.registerLanguage("q",vs),Hr.registerLanguage("qml",Is),Hr.registerLanguage("r",hs),Hr.registerLanguage("reasonml",As),Hr.registerLanguage("rib",ys),Hr.registerLanguage("roboconf",Ds),Hr.registerLanguage("routeros",Ms),Hr.registerLanguage("rsl",Ls),Hr.registerLanguage("ruleslanguage",xs),Hr.registerLanguage("rust",ws),Hr.registerLanguage("sas",Ps),Hr.registerLanguage("scala",ks),Hr.registerLanguage("scheme",Us),Hr.registerLanguage("scilab",Fs),Hr.registerLanguage("scss",Bs),Hr.registerLanguage("shell",Gs),Hr.registerLanguage("smali",Ys),Hr.registerLanguage("smalltalk",Hs),Hr.registerLanguage("sml",Vs),Hr.registerLanguage("sqf",qs),Hr.registerLanguage("sql",zs),Hr.registerLanguage("stan",Ws),Hr.registerLanguage("stata",Qs), +Hr.registerLanguage("step21",$s),Hr.registerLanguage("stylus",Ks),Hr.registerLanguage("subunit",js),Hr.registerLanguage("swift",Xs),Hr.registerLanguage("taggerscript",Zs),Hr.registerLanguage("yaml",Js),Hr.registerLanguage("tap",el),Hr.registerLanguage("tcl",tl),Hr.registerLanguage("thrift",al),Hr.registerLanguage("tp",nl),Hr.registerLanguage("twig",rl),Hr.registerLanguage("typescript",il),Hr.registerLanguage("vala",ol),Hr.registerLanguage("vbnet",sl),Hr.registerLanguage("vbscript",ll),Hr.registerLanguage("vbscript-html",_l),Hr.registerLanguage("verilog",cl),Hr.registerLanguage("vhdl",dl),Hr.registerLanguage("vim",ul),Hr.registerLanguage("x86asm",ml),Hr.registerLanguage("xl",pl),Hr.registerLanguage("xquery",El),Hr.registerLanguage("zephir",gl);var Sl=Hr;!function(e,t){function n(a){try{var n=t.querySelectorAll("code.hljs,code.nohighlight");for(var i in n)n.hasOwnProperty(i)&&r(n[i],a)}catch(t){e.console.error("LineNumbers error: ",t)}}function r(e,t){"object"==a(e)&&function(e){e()}(function(){e.innerHTML=i(e,t)})}function i(e,t){var a=(t=t||{singleLine:!1}).singleLine?0:1;return function n(e){var t=e.childNodes;for(var a in t)if(t.hasOwnProperty(a)){var r=t[a];l(r.textContent)>0&&(r.childNodes.length>0?n(r):o(r.parentNode))}}(e),function(e,t){var a=s(e);if(""===a[a.length-1].trim()&&a.pop(),a.length>t){for(var n="",r=0,i=a.length;r
{6}
',[m,d,p,E,u,r+1,a[r].length>0?a[r]:" "]);return _('{1}
',[c,n])}return e}(e.innerHTML,a)}function o(e){var t=e.className;if(/hljs-/.test(t)){for(var a=s(e.innerHTML),n=0,r="";n{1}
\n',[t,a[n].length>0?a[n]:" "]);e.innerHTML=r.trim()}}function s(e){return 0===e.length?[]:e.split(g)}function l(e){return(e.trim().match(g)||[]).length}function _(e,t){return e.replace(/\{(\d+)\}/g,function(e,a){return t[a]?t[a]:e})}var c="hljs-ln",d="hljs-ln-line",u="hljs-ln-code",m="hljs-ln-numbers",p="hljs-ln-n",E="data-line-number",g=/\r\n|\r|\n/g;Sl?(Sl.initLineNumbersOnLoad=function(a){"interactive"===t.readyState||"complete"===t.readyState?n(a):e.addEventListener("DOMContentLoaded",function(){n(a)})},Sl.lineNumbersBlock=r,Sl.lineNumbersValue=function(e,t){if("string"==typeof e){var a=document.createElement("code");return a.innerHTML=e,i(a,t)}},function(){var e=t.createElement("style");e.type="text/css",e.innerHTML=_(".{0}{border-collapse:collapse}.{0} td{padding:0}.{1}:before{content:attr({2})}",[c,p,E]),t.getElementsByTagName("head")[0].appendChild(e)}()):e.console.error("highlight.js not detected!")}(window,document); +/*! + * reveal.js plugin that adds syntax highlight support. + */ +var Tl={id:"highlight",HIGHLIGHT_STEP_DELIMITER:"|",HIGHLIGHT_LINE_DELIMITER:",",HIGHLIGHT_LINE_RANGE_DELIMITER:"-",hljs:Sl,init:function(e){var t=e.getConfig().highlight||{};t.highlightOnLoad="boolean"!=typeof t.highlightOnLoad||t.highlightOnLoad,t.escapeHTML="boolean"!=typeof t.escapeHTML||t.escapeHTML,[].slice.call(e.getRevealElement().querySelectorAll("pre code")).forEach(function(e){e.hasAttribute("data-trim")&&"function"==typeof e.innerHTML.trim&&(e.innerHTML=function(e){function t(e){return e.replace(/^[\s\uFEFF\xA0]+/g,"")}return function(e){var a=function(e){for(var t=e.split("\n"),a=0;a=0&&""===t[a].trim();a--)t.splice(a,1);return t.join("\n")}(e.innerHTML).split("\n"),n=a.reduce(function(e,a){return a.length>0&&t(a).length>0&&e>a.length-t(a).length?a.length-t(a).length:e},Number.POSITIVE_INFINITY);return a.map(function(e){return e.slice(n)}).join("\n")}(e)}(e)),t.escapeHTML&&!e.hasAttribute("data-noescape")&&(e.innerHTML=e.innerHTML.replace(//g,">")),e.addEventListener("focusout",function(e){Sl.highlightBlock(e.currentTarget)},!1),t.highlightOnLoad&&Tl.highlightBlock(e)}),e.on("pdf-ready",function(){[].slice.call(e.getRevealElement().querySelectorAll("pre code[data-line-numbers].current-fragment")).forEach(function(e){Tl.scrollHighlightedLineIntoView(e,{},!0)})})},highlightBlock:function(e){if(Sl.highlightBlock(e),0!==e.innerHTML.trim().length&&e.hasAttribute("data-line-numbers")){Sl.lineNumbersBlock(e,{singleLine:!0});var t={currentBlock:e},a=Tl.deserializeHighlightSteps(e.getAttribute("data-line-numbers"));if(a.length>1){var n=parseInt(e.getAttribute("data-fragment-index"),10);("number"!=typeof n||isNaN(n))&&(n=null),a.slice(1).forEach(function(a){var r=e.cloneNode(!0);r.setAttribute("data-line-numbers",Tl.serializeHighlightSteps([a])),r.classList.add("fragment"),e.parentNode.appendChild(r),Tl.highlightLines(r),"number"==typeof n?(r.setAttribute("data-fragment-index",n),n+=1):r.removeAttribute("data-fragment-index"),r.addEventListener("visible",Tl.scrollHighlightedLineIntoView.bind(Tl,r,t)),r.addEventListener("hidden",Tl.scrollHighlightedLineIntoView.bind(Tl,r.previousSibling,t))}),e.removeAttribute("data-fragment-index"),e.setAttribute("data-line-numbers",Tl.serializeHighlightSteps([a[0]]))}var r="function"==typeof e.closest?e.closest("section:not(.stack)"):null;r&&r.addEventListener("visible",function i(){Tl.scrollHighlightedLineIntoView(e,t,!0),r.removeEventListener("visible",i)}),Tl.highlightLines(e)}},scrollHighlightedLineIntoView:function(e,t,a){cancelAnimationFrame(t.animationFrameID),t.currentBlock&&(e.scrollTop=t.currentBlock.scrollTop),t.currentBlock=e;var n=this.getHighlightedLineBounds(e),r=e.offsetHeight,i=getComputedStyle(e);r-=parseInt(i.paddingTop)+parseInt(i.paddingBottom);var o=e.scrollTop,s=n.top+(Math.min(n.bottom-n.top,r)-r)/2,l=e.querySelector(".hljs-ln");if(l&&(s+=l.offsetTop-parseInt(i.paddingTop)),s=Math.max(Math.min(s,e.scrollHeight-r),0),!0===a||o===s)e.scrollTop=s;else{if(e.scrollHeight<=r)return;var _=0;!function a(){_=Math.min(_+.02,1),e.scrollTop=o+(s-o)*Tl.easeInOutQuart(_),_<1&&(t.animationFrameID=requestAnimationFrame(a))}()}},easeInOutQuart:function(e){return e<.5?8*e*e*e*e:1-8*--e*e*e*e},getHighlightedLineBounds:function(e){var t=e.querySelectorAll(".highlight-line");if(0===t.length)return{top:0,bottom:0};var a=t[0],n=t[t.length-1];return{top:a.offsetTop,bottom:n.offsetTop+n.offsetHeight}},highlightLines:function(e,t){var a=Tl.deserializeHighlightSteps(t||e.getAttribute("data-line-numbers"));a.length&&a[0].forEach(function(t){var a=[];"number"==typeof t.end?a=[].slice.call(e.querySelectorAll("table tr:nth-child(n+"+t.start+"):nth-child(-n+"+t.end+")")):"number"==typeof t.start&&(a=[].slice.call(e.querySelectorAll("table tr:nth-child("+t.start+")"))),a.length&&(a.forEach(function(e){e.classList.add("highlight-line")}),e.classList.add("has-highlights"))})},deserializeHighlightSteps:function(e){return(e=(e=e.replace(/\s/g,"")).split(Tl.HIGHLIGHT_STEP_DELIMITER)).map(function(e){return e.split(Tl.HIGHLIGHT_LINE_DELIMITER).map(function(e){if(/^[\d-]+$/.test(e)){e=e.split(Tl.HIGHLIGHT_LINE_RANGE_DELIMITER);var t=parseInt(e[0],10),a=parseInt(e[1],10);return isNaN(a)?{start:t}:{start:t,end:a}}return{}})})},serializeHighlightSteps:function(e){return e.map(function(e){return e.map(function(e){return"number"==typeof e.end?e.start+Tl.HIGHLIGHT_LINE_RANGE_DELIMITER+e.end:"number"==typeof e.start?e.start:""}).join(Tl.HIGHLIGHT_LINE_DELIMITER)}).join(Tl.HIGHLIGHT_STEP_DELIMITER)}};return function(){return Tl}}); + + + Reveal.initialize({ + width: SLConfig.deck.width, + height: SLConfig.deck.height, + margin: 0.05, + + + hash: true, + controls: true, + progress: true, + mouseWheel: false, + showNotes: SLConfig.deck.share_notes ? 'separate-page' : false, + slideNumber: SLConfig.deck.slide_number, + fragmentInURL: true, + + autoSlide: SLConfig.deck.auto_slide_interval || 0, + autoSlideStoppable: true, + + autoAnimateMatcher: SL.deck.AutoAnimate.matcher, + + scrollActivationWidth: null, + + rollingLinks: false, + center: SLConfig.deck.center || false, + shuffle: SLConfig.deck.shuffle || false, + loop: SLConfig.deck.should_loop || false, + rtl: SLConfig.deck.rtl || false, + navigationMode: SLConfig.deck.navigation_mode, + + transition: SLConfig.deck.transition, + backgroundTransition: SLConfig.deck.background_transition, + + pdfMaxPagesPerSlide: 1, + + highlight: { + escapeHTML: false + }, + + plugins: [ RevealHighlight ] + }); + \ No newline at end of file diff --git a/examples/official-site/pricing.sql b/examples/official-site/pricing.sql new file mode 100644 index 0000000..0a52fa7 --- /dev/null +++ b/examples/official-site/pricing.sql @@ -0,0 +1,79 @@ +SELECT 'shell' as component, +'style_pricing.css' as css ; + + +SELECT 'hero' as component, + 'DATAPAGE PRICING PLANS' as title, +' +> *Start free, launch with fixed costs, and scale efficiently.* + +> If you have any questions regarding **DataPage.app**, fill out the form [*here*](https://beta.datapage.app/fill-the-form.sql) and we''ll get back to you shortly.' as description_md; + +SELECT 'START PLAN' as title, +' +### **Price**: **€18/month** *(First 1 month FREE)* +### **🚩[Register for the *START Plan*](https://buy.stripe.com/9AQeWCa6k85Q9gY8wy)** +--- +- **Database Size**: **128MB** +- **Ideal For**: Testing and small-scale projects. +- **Features**: + - Basic SQLPage hosting. + - Essential components for simple applications. + - Community Support via forums. +--- +### **🚩[Register for the *START Plan*](https://buy.stripe.com/9AQeWCa6k85Q9gY8wy)** +' +as description_md, + 'player-play' as icon, + 'blue' as color; + +SELECT 'PRO PLAN' as title, +' +### **Price**: **€40/month** *(First 1 month FREE)* +### **🚩[Register for the *PRO Plan*](https://buy.stripe.com/eVabKqces99U1OweUX)** +--- +- **Database Size**: **1GB** +- **Ideal For**: Growing projects and businesses needing enhanced support and features +- **Features**: + - All *START plan* features. + - **Priority support**: Get faster response times and direct assistance from our support team + - **Custom Domain**: Use your custom domain name with your SQLPage app +--- + +### **🚩[Register for the *PRO Plan*](https://buy.stripe.com/eVabKqces99U1OweUX)** +' + as description_md, + 'shield-check' as icon, + 'green' as color; + + + + +SELECT 'ENTREPRISE PLAN' as title, +' +### **Price**: **€600/month** *(First 1 month FREE)* +### **🚩[Register for the *ENTREPRISE Plan*](https://buy.stripe.com/8wM6q62DS5XI3WE4gk)** +--- +- **Database**: **Custom Scaling** +- **Ideal For**: Large-scale operations with custom needs. +- **Features**: + - All Pro Plan features. + - **Custom Deployment**: Tailored deployment to suit your specific requirements, whether on-premises or in the cloud. + - **Database Scaling**: Dynamically scale your database to handle increased traffic and storage needs. + - **Authentication**: Implement OpenID Connect and OAuth2 for secure user authentication via Google, Facebook, or internal company accounts. + - **Premium Components**: Access to exclusive, high-performance components for building complex applications. + - **1-Hour Monthly Support**: Dedicated one-on-one support session with our experts each month. + - **SLA Agreement**: Service Level Agreement with guaranteed uptime and response times. + - **Custom Integration**: Personalized integration with your existing systems and workflows. + - **Onboarding Assistance**: Get personalized setup and onboarding assistance for a smooth start. +--- + +### **🚩[Register for the *ENTREPRISE Plan*](https://buy.stripe.com/8wM6q62DS5XI3WE4gk)** + ' as description_md, + 'bubble-plus' as icon, + 'red' as color; + +SELECT 'text' as component, +'' as title, +'## **Ready to Get Started?** +[Sign Up Now](https://datapage.app) and start building your SQLPage app with Datapage.app today!' as contents_md; diff --git a/examples/official-site/robots.txt b/examples/official-site/robots.txt new file mode 100644 index 0000000..657f911 --- /dev/null +++ b/examples/official-site/robots.txt @@ -0,0 +1,5 @@ +User-agent: * +Disallow: /examples/authentication/basic_auth.sql +Disallow: /cdn-cgi/l/email-protection +Disallow: /sqlpage/ +Crawl-delay: 1 diff --git a/examples/official-site/rss.sql b/examples/official-site/rss.sql new file mode 100644 index 0000000..c23d07c --- /dev/null +++ b/examples/official-site/rss.sql @@ -0,0 +1,21 @@ +select 'http_header' as component, + 'application/rss+xml' as "Content-Type"; +select 'shell-empty' as component; +select 'rss' as component, + 'SQLPage blog' as title, + 'https://sql-page.com/blog.sql' as link, + 'latest news about SQLpage' as description, + 'en' as language, + 'https://sql-page.com/rss.sql' as self_link, + 'Technology' as category, + '2de3f968-9928-5ec6-9653-6fc6fe382cfd' as guid; +SELECT title, + description, + CASE + WHEN external_url IS NOT NULL THEN external_url + ELSE 'https://sql-page.com/blog.sql?post=' || title + END AS link, + created_at AS date, + false AS explicit +FROM blog_posts +ORDER BY created_at DESC; \ No newline at end of file diff --git a/examples/official-site/safety.sql b/examples/official-site/safety.sql new file mode 100644 index 0000000..a9af1f1 --- /dev/null +++ b/examples/official-site/safety.sql @@ -0,0 +1,159 @@ +select 'http_header' as component, + 'public, max-age=600, stale-while-revalidate=3600, stale-if-error=86400' as "Cache-Control", + '; rel="canonical"' as "Link"; + +select 'dynamic' as component, json_patch(json_extract(properties, '$[0]'), json_object( + 'title', 'Security in SQLPage: SSO, protection against SQLi, XSS, CSRF, and more' +)) as properties +FROM example WHERE component = 'shell' LIMIT 1; + +select 'hero' as component, + 'SQLPage''s security guarantees' as title, + 'SQLPage prevents common web vulnerabilities such as SQL injections and XSS attacks by default.' as description, + 'safety.webp' as image; + +select 'text' as component, + ' +SQLPage is a tool that allows you to create a full website using only SQL queries, and render results straight from the database to the browser. +Most programmers, hearing this, will immediately think of the security implications of this model. + +This page is here to provide a list of the security guarantees that SQLPage provides. +SQLPage was designed from the ground up to be usable by non-technical *data analysts* and other non-web-developers, +so it provides safe defaults everywhere, so that you don''t have to think about basic security issues +you would have to worry about in a traditional web development stack. + +## SQLPage does not expose your database to the internet + +SQLPage websites are *server-side rendered*, which means that the SQL queries stay on the server +where SQLPage is installed. + +The results of these queries are then rendered to HTML, and sent to the user''s browser. +A malicious user cannot run arbitrary SQL queries on your database, because SQLPage +does not expose your entire database to the internet, only the results of +your prepared queries, rendered as web pages. + +## Protection against SQL injections + +SQL injections are a common security vulnerability in traditional back-end web development, +that allow an attacker to execute arbitrary SQL code on your database. + +**SQLPage is immune to SQL injections**, because it uses [prepared statements](https://en.wikipedia.org/wiki/Prepared_statement) +to pass parameters to your SQL queries. + +When a web page starts rendering, and before processing any user inputs, all your SQL queries have already been prepared, and no +new SQL code can be passed to the database. Whatever evil inputs a user might try to pass to your website, +it will never be executed as SQL code on the database. + +SQLPage **cannot** execute any other SQL code than the one you, the site author, wrote in your SQL files. + +If you have a SQL query that looks like this: + +```sql +SELECT * FROM users WHERE userid = $id; +``` + +and a user tries to pass the following value to the `id` parameter: + +``` +1; DROP TABLE users; +``` + +SQLPage will execute the search for the user with id `1; DROP TABLE users;` (and most likely not find any user with that id), +but it *will not* execute the `DROP TABLE` statement. + +## Protection against XSS attacks + +XSS attacks are a common security vulnerability in traditional front-end web development, +that allow an attacker to execute arbitrary JavaScript code on your users'' browsers. + +**SQLPage is immune to XSS attacks**, because it uses an HTML-aware templating engine to render your +SQL query results to HTML. When you execute the following SQL code: + +```sql +SELECT ''text'' AS component, '''' AS contents; +``` + +it will be rendered as: + +```html +

+ <script>alert("I am evil")</script> +

+``` + +Additionnally, SQLPage uses a [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) +that disallows the execution of any inline JavaScript code, and only allows loading JavaScript code from trusted sources. + +If you have some legitimate JavaScript code that you want to execute on your website, you can use the `javascript` +parameter of the [`shell`](documentation.sql?component=shell#component) component to do so. + +## Authentication + +Use either the built-in username/password or Single Sign-On; both follow safe defaults. + +### Built-in username/password + +SQLPage provides an [authentication](/documentation.sql?component=authentication#component) component to protect pages, +with helpers like [`sqlpage.basic_auth_username()`](/functions.sql?function=basic_auth_username#function), +[`sqlpage.basic_auth_password()`](/functions.sql?function=basic_auth_password#function), and +[`sqlpage.hash_password()`](/functions.sql?function=hash_password#function). +Passwords are salted and hashed with [argon2](https://en.wikipedia.org/wiki/Argon2), +following [best practices](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html). + +### Session management + +If you implement your own sessions using the [`cookie` component](/documentation.sql?component=cookie#component), +follow the [OWASP recommendations](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html#cookies). +Avoid rolling your own unless you fully understand web security. + +### Single Sign-On (OIDC) + +When OIDC is enabled, SQLPage validates a signed identity token on every request +before any of your SQL runs. Without a successful login, requests are redirected +to your identity provider and your application code never executes. +This keeps attackers outside your SSO realm from reaching your app, +even if a vulnerability exists in your own code. + +By default, all pages are protected when single sign-on is enabled. +Once authenticated, you can access user claims with +[`sqlpage.user_info()`](/functions.sql?function=user_info) +to further restrict what users see based on who they are. + +## Protection against [CSRF attacks](https://en.wikipedia.org/wiki/Cross-site_request_forgery) + +The recommended way to store session tokens for user authentication +in SQLPage is to use the [`cookie` component](/documentation.sql?component=cookie#component). + +All cookies set by SQLPage have the `SameSite` attribute set to `strict` by default, +which means that they will only be sent to your website if the user is already on your website. +An attacker cannot make a user''s browser send a request to your website from another (malicious) +website, and have it perform an action on your website in the user''s name, +because the browser will not send the cookies to your website. + +SQLPage differentiates between POST variables (accessed with the `:variable` syntax), and +variables that can come from URL parameters (accessible with `$variable`). Note that URL parameters +prefixed with `_sqlpage_` are reserved for internal use. + +When a user submits a form, you should use POST variables to access the form data. +This ensures that you only use data that indeed comes from the form, and not from a +URL parameter that could be part of a malicious link. + +Advanced users who may want to implement their own csrf protection system can do so +using the [`sqlpage.random_string()`](/functions.sql?function=random_string#function) function, +and the `hidden` input type of the [`form`](/documentation.sql?component=form#component) component. + +For more information, see the [this discussion](https://github.com/sqlpage/SQLPage/discussions/148). + +## Database connections + +SQLPage uses a fixed pool of database connections, and will never open more connections than the ones you +[configured](https://github.com/sqlpage/SQLPage/blob/main/configuration.md). So even under heavy load, your database +connection limit will never be saturated by SQLPage. + +And SQLPage will accept any restriction you put on the database user you use to connect to your database, so you can +create a specific user for SQLPage that only has access to the specific tables you will use in your application. + +If your entire application is read-only, you can even create a user that only has the `SELECT` privilege on your database, +preventing any accidental data modification. SQLPage will work fine with such a user and will never try to execute any +other SQL statements than the ones you explicitly wrote in your SQL files. +' as contents_md; diff --git a/examples/official-site/safety.webp b/examples/official-site/safety.webp new file mode 100644 index 0000000..11ea684 Binary files /dev/null and b/examples/official-site/safety.webp differ diff --git a/examples/official-site/search.sql b/examples/official-site/search.sql new file mode 100644 index 0000000..2f5232a --- /dev/null +++ b/examples/official-site/search.sql @@ -0,0 +1,117 @@ +set search = nullif(trim($search), ''); + +-- Check for exact matches and redirect if found +set redirect = CASE + WHEN EXISTS (SELECT 1 FROM component WHERE name = $search) THEN sqlpage.link('/component.sql', json_object('component', $search)) + WHEN EXISTS (SELECT 1 FROM sqlpage_functions WHERE name = $search) THEN sqlpage.link('/functions.sql', json_object('function', $search)) +END +SELECT 'redirect' as component, $redirect as link WHERE $redirect IS NOT NULL; + +select 'dynamic' as component, json_patch(json_extract(properties, '$[0]'), json_object( + 'title', coalesce($search || ' | ', '') || 'SQLPage documentation search' +)) as properties +FROM example WHERE component = 'shell' LIMIT 1; + +SELECT 'form' as component, + 'GET' as method, + true as auto_submit, + 'Search documentation' as title; + +SELECT 'text' as type, + 'search' as name, + '' as label, + true as autofocus, + 'Search for components, parameters, functions...' as placeholder, + $search as value; + +set escaped_search = '"' || replace($search, '"', '""') || '"'; + +SELECT 'text' as component, + CASE + WHEN $search IS NULL THEN 'Enter a search term above to find documentation about components, parameters, functions, and blog posts.' + WHEN NOT EXISTS ( + SELECT 1 FROM documentation_fts + WHERE documentation_fts = $escaped_search + ) THEN 'No results found for "' || $search || '".' + ELSE NULL + END as contents; + +SELECT 'list' as component, + 'Search Results' as title, + 'No results found for "' || $search || '".' as empty_description +WHERE $search IS NOT NULL; + +WITH search_results AS ( + SELECT + COALESCE( + component_name || ' component: parameter ' || parameter_name + , component_name || ' component' || IF(component_example_description IS NULL, '', ' example') + , 'blog: ' || blog_title + , 'sqlpage.' || function_name || '(...' || function_parameter_name || '...)' + , 'sqlpage.' || function_name || '(...)' + ) as title, + COALESCE( + component_description, + parameter_description, + blog_description, + function_parameter_description, + function_description, + component_example_description + ) as description, + CASE + WHEN component_name IS NOT NULL THEN + json_object( + 'page', '/component.sql', + 'parameters', json_object('component', component_name) + ) + WHEN parameter_name IS NOT NULL THEN + json_object( + 'page', '/component.sql', + 'parameters', json_object('component', ( + SELECT component + FROM parameter + WHERE name = parameter_name + LIMIT 1 + )) + ) + WHEN blog_title IS NOT NULL THEN + json_object( + 'page', '/blog.sql', + 'parameters', json_object('post', blog_title) + ) + WHEN function_name IS NOT NULL THEN + json_object( + 'page', '/functions.sql', + 'parameters', json_object('function', function_name) + ) + WHEN function_parameter_name IS NOT NULL THEN + json_object( + 'page', '/functions.sql', + 'parameters', json_object('function', ( + SELECT function + FROM sqlpage_function_parameters + WHERE name = function_parameter_name + LIMIT 1 + )) + ) + END as link_data, + rank + FROM documentation_fts + WHERE $search IS NOT NULL + AND documentation_fts = $escaped_search +) +SELECT + max(title) as title, + max(description) as description, + sqlpage.link(link_data->>'page', link_data->'parameters') as link +FROM search_results +GROUP BY link_data +ORDER BY + rank, + CASE + WHEN title LIKE 'component:%' THEN 1 + WHEN title LIKE 'parameter:%' THEN 2 + WHEN title LIKE 'blog:%' THEN 3 + WHEN title LIKE 'function:%' THEN 4 + END, + description; diff --git a/examples/official-site/sqlpage/migrations/01_documentation.sql b/examples/official-site/sqlpage/migrations/01_documentation.sql new file mode 100644 index 0000000..012b670 --- /dev/null +++ b/examples/official-site/sqlpage/migrations/01_documentation.sql @@ -0,0 +1,1585 @@ +CREATE TABLE component( + name TEXT PRIMARY KEY, + description TEXT NOT NULL, + icon TEXT, -- icon name from tabler icon + introduced_in_version TEXT +); + +CREATE TABLE parameter_type( + name TEXT PRIMARY KEY +); +INSERT INTO parameter_type(name) VALUES + ('BOOLEAN'), ('COLOR'), ('HTML'), ('ICON'), ('INTEGER'), ('JSON'), ('REAL'), ('TEXT'), ('TIMESTAMP'), ('URL'); + +CREATE TABLE parameter( + top_level BOOLEAN DEFAULT FALSE, + name TEXT, + component TEXT REFERENCES component(name) ON DELETE CASCADE, + description TEXT, + description_md TEXT, + type TEXT REFERENCES parameter_type(name) ON DELETE CASCADE, + optional BOOLEAN DEFAULT FALSE, + PRIMARY KEY (component, top_level, name) +); + +CREATE TABLE example( + component TEXT REFERENCES component(name) ON DELETE CASCADE, + description TEXT, + properties JSON, + FOREIGN KEY (component) REFERENCES component(name) ON DELETE CASCADE +); + +INSERT INTO component(name, icon, description) VALUES + ('list', 'list', 'A vertical list of items. Each item can be clickable and link to another page.'); +INSERT INTO parameter(component, name, description, type, top_level, optional) SELECT 'list', * FROM (VALUES + -- top level + ('title', 'Text header at the top of the list.', 'TEXT', TRUE, TRUE), + ('empty_title', 'Title text to display if the list is empty.', 'TEXT', TRUE, TRUE), + ('empty_description', 'Description to display if the list is empty.', 'TEXT', TRUE, TRUE), + ('empty_description_md', 'Description to display if the list is empty, in Markdown format.', 'TEXT', TRUE, TRUE), + ('empty_link', 'URL to which the user should be taken if they click on the empty list.', 'URL', TRUE, TRUE), + ('compact', 'Whether to display the list in a more compact format, allowing more items to be displayed on the screen.', 'BOOLEAN', TRUE, TRUE), + ('wrap', 'Wrap list items onto multiple lines if they are too long', 'BOOLEAN', TRUE, TRUE), + -- item level + ('title', 'Name of the list item, displayed prominently.', 'TEXT', FALSE, FALSE), + ('description', 'A description of the list item, displayed as greyed-out text.', 'TEXT', FALSE, TRUE), + ('description_md', 'A description of the list item, displayed as greyed-out text, in Markdown format, allowing you to use rich text formatting, including **bold** and *italic* text.', 'TEXT', FALSE, TRUE), + ('link', 'An URL to which the user should be taken when they click on the list item.', 'URL', FALSE, TRUE), + ('icon', 'Name of an icon to display on the left side of the item.', 'ICON', FALSE, TRUE), + ('image_url', 'The URL of a small image to display on the left side of the item.', 'URL', FALSE, TRUE), + ('color', 'The name of a color, to be displayed as a dot near the list item contents.', 'COLOR', FALSE, TRUE), + ('active', 'Whether this item in the list is considered "active". Active items are displayed more prominently.', 'BOOLEAN', FALSE, TRUE), + ('view_link', 'A URL to which the user should be taken when they click on the "view" icon. Does not show the icon when omitted.', 'URL', FALSE, TRUE), + ('edit_link', 'A URL to which the user should be taken when they click on the "edit" icon. Does not show the icon when omitted.', 'URL', FALSE, TRUE), + ('delete_link', 'A page that will be loaded when the user clicks on the delete button for this specific item. The link will be submitted as a POST request.', 'URL', FALSE, TRUE) +) x; + +INSERT INTO example(component, description, properties) VALUES + ('list', 'A basic compact list', json('[{"component":"list", "compact": true, "title": "SQLPage lists are..."},{"title":"Beautiful"},{"title":"Useful"},{"title":"Versatile"}]')), + ('list', 'An empty list with a link to add an item', json('[{"component":"list", "empty_title": "No items yet", "empty_description": "This list is empty. Click here to create a new item !", "empty_link": "documentation.sql"}]')), + ('list', ' +### A list with rich text descriptions + +This example illustrates creating a nice list where each item has a title, a description, an image, and a link to another page. + +> Be careful, nested links are not supported. If you use the list''s `link` property, then your markdown `description_md` should not contain any link. +', json('[{"component":"list", "wrap": true}, + {"title":"SQL Websites", "image_url": "/favicon.ico", "description_md":"Write SQL, get a website. SQLPage is a **SQL**-based **site** generator for **PostgreSQL**, **MySQL**, **SQLite** and **SQL Server**.", "link": "/"}, + {"title":"SQL Forms", "image_url": "https://upload.wikimedia.org/wikipedia/commons/b/b6/FileStack_retouched.jpg", "description_md":"Easily collect data **from users to your database** using the *form* component. Handle the data in SQL with `INSERT` or `UPDATE` queries.", "link": "?component=form"}, + {"title":"SQL Maps", "image_url": "https://upload.wikimedia.org/wikipedia/commons/1/15/Vatican_City_map_EN.png", "description_md":"Show database contents on a map using the *map* component. Works well with *PostGIS* and *SpatiaLite*.", "link": "?component=map"}, + {"title":"Advanced features", "icon": "settings", "description_md":"[Authenticate users](?component=authentication), [edit data](?component=form), [generate an API](?component=json), [maintain your database schema](/your-first-sql-website/migrations.sql), and more."} + ]')), + ('list', 'A beautiful list with bells and whistles.', + json('[{"component":"list", "title":"Top SQLPage features", "compact": true }, + {"title":"Authentication", "link":"?component=authentication", "description": "Authenticate users with a login form or HTTP basic authentication", "color": "red", "icon":"lock", "active": true, "view_link": "?component=authentication#view" }, + {"title":"Editing data", "link":"?component=form", "description": "SQLPage makes it easy to UPDATE, INSERT and DELETE data in your database tables", "color": "blue", "icon":"database", "edit_link": "?component=form#edit", "delete_link": "?component=form#delete" }, + {"title":"API", "link":"?component=json", "description": "Generate a REST API from a single SQL query to connect with other applications and services", "color": "green", "icon":"plug-connected", "edit_link": "?component=json#edit", "delete_link": "?component=json#delete" } + ]')); + +INSERT INTO component(name, icon, description) VALUES + ('datagrid', 'grid-dots', 'Display small pieces of information in a clear and readable way. Each item has a name and is associated with a value.'); +INSERT INTO parameter(component, name, description, type, top_level, optional) SELECT 'datagrid', * FROM (VALUES + -- top level + ('title', 'Text header at the top of the data grid.', 'TEXT', TRUE, TRUE), + ('description', 'A short paragraph displayed below the title.', 'TEXT', TRUE, TRUE), + ('description_md', 'A short paragraph displayed below the title - formatted using markdown.', 'TEXT', TRUE, TRUE), + ('icon', 'Name of an icon to display on the left side of the title.', 'ICON', TRUE, TRUE), + ('image_url', 'URL of an image to display on the left side of the title.', 'URL', TRUE, TRUE), + -- item level + ('title', 'Name of the piece of information.', 'TEXT', FALSE, FALSE), + ('description', 'Value to display below the name.', 'TEXT', FALSE, TRUE), + ('footer', 'Muted text to display below the value.', 'TEXT', FALSE, TRUE), + ('image_url', 'URL of a small image (such as an avatar) to display on the left side of the value.', 'URL', FALSE, TRUE), + ('link', 'A target URL to which the user should be taken when they click on the value.', 'URL', FALSE, TRUE), + ('icon', 'An icon name (from tabler-icons.io) to display on the left side of the value.', 'ICON', FALSE, TRUE), + ('color', 'If set to a color name, the value will be displayed in a pill of that color.', 'COLOR', FALSE, TRUE), + ('active', 'Whether this item in the grid is considered "active". Active items are displayed more prominently.', 'BOOLEAN', FALSE, TRUE), + ('tooltip', 'A tooltip to display when the user passes their mouse over the value.', 'TEXT', FALSE, TRUE) +) x; + +INSERT INTO example(component, description, properties) VALUES + ('datagrid', 'Just some sections of information.', json('[{"component":"datagrid"},{"title":"Language","description":"SQL"},{"title":"Creation date","description":"1974"}, {"title":"Language family","description":"Query language"}]')), + ('datagrid', 'A beautiful data grid with nice colors and icons.', + json('[{"component":"datagrid", "title": "Ophir Lojkine", "image_url": "https://avatars.githubusercontent.com/u/552629", "description_md": "Member since **2021**"}, + {"title": "Pseudo", "description": "lovasoa", "image_url": "https://avatars.githubusercontent.com/u/552629" }, + {"title": "Status", "description": "Active", "color": "green"}, + {"title": "Email Status", "description": "Validated", "icon": "check", "active": true, "tooltip": "Email address has been validated."}, + {"title": "Personal page", "description": "ophir.dev", "link": "https://ophir.dev/", "tooltip": "About me"} + ]')), + ('datagrid', 'Using a picture in the data grid card header.', json('[ + {"component":"datagrid", "title": "Website Ideas", "icon": "bulb"}, + {"title": "Search engine", "link":"https://google.com", "description": "Google", "color": "red", "icon":"brand-google", "footer": "Owned by Alphabet Inc."}, + {"title": "Encyclopedia", "link":"https://wikipedia.org", "description": "Wikipedia", "color": "blue", "icon":"world", "footer": "Owned by the Wikimedia Foundation"} + ]')); + + +INSERT INTO component(name, icon, description) VALUES + ('steps', 'dots-circle-horizontal', 'Guide users through multi-stage processes, displaying a clear list of previous and future steps.'); +INSERT INTO parameter(component, name, description, type, top_level, optional) SELECT 'steps', * FROM (VALUES + -- top level + ('color', 'Color of the bars displayed between steps.', 'COLOR', TRUE, TRUE), + ('counter', 'Display the number of the step on top of its name.', 'TEXT', TRUE, TRUE), + ('title', 'Title of the section.', 'TEXT', TRUE, TRUE), + ('description', 'Description of the section.', 'TEXT', TRUE, TRUE), + -- item level + ('title', 'Name of the step.', 'TEXT', FALSE, TRUE), + ('description', 'Tooltip to display when the user passes their mouse over the step''s name.', 'TEXT', FALSE, TRUE), + ('link', 'A target URL to which the user should be taken when they click on the step.', 'URL', FALSE, TRUE), + ('icon', 'An icon name (from tabler-icons.io) to display on the left side of the step name.', 'ICON', FALSE, TRUE), + ('active', 'Whether this item in the grid is considered "active". Active items are displayed more prominently.', 'BOOLEAN', FALSE, TRUE) +) x; + +INSERT INTO example(component, description, properties) VALUES + ('steps', 'Online store checkout steps.', json('[{"component":"steps"},{"title":"Shopping"},{"title":"Store pickup"}, {"title":"Payment","active":true},{"title":"Review & Order"}]')), + ('steps', 'A progress indicator with custom color, auto-generated step numbers, icons, and description tooltips.', + json('[{"component":"steps", "counter": true, "color":"purple"}, '|| + '{"title": "Registration form", "icon":"forms", "link": "https://github.com/sqlpage/SQLPage", "description": "Initial account data creation."},' || + '{"title": "Email confirmation", "icon": "mail", "link": "https://sql-page.com", "description": "Confirm your email by clicking on a link in a validation email."},' || + '{"title": "ID verification", "description": "Checking personal information", "icon": "user", "link": "#"},' || + '{"title": "Final account approval", "description": "ophir.dev", "link": "https://ophir.dev/", "icon":"eye-check", "active": true},' || + '{"title":"Account creation", "icon":"check"}]')); + +INSERT INTO component(name, icon, description) VALUES + ('text', 'align-left', 'A paragraph of text. The entire component will render as a single paragraph, with each item being rendered as a span of text inside it, the styling of which can be customized using parameters.'); + +INSERT INTO parameter(component, name, description, type, top_level, optional) SELECT 'text', * FROM (VALUES + -- top level + ('title', 'Text header before the paragraph.', 'TEXT', TRUE, TRUE), + ('center', 'Whether to center the title.', 'BOOLEAN', TRUE, TRUE), + ('width', 'How wide the paragraph should be, in characters.', 'INTEGER', TRUE, TRUE), + ('html', 'Raw html code to include on the page. Don''t use that if you are not sure what you are doing, it may have security implications.', 'TEXT', TRUE, TRUE), + ('contents', 'A top-level paragraph of text to display, without any formatting, without having to make additional queries.', 'TEXT', TRUE, TRUE), + ('contents_md', 'Rich text in the markdown format. Among others, this allows you to write bold text using **bold**, italics using *italics*, and links using [text](https://example.com).', 'TEXT', TRUE, TRUE), + ('article', 'Makes long texts more readable by increasing the line height, adding margins, using a serif font, and decorating the initial letter.', 'BOOLEAN', TRUE, TRUE), + -- item level + ('contents', 'A span of text to display', 'TEXT', FALSE, FALSE), + ('contents_md', 'Rich text in the markdown format. Among others, this allows you to write bold text using **bold**, italics using *italics*, and links using [text](https://example.com).', 'TEXT', FALSE, TRUE), + ('link', 'An URL to which the user should be taken when they click on this span of text.', 'URL', FALSE, TRUE), + ('color', 'The name of a color for this span of text.', 'COLOR', FALSE, TRUE), + ('underline', 'Whether the span of text should be underlined.', 'BOOLEAN', FALSE, TRUE), + ('bold', 'Whether the span of text should be displayed as bold.', 'BOOLEAN', FALSE, TRUE), + ('code', 'Use a monospace font. Useful to display the text as code.', 'BOOLEAN', FALSE, TRUE), + ('italics', 'Whether the span of text should be displayed as italics.', 'BOOLEAN', FALSE, TRUE), + ('break', 'Indicates that the current span of text starts a new paragraph.', 'BOOLEAN', FALSE, TRUE), + ('size', 'A number between 1 and 6 indicating the font size.', 'INTEGER', FALSE, TRUE) +) x; + +INSERT INTO example(component, description, properties) VALUES + ('text', 'Rendering a simple text paragraph.', json('[{"component":"text", "contents":"Hello, world ! <3"}]')), + ('text', 'Rendering rich text using markdown', json('[{"component":"text", "article": true, "contents_md":"\n'|| + '# Markdown in SQLPage\n\n' || + '## Simple formatting\n\n' || + 'SQLPage supports only plain text as column values, but markdown allows easily adding **bold**, *italics*, [external links](https://github.com/sqlpage/SQLPage), [links to other pages](/index.sql) and [intra-page links](#my-paragraph). \n\n' || + '## Lists\n' || + '### Unordered lists\n' || + '* SQLPage is easy\n' || + '* SQLPage is fun\n' || + '* SQLPage is free\n\n' || + '### Ordered lists\n' || + '1. SQLPage is fast\n' || + '2. SQLPage is safe\n' || + '3. SQLPage is open-source\n\n' || + '## Code\n' || + '```sql\n' || + 'SELECT ''list'' AS component;\n' || + 'SELECT name as title FROM users;\n' || + '```\n\n' || + '## Tables\n\n' || + '| SQLPage component | Description | Documentation link |\n' || + '| --- | --- | --- |\n' || + '| text | A paragraph of text. | [Documentation](https://sql-page.com/documentation.sql?component=text) |\n' || + '| list | A list of items. | [Documentation](https://sql-page.com/documentation.sql?component=list) |\n' || + '| steps | A progress indicator. | [Documentation](https://sql-page.com/documentation.sql?component=steps) |\n' || + '| form | A series of input fields. | [Documentation](https://sql-page.com/documentation.sql?component=form) |\n\n' || + '## Quotes\n' || + '> Fantastic.\n>\n' || + '> — [HackerNews User](https://news.ycombinator.com/item?id=36194473#36209061) about SQLPage\n\n' || + '## Images\n' || + '![SQLPage logo](https://sql-page.com/favicon.ico)\n\n' || + '## Horizontal rules\n' || + '---\n\n' || + '"}]')), + ('text', 'Rendering a paragraph with links and styling.', + json('[{"component":"text", "title":"About SQL"}, '|| + '{"contents":"SQL", "bold":true, "italics": true}, '|| + '{"contents":" is a domain-specific language used in programming and designed for managing data held in a "},'|| + '{"contents": "relational database management system", "link": "https://en.wikipedia.org/wiki/Relational_database"},'|| + '{"contents": ". It is particularly useful in handling structured data."}]') + ), + ( + 'text', + 'An intra-page link to a section of the page.', + json('[ + {"component":"text", "contents_md":"This is a link to the [next paragraph](#my-paragraph). You can open this link in a new tab and the page will scroll to the paragraph on load."}, + {"component":"text", "id": "my-paragraph", "contents_md": "This **is** the next paragraph."} + ]') + ) +; + +INSERT INTO component(name, icon, description) VALUES + ('form', 'cursor-text', ' +# Building forms in SQL + +The form component will display a series of input fields of various types, that can be filled in by the user. +When the user submits the form, the data is posted to an SQL file specified in the `action` property. + +## Handle Data with SQL + +The receiving SQL page will be able to handle the data, +and insert it into the database, use it to perform a search, format it, update existing data, etc. + +A value in a field named "x" will be available as `:x` in the SQL query of the target page. + +## Examples + + - [A multi-step form](https://github.com/sqlpage/SQLPage/tree/main/examples/forms-with-multiple-steps), guiding the user through a process without overwhelming them with a large form. + - [File upload form](https://github.com/sqlpage/SQLPage/tree/main/examples/image%20gallery%20with%20user%20uploads), letting users upload images to a gallery. + - [Rich text editor](https://github.com/sqlpage/SQLPage/tree/main/examples/rich-text-editor), letting users write text with bold, italics, links, images, etc. + - [Master-detail form](https://github.com/sqlpage/SQLPage/tree/main/examples/master-detail-forms), to edit a list of structured items. + - [Form with a variable number of fields](https://github.com/sqlpage/SQLPage/tree/main/examples/forms%20with%20a%20variable%20number%20of%20fields), when the fields are not known in advance. + - [Demo of all input types](/examples/form), showing all the input types supported by SQLPage. +'); +INSERT INTO parameter(component, name, description_md, type, top_level, optional) SELECT 'form', * FROM (VALUES + -- top level + ('enctype', ' +When ``method="post"``, this specifies how the form-data should be encoded +when submitting it to the server. +', 'TEXT', TRUE, TRUE), + -- item level + ('formenctype', ' +When ``type`` is ``submit`` or ``image``, this specifies how the form-data +should be encoded when submitting it to the server. + +Takes precedence over any ``enctype`` set on the ``form`` element. + +NOTE: when a ``file`` type input is present, then ``formenctype="multipart/form-data"`` +is automatically applied to the default validate button. +', 'TEXT', FALSE, TRUE) +); +INSERT INTO parameter(component, name, description, type, top_level, optional) SELECT 'form', * FROM (VALUES + -- top level + ('method', 'Set this to ''GET'' to pass the form contents directly as URL parameters. If the user enters a value v in a field named x, submitting the form will load target.sql?x=v. If target.sql contains SELECT $x, it will display the value v.', 'TEXT', TRUE, TRUE), + ('action', 'An optional link to a target page that will handle the results of the form. By default the target page is the current page with the id of the form (if passed) used as hash - this will bring us back to the location of the form after submission. Setting it to the name of a different sql file will load that file when the user submits the form.', 'TEXT', TRUE, TRUE), + ('title', 'A name to display at the top of the form. It will be displayed in a larger font size at the top of the form.', 'TEXT', TRUE, TRUE), + ('validate', 'The text to display in the button at the bottom of the form that submits the values. Omit this property to let the browser display the default form validation text, or set it to the empty string to remove the button completely.', 'TEXT', TRUE, TRUE), + ('validate_color', 'The color of the button at the bottom of the form that submits the values. Omit this property to use the default color.', 'COLOR', TRUE, TRUE), + ('validate_outline', 'A color to outline the validation button.', 'COLOR', TRUE, TRUE), + ('reset', 'The text to display in the button at the bottom of the form that resets the form to its original state. Omit this property not to show a reset button at all.', 'TEXT', TRUE, TRUE), + ('id', 'A unique identifier for the form, which can then be used to validate the form from a button outside of the form.', 'TEXT', TRUE, TRUE), + ('auto_submit', 'Automatically submit the form when the user changes any of its fields, and remove the validation button.', 'BOOLEAN', TRUE, TRUE), + ('validate_icon', 'Name of an icon to be displayed on the left side of the submit button.', 'ICON', TRUE, TRUE), + ('reset_icon', 'Name of an icon to be displayed on the left side of the reset button.', 'ICON', TRUE, TRUE), + ('reset_color', 'The color of the button at the bottom of the form that resets the form to its original state. Omit this property to use the default color.', 'COLOR', TRUE, TRUE), + -- item level + ('type', 'Declares input control behavior and expected format. All HTML input types are supported (text, number, date, file, checkbox, radio, hidden, ...). SQLPage adds some custom types: textarea, switch, header. text by default. See https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input#input_types', 'TEXT', FALSE, TRUE), + ('name', 'The name of the input field, that you can use in the target page to get the value the user entered for the field.', 'TEXT', FALSE, FALSE), + ('label', 'A friendly name for the text field to show to the user.', 'TEXT', FALSE, TRUE), + ('placeholder', 'A placeholder text that will be shown in the field when is is empty.', 'TEXT', FALSE, TRUE), + ('value', 'A default value that will already be present in the field when the user loads the page.', 'TEXT', FALSE, TRUE), + ('options', 'A json array of objects containing the label and value of initial options of a select field. Used only when type=select. JSON objects in the array can contain the properties "label", "value" and "selected".', 'JSON', FALSE, TRUE), + ('options_source', 'Only for inputs of type `select`. URL of a SQL file that returns JSON search results for the dropdown. The SQL file receives the search text as `$search` and must return an array of objects with exactly `label` and `value`. Use `options` for options that should be available before the user searches.', 'URL', FALSE, TRUE), + ('required', 'Set this to true to prevent the form contents from being sent if this field is left empty by the user.', 'BOOLEAN', FALSE, TRUE), + ('min', 'The minimum value to accept for an input of type number', 'REAL', FALSE, TRUE), + ('max', 'The maximum value to accept for an input of type number', 'REAL', FALSE, TRUE), + ('checked', 'Used only for checkboxes and radio buttons. Indicates whether the checkbox should appear as already checked.', 'BOOLEAN', FALSE, TRUE), + ('multiple', 'Used only for select elements. Indicates that multiple elements can be selected simultaneously. When using multiple, you should add square brackets after the variable name: ''my_variable[]'' as name', 'BOOLEAN', FALSE, TRUE), + ('empty_option', 'Only for inputs of type `select`. Adds an empty option with the given label before the ones defined in `options`. Useful when generating other options from a database table.', 'TEXT', FALSE, TRUE), + ('searchable', 'For select and multiple-select elements, displays them with a nice dropdown that allows searching for options.', 'BOOLEAN', FALSE, TRUE), + ('dropdown', 'An alias for "searchable".', 'BOOLEAN', FALSE, TRUE), + ('create_new', 'In a multiselect with a dropdown, this option allows the user to enter new values, that are not in the list of options.', 'BOOLEAN', FALSE, TRUE), + ('step', 'The increment of values in an input of type number. Set to 1 to allow only integers.', 'REAL', FALSE, TRUE), + ('description', 'A helper text to display near the input field.', 'TEXT', FALSE, TRUE), + ('description_md', 'A helper text to display near the input field - formatted using markdown.', 'TEXT', FALSE, TRUE), + ('pattern', 'A regular expression that the value must match. For instance, [0-9]{3} will only accept 3 digits.', 'TEXT', FALSE, TRUE), + ('autofocus', 'Automatically focus the field when the page is loaded', 'BOOLEAN', FALSE, TRUE), + ('width', 'Width of the form field, between 1 and 12.', 'INTEGER', FALSE, TRUE), + ('autocomplete', 'Whether the browser should suggest previously entered values for this field.', 'BOOLEAN', FALSE, TRUE), + ('minlength', 'Minimum length of text allowed in the field.', 'INTEGER', FALSE, TRUE), + ('maxlength', 'Maximum length of text allowed in the field.', 'INTEGER', FALSE, TRUE), + ('formaction', 'When type is "submit", this specifies the URL of the file that will handle the form submission. Useful when you need multiple submit buttons.', 'TEXT', FALSE, TRUE), + ('class', 'A CSS class to apply to the form element.', 'TEXT', FALSE, TRUE), + ('prefix_icon','Icon to display on the left side of the input field, on the same line.','ICON',FALSE,TRUE), + ('prefix','Text to display on the left side of the input field, on the same line.','TEXT',FALSE,TRUE), + ('suffix','Short text to display after th input, on the same line. Useful to add units or a currency symbol to an input.','TEXT',FALSE,TRUE), + ('readonly','Set to true to prevent the user from modifying the value of the input field.','BOOLEAN',FALSE,TRUE), + ('rows','Number of rows to display for a textarea. Defaults to 3.','INTEGER',FALSE,TRUE), + ('disabled','Makes the field non-editable, non-focusable, and not submitted with the form. Use readonly instead for simple non-editable fields.','BOOLEAN',FALSE,TRUE), + ('id','A unique identifier for the input, which can then be used to select and manage the field with Javascript code. Usefull for advanced using as setting client side event listeners, interactive control of input field (disabled, visibility, read only, e.g.) and AJAX requests.','TEXT',FALSE,TRUE) +) x; +INSERT INTO example(component, description, properties) VALUES + ( + 'form', + ' + +The best way to manage forms in SQLPage is to create at least two separate files: + + - one that will contain the form itself, and will be loaded when the user visits the page, + - one that will handle the form submission, and will redirect to whatever page you want to display after the form has been submitted. + +For instance, if you were creating a form to manage a list of users, you could create: + + - a file named `users.sql` that would contain a list of users and a form to create a new user, + - a file named `create_user.sql` that would insert the new user in the database, and then redirect to `users.sql`. + +`create_user.sql` could contain the following sql statement to [safely](safety.sql) insert the new user in the database: + +```sql +INSERT INTO users(name) VALUES(:username) +RETURNING ''redirect'' AS component, ''users.sql'' AS link +``` + +When loading the page, the value for `:username` will be `NULL` if no value has been submitted. +', + json('[{"component":"form", "action": "create_user.sql"}, {"name": "username"}]')), + ('form', 'A user registration form, illustrating the use of required fields, and different input types.', + json('[{"component":"form", "title": "User", "validate": "Create new user"}, '|| + '{"name": "First name", "placeholder": "John"}, '|| + '{"name": "Last name", "required": true, "description": "We need your last name for legal purposes."},'|| + '{"name": "Resume", "type": "textarea"},'|| + '{"name": "Birth date", "type": "date", "max": "2010-01-01", "value": "1994-04-16"},'|| + '{"name": "Password", "type": "password", "pattern": "^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z\\d]{8,}$", "required": true, "description_md": "**Password Requirements:** Minimum **8 characters**, at least **one letter** & **one number**. *Tip:* Use a passphrase for better security!"},'|| + '{"label": "I accept the terms and conditions", "name": "terms", "type": "checkbox", "required": true}'|| + ']')), + ('form','Create prepended and appended inputs to make your forms easier to use.', + json('[{"component":"form"}, '|| + '{"name": "Your account", "prefix_icon": "mail", "prefix": "Email:", "suffix": "@mydomain.com"}, ' || + ']')), + + ('form','With the header type, you can group your input fields based on a theme. For example, you can categorize fields according to a person''s identity and their contact information.', + json('[{"component":"form","title":"Information about the person"}, '|| + '{"type": "header", "label": "Identity"},' || + '{"name": "Name"},' || + '{"name": "Surname"},' || + '{"type": "header","label": "Contact"},' || + '{"name": "phone", "label": "Phone number"},' || + '{"name": "Email"},' || + ']')), + + ('form','A toggle switch in an HTML form is a user interface element that allows users to switch between two states, typically "on" and "off." It visually resembles a physical switch and is often used for settings or options that can be enabled or disabled.', + json('[{"component":"form"}, + {"type": "switch", "label": "Dark theme", "name": "dark", "description": "Enable dark theme"}, + {"type": "switch", "label": "A required toggle switch", "name": "my_checkbox", "required": true,"checked": true}, + {"type": "switch", "label": "A disabled toggle switch", "name": "my_field", "disabled": true} + ]')), + + ('form', 'This example illustrates the use of the `select` type. +In this select input, the various options are hardcoded, but they could also be loaded from a database table, +[using a function to convert the rows into a json array](/blog.sql?post=JSON%20in%20SQL%3A%20A%20Comprehensive%20Guide) like + - `json_group_array()` in SQLite, + - `json_agg()` in Postgres, + - `JSON_ARRAYAGG()` in MySQL, or + - `FOR JSON PATH` in Microsoft SQL Server. + + +In SQLite, the query would look like +```sql +SELECT + ''select'' as type, + ''Select a fruit...'' as empty_option, + json_group_array(json_object( + ''label'', name, + ''value'', id + )) as options +FROM fruits +``` +', json('[{"component":"form", "action":"examples/show_variables.sql"}, + {"name": "Fruit", "type": "select", + "empty_option": "Select a fruit...", + "options": + "[{\"label\": \"Orange\", \"value\": 0}, {\"label\": \"Apple\", \"value\": 1}, {\"label\": \"Banana\", \"value\": 3}]"} + ]')), + ('form', '### Multi-select +You can authorize the user to select multiple options by setting the `multiple` property to `true`. +This creates a more compact (but arguably less user-friendly) alternative to a series of checkboxes. +In this case, you should add square brackets to the name of the field (e.g. `''my_field[]'' as name`). +The target page will then receive the value as a JSON array of strings, which you can iterate over using + - the `json_each` function [in SQLite](https://www.sqlite.org/json1.html) and [Postgres](https://www.postgresql.org/docs/9.3/functions-json.html), + - the [`OPENJSON`](https://learn.microsoft.com/fr-fr/sql/t-sql/functions/openjson-transact-sql?view=sql-server-ver16) function in Microsoft SQL Server. + - in MySQL, json manipulation is less straightforward: see [the SQLPage MySQL json example](https://github.com/sqlpage/SQLPage/tree/main/examples/mysql%20json%20handling) + +[More information on how to handle JSON in SQL](/blog.sql?post=JSON%20in%20SQL%3A%20A%20Comprehensive%20Guide). + +The target page could then look like this: + +```sql +insert into best_fruits(id) -- INSERT INTO ... SELECT ... runs the SELECT query and inserts the results into the table +select CAST(value AS integer) as id -- all values are transmitted by the browser as strings +from json_each($my_field); -- in SQLite, json_each returns a table with a "value" column for each element in the JSON array +``` + +### Example multiselect generated from a database table + +If you have a table of all possible options (`my_options(id int, label text)`), +and want to generate a multi-select field from it, you have two options: +- if the number of options is not too large, you can use the `options` parameter to return them all as a JSON array in the SQL query +- if the number of options is large (e.g. more than 1000), you can use `options_source` to load options dynamically from a different SQL query as the user types + +#### Embedding all options in the SQL query + +Let''s say you have a table that contains the selected options per user (`my_user_options(user_id int, option_id int)`). +You can use a query like this to generate the multi-select field: + +```sql +select ''select'' as type, true as multiple, json_group_array(json_object( + ''label'', my_options.label, + ''value'', my_options.id, + ''selected'', my_user_options.option_id is not null +)) as options +from my_options +left join my_user_options + on my_options.id = my_user_options.option_id + and my_user_options.user_id = $user_id +``` + +This will generate a json array of objects, each containing the label, value and selected status of each option. + +#### Loading options dynamically from a different SQL query with `options_source` + +If the `my_options` table has a large number of rows, you can use the `options_source` parameter to load options dynamically from a different SQL query as the user types. + +We''ll write a second SQL file, `options_source.sql`, that will receive the user''s search string as a parameter named `$search`, + and return a json array of objects, each containing the label and value of each option. + +When both `options` and `options_source` are set, the local `options` are loaded first. Search results from `options_source` are loaded into the same option list; a result with the same `value` updates the existing option. This is useful to set a default preselected value with `options_source`. + +##### `options_source.sql` + +```sql +select ''json'' as component; + +select id as value, label as label +from my_options +where label like $search || ''%''; +``` + +##### `form` + +', json('[{"component":"form", "action":"examples/show_variables.sql", "reset": "Reset"}, + {"name": "component", "type": "select", + "value": "form", + "options": [{"label": "Form", "value": "form"}], + "options_source": "examples/from_component_options_source.sql", + "description": "Start typing the name of a component like ''map'' or ''form''..." + }]')), + ('form', 'This example illustrates the use of the `radio` type. +The `name` parameter is used to group the radio buttons together. +The `value` parameter is used to set the value that will be submitted when the user selects the radio button. +The `label` parameter is used to display a friendly name for the radio button. +The `description` parameter is used to display a helper text near the radio button. + +We could also save all the options in a database table, and then run a simple query like + +```sql +SELECT ''form'' AS component; +SELECT + ''radio'' as type, + ''db'' as name, + option_name as label, + option_id as value +FROM my_options; +``` + +In this example, depending on what the user clicks, the page will be reloaded with a the variable `$component` set to the string "form", "map", or "chart". + + ', json('[{"component":"form", "method": "GET"}, + {"name": "component", "type": "radio", "value": "form", "description": "Read user input in SQL", "label": "Form"}, + {"name": "component", "type": "radio", "value": "map", "checked": true, "description": "Display a map based on database data", "label": "Map"}, + {"name": "component", "type": "radio", "value": "chart", "description": "Interactive plots of SQL query results", "label": "Chart"} + ]')), + ('form', ' +### Dynamically refresh the page when the user changes the form + +The form will be automatically submitted when the user changes any of its fields, and the page will be reloaded with the new value. +The validation button is removed. +', json('[{"component":"form", "auto_submit": true}, + {"name": "component", "type": "select", "autocomplete": false, "options": [ + {"label": "Form", "value": "form", "selected": true}, + {"label": "Map", "value": "map"}, + {"label": "Chart", "value": "chart"} + ], "description": "Choose a component to view its documentation. No need to click a button, the page will be reloaded automatically.", "label": "Component"} + ]')), + ('form', 'When you want to include some information in the form data, but not display it to the user, you can use a hidden field. + +This can be used to track simple data such as the current user''s id, +or to implement more complex flows, such as a multi-step form, +where the user is redirected to a different page after each step. + +This can also be used to implement [CSRF protection](https://en.wikipedia.org/wiki/Cross-site_request_forgery#Synchronizer_token_pattern), +if your website has authenticated users that can perform sensitive actions through simple links. +But note that SQLPage cookies already have the `SameSite=strict` attribute by default, which protects you against CSRF attacks by default in most cases. + +', json('[{"component":"form", "validate": "Delete", "validate_color": "red"}, + {"type": "hidden", "name": "resource_id", "value": "1234"}, + {"name": "confirm", "label": "Please type \"sensitive resource\" here to confirm the deletion", "required": true} + ]')), + ('form', 'This example illustrates the use of custom validation buttons and half-width fields.', + json('[{"component":"form", "title": "User", "validate": "Create new user", "validate_color": "green", "reset": "Clear"}, + {"name": "first_name", "label": "First name", "placeholder": "John", "width": 4}, + {"name": "middle_name", "label": "Middle name", "placeholder": "Fitzgerald", "width": 4}, + {"name": "last_name", "label": "Last name", "placeholder": "Doe", "width": 4}, + {"name": "email", "label": "Email", "placeholder": "john.doe@gmail.com", "width": 12}, + {"name": "password", "label": "Password", "type": "password", "width": 6}, + {"name": "password_confirmation", "label": "Password confirmation", "type": "password", "width": 6}, + {"name": "terms", "label": "I accept the terms and conditions", "type": "checkbox", "required": true} + ]')), + ('form', ' +## File upload + +You can use the `file` type to allow the user to upload a file. + +The file will be uploaded to the server, and you will be able to access it using the +[`sqlpage.uploaded_file_path`](functions.sql?function=uploaded_file_path#function) function. + +Here is how you could save the uploaded file to a table in the database: + +```sql +INSERT INTO uploaded_file(name, data) +VALUES ( + :filename, + sqlpage.read_file_as_data_url(sqlpage.uploaded_file_path(''my_file'')) +) +``` +', + json('[{"component":"form", "enctype": "multipart/form-data", "title": "Upload a picture", "validate": "Upload", "action": "examples/handle_picture_upload.sql"}, + {"name": "my_file", "type": "file", "accept": "image/png, image/jpeg", "label": "Picture", "description": "Upload a small picture", "required": true} + ]')), + ('form', ' +## Form Encoding + +You can specify the way form data should be encoded by setting the `enctype` +top-level property on the form. + +You may also specify `formenctype` on `submit` and `image` type inputs. +This will take precedence over the `enctype` specified on the form and is +useful in the case there are multiple `submit` buttons on the form. +For example, an external site may have specific requirements on encoding type. + +As a rule of thumb, ``multipart/form-data`` is best when fields may contain +copious non-ascii characters or for binary data such as an image or a file. +However, ``application/x-www-form-urlencoded`` creates less overhead when +many short ascii text values are submitted. +', + json('[ + { + "component": "form", + "method": "post", + "enctype": "multipart/form-data", + "title": "Submit with different encoding types", + "validate": "Submit with form encoding type", + "action": "examples/handle_enctype.sql" + }, + {"name": "data", "type": "text", "label": "Data", "required": true}, + { + "name": "percent_encoded", + "type": "submit", + "label": "Submit as", + "width": 4, + "formaction": "examples/handle_enctype.sql", + "formenctype": "application/x-www-form-urlencoded", + "value": "application/x-www-form-urlencoded" + }, + { + "name": "multipart_form_data", + "type": "submit", + "label": "Submit as", + "width": 4, + "formaction": "examples/handle_enctype.sql", + "formenctype": "multipart/form-data", + "value": "multipart/form-data" + } +]')), + ('form', ' +## Bulk data insertion + +You can use the `file` type to allow the user to upload a [CSV](https://en.wikipedia.org/wiki/Comma-separated_values) +file containing data to insert in a table. + +SQLPage can load data from a CSV file and insert it into a database table. +SQLPage re-uses PostgreSQL''s [`COPY` syntax](https://www.postgresql.org/docs/current/sql-copy.html) +to specify the format of the CSV file, but makes it work with all supported databases. + +> When connected to a PostgreSQL database, SQLPage will use the native `COPY` statement, +> for super fast and efficient on-database CSV parsing. +> But it will also work transparently with other databases, by +> parsing the CSV locally and emulating the same behavior with simple `INSERT` statements. + +Here is how you could easily copy data from a CSV to a table in the database: + +```sql +copy product(name, description) from ''product_data_input'' +with (header true, delimiter '','', quote ''"''); +``` + +If you want to pre-process the data before inserting it into the final table, +you can use a temporary table to store the data, and then insert it into the final table: + +```sql +-- temporarily store the data in a table with text columns +create temporary table if not exists product_tmp(name text, description text, price text); +delete from product_tmp; + +-- copy the data from the CSV file into the temporary table +copy product_tmp(name, description, price) from ''product_data_input''; + +-- insert the data into the final table, converting the price column to an integer +insert into product(name, description, price) +select name, description, CAST(price AS integer) from product_tmp +where price is not null and description is not null and length(description) > 10; +``` + +This will load the processed CSV into the product table, provided it has the following structure: + +```csv +name,description,price +"SQLPage","A tool to create websites using SQL",0 +"PostgreSQL","A powerful open-source relational database",0 +"SQLite","A lightweight relational database",0 +"MySQL","A popular open-source relational database",0 +``` +', + json('[{"component":"form", "title": "CSV import", "validate": "Load data", "action": "examples/handle_csv_upload.sql"}, + {"name": "product_data_input", "type": "file", "accept": "text/csv", "label": "Products", "description": "Upload a CSV with a name, description, and price columns", "required": true} + ]')) +; + +INSERT INTO component(name, icon, description) VALUES + ('chart', 'timeline', 'A component that plots data. Line, area, bar, and pie charts are all supported. Each item in the component is a data point in the graph.'); + +INSERT INTO parameter(component, name, description, type, top_level, optional) SELECT 'chart', * FROM (VALUES + -- top level + ('title', 'The name of the chart.', 'TEXT', TRUE, TRUE), + ('type', 'The type of chart. One of: "line", "area", "bar", "column", "pie", "scatter", "bubble", "heatmap", "rangeBar"', 'TEXT', TRUE, FALSE), + ('time', 'Whether the x-axis represents time. If set to true, the x values will be parsed and formatted as dates for the user.', 'BOOLEAN', TRUE, TRUE), + ('xmin', 'The minimal value for the x-axis. When time is true, this can be a date or timestamp.', 'TEXT', TRUE, TRUE), + ('xmax', 'The maximum value for the x-axis. When time is true, this can be a date or timestamp.', 'TEXT', TRUE, TRUE), + ('ymin', 'The minimal value for the y-axis.', 'REAL', TRUE, TRUE), + ('ymax', 'The maximum value for the y-axis.', 'REAL', TRUE, TRUE), + ('xtitle', 'Title of the x axis, displayed below it.', 'TEXT', TRUE, TRUE), + ('ytitle', 'Title of the y axis, displayed to its left.', 'TEXT', TRUE, TRUE), + ('ztitle', 'Title of the z axis, displayed in tooltips.', 'TEXT', TRUE, TRUE), + ('xticks', 'Number of ticks on the x axis.', 'INTEGER', TRUE, TRUE), + ('ystep', 'Step between ticks on the y axis.', 'REAL', TRUE, TRUE), + ('marker', 'Marker size', 'REAL', TRUE, TRUE), + ('labels', 'Whether to show the data labels on the chart or not.', 'BOOLEAN', TRUE, TRUE), + ('color', 'The name of a color in which to display the chart. If there are multiple series in the chart, this parameter can be repeated multiple times.', 'COLOR', TRUE, TRUE), + ('stacked', 'Whether to cumulate values from different series.', 'BOOLEAN', TRUE, TRUE), + ('toolbar', 'Whether to display a toolbar at the top right of the chart, that offers downloading the data as CSV.', 'BOOLEAN', TRUE, TRUE), + ('show_legend', 'Whether to display the legend listing all chart series. Defaults to true.', 'BOOLEAN', TRUE, TRUE), + ('logarithmic', 'Display the y-axis in logarithmic scale.', 'BOOLEAN', TRUE, TRUE), + ('horizontal', 'Displays a bar chart with horizontal bars instead of vertical ones.', 'BOOLEAN', TRUE, TRUE), + ('height', 'Height of the chart, in pixels. By default: 250', 'INTEGER', TRUE, TRUE), + -- item level + ('x', 'The value of the point on the horizontal axis', 'REAL', FALSE, FALSE), + ('y', 'The value of the point on the vertical axis', 'REAL', FALSE, FALSE), + ('label', 'An alias for parameter "x"', 'REAL', FALSE, TRUE), + ('value', 'An alias for parameter "y"', 'REAL', FALSE, TRUE), + ('series', 'If multiple series are represented and share the same y-axis, this parameter can be used to distinguish between them.', 'TEXT', FALSE, TRUE) +) x; +INSERT INTO example(component, description, properties) VALUES + ('chart', 'An area chart representing a time series, using the top-level property `time`. + Ticks on the x axis are adjusted automatically, and ISO datetimes are parsed and displayed in a readable format.', json('[ + { + "component": "chart", + "title": "Quarterly Revenue", + "type": "area", + "color": "blue-lt", + "marker": 5, + "time": true + }, + {"x":"2022-01-01T00:00:00Z","y":15}, + {"x":"2022-04-01T00:00:00Z","y":46}, + {"x":"2022-07-01T00:00:00Z","y":23}, + {"x":"2022-10-01T00:00:00Z","y":70}, + {"x":"2023-01-01T00:00:00Z","y":35}, + {"x":"2023-04-01T00:00:00Z","y":106}, + {"x":"2023-07-01T00:00:00Z","y":53} + ]')), + ('chart', 'A pie chart.', json('[{"component":"chart", "title": "Answers", "type": "pie", "labels": true}, + {"label": "Yes", "value": 65}, + {"label": "No", "value": 35}]')), + ('chart', 'A basic bar chart', json('[ + {"component":"chart", "type": "bar", "title": "Quarterly Results", "horizontal": true, "labels": true}, + {"label": "Tom", "value": 35}, {"label": "Olive", "value": 15}]')), + ('chart', 'A TreeMap Chart allows you to display hierarchical data in a nested layout. This is useful for visualizing the proportion of each part to the whole.', + json('[ + {"component":"chart", "type": "treemap", "title": "Quarterly Results By Region (in k$)", "labels": true }, + {"series": "North America", "label": "United States", "value": 35}, + {"series": "North America", "label": "Canada", "value": 15}, + {"series": "Europe", "label": "France", "value": 30}, + {"series": "Europe", "label": "Germany", "value": 55}, + {"series": "Asia", "label": "China", "value": 20}, + {"series": "Asia", "label": "Japan", "value": 10} + ]')), + ('chart', 'A bar chart with multiple series.', json('[{"component":"chart", "title": "Expenses", "type": "bar", "stacked": true, "toolbar": true, "show_legend": false, "ystep": 10}, '|| + '{"series": "Marketing", "x": 2021, "value": 35}, '|| + '{"series": "Marketing", "x": 2022, "value": 15}, '|| + '{"series": "Human resources", "x": 2021, "value": 30}, '|| + '{"series": "Human resources", "x": 2022, "value": 55}]')), + ('chart', 'A line chart with multiple series. One of the most common types of charts, often used to show trends over time. +Also demonstrates the use of the `toolbar` attribute to allow the user to download the graph as an image or the data as a CSV file.', + json('[{"component":"chart", "title": "Revenue", "ymin": 0, "toolbar": true}, + {"series": "Chicago Store", "x": 2021, "value": 35}, + {"series": "Chicago Store", "x": 2022, "value": 15}, + {"series": "Chicago Store", "x": 2023, "value": 45}, + {"series": "New York Store", "x": 2021, "value": 30}, + {"series": "New York Store", "x": 2022, "value": 55}, + {"series": "New York Store", "x": 2023, "value": 19} + ]')), + ('chart', 'A scatter plot with multiple custom options.', + json('[ + {"component":"chart", "title": "Gross domestic product and its growth", "type": "scatter", + "xtitle": "Growth Rate", "ytitle": "GDP (Trillions USD)", "height": 500, "marker": 8, + "xmin": 0, "xmax": 10, "ymin": 0, "ymax": 25, "yticks": 5}, + + {"series": "Brazil", "x": 2.5, "y": 2}, + {"series": "China", "x": 6.5, "y": 14}, + {"series": "United States", "x": 2.3, "y": 21}, + {"series": "France", "x": 1.5, "y": 3}, + {"series": "South Africa", "x": 0.9, "y": 0.3} + ]')), + ('chart', ' +## Heatmaps + +You can build heatmaps using the `heatmap` top-level property. + +The data format follows the [apexcharts heatmap format](https://apexcharts.com/angular-chart-demos/heatmap-charts/basic/), +where each series is represented as a line in the chart: + - The `x` property of each item will be used as the x-axis value. + - The `series` property of each item will be used as the y-axis value. + - The `y` property of each item will be used as the value to display in the heatmap + +The `color` property sets the color of each series separately, in order. +',json('[ + {"component":"chart", "title": "Survey Results", "type": "heatmap", + "ytitle": "Database managemet system", "xtitle": "Year", "color": ["purple","purple","purple"]}, + { "series": "PostgreSQL", "x": "2000", "y": 48},{ "series": "SQLite", "x": "2000", "y": 44},{ "series": "MySQL", "x": "2000", "y": 78}, + { "series": "PostgreSQL", "x": "2010", "y": 65},{ "series": "SQLite", "x": "2010", "y": 62},{ "series": "MySQL", "x": "2010", "y": 83}, + { "series": "PostgreSQL", "x": "2020", "y": 73},{ "series": "SQLite", "x": "2020", "y": 38},{ "series": "MySQL", "x": "2020", "y": 87} + ]')), + ('chart', 'A timeline displaying events with a start and an end date', + json('[ + {"component":"chart", "title": "Project Timeline", "type": "rangeBar", "time": true, "color": ["teal", "cyan"], "labels": true, "xmin": "2021-12-28", "xmax": "2022-01-04" }, + {"series": "Phase 1", "label": "Operations", "value": ["2021-12-29", "2022-01-02"]}, + {"series": "Phase 2", "label": "Operations", "value": ["2022-01-03", "2022-01-04"]}, + {"series": "Yearly maintenance", "label": "Maintenance", "value": ["2022-01-01", "2022-01-03"]} + ]')), + ('chart', ' +## Multiple charts on the same line + +You can create information-dense dashboards by using the [card component](?component=card#component) +to put multiple charts on the same line. + +For this, create one sql file per visualization you want to show, +and set the `embed` attribute of the [card](?component=card#component) component +to the path of the file you want to include, followed by `?_sqlpage_embed`. +', + json('[ + {"component":"card", "title":"A dashboard with multiple graphs on the same line", "columns": 2}, + {"embed": "/examples/chart.sql?color=green&n=42&_sqlpage_embed"}, + {"embed": "/examples/chart.sql?_sqlpage_embed" } + ]')); + +INSERT INTO component(name, icon, description) VALUES + ('table', 'table', 'A table with optional filtering and sorting. +Unlike most others, this component does not have a fixed set of item properties, any property that is used will be rendered directly as a column in the table. +Tables can contain rich text, including images, links, and icons. Table rows can be styled with a background color, and the table can be made striped, hoverable, and bordered. + +Advanced users can apply custom styles to table columns using a CSS class with the same name as the column, and to table rows using the `_sqlpage_css_class` property. +'); + +INSERT INTO parameter(component, name, description, type, top_level, optional) SELECT 'table', * FROM (VALUES + -- top level + ('sort', 'Make the columns clickable to let the user sort by the value contained in the column.', 'BOOLEAN', TRUE, TRUE), + ('search', 'Add a search bar at the top of the table, letting users easily filter table rows by value.', 'BOOLEAN', TRUE, TRUE), + ('initial_search_value', 'Pre-fills the search bar used to filter the table. The user will still be able to edit the value to display table rows that will initially be filtered out.', 'TEXT', TRUE, TRUE), + ('search_placeholder', 'Customizes the placeholder text shown in the search input field. Replaces the default "Search..." with text that better describes what users should search for.', 'TEXT', TRUE, TRUE), + ('markdown', 'Set this to the name of a column whose content should be interpreted as markdown . Used to display rich text with links in the table. This argument can be repeated multiple times to intepret multiple columns as markdown.', 'TEXT', TRUE, TRUE), + ('icon', 'Set this to the name of a column whose content should be interpreted as a tabler icon name. Used to display icons in the table. This argument can be repeated multiple times to intepret multiple columns as icons. Introduced in v0.8.0.', 'TEXT', TRUE, TRUE), + ('align_right', 'Name of a column the contents of which should be right-aligned. This argument can be repeated multiple times to align multiple columns to the right. Introduced in v0.15.0.', 'TEXT', TRUE, TRUE), + ('align_center', 'Name of a column the contents of which should be center-aligned. This argument can be repeated multiple times to align multiple columns to the center.', 'TEXT', TRUE, TRUE), + ('monospace', 'Name of a column the contents of which should be displayed in monospace. This argument can be repeated multiple times to display multiple columns in monospace. Introduced in v0.32.1.', 'TEXT', TRUE, TRUE), + ('striped_rows', 'Whether to add zebra-striping to any table row.', 'BOOLEAN', TRUE, TRUE), + ('striped_columns', 'Whether to add zebra-striping to any table column.', 'BOOLEAN', TRUE, TRUE), + ('hover', 'Whether to enable a hover state on table rows.', 'BOOLEAN', TRUE, TRUE), + ('border', 'Whether to draw borders on all sides of the table and cells.', 'BOOLEAN', TRUE, TRUE), + ('overflow', 'Whether to to let "wide" tables overflow across the right border and enable browser-based horizontal scrolling.', 'BOOLEAN', TRUE, TRUE), + ('small', 'Whether to use compact table.', 'BOOLEAN', TRUE, TRUE), + ('description','Description of the table contents. Helps users with screen readers to find a table and understand what it’s about.','TEXT',TRUE,TRUE), + ('empty_description', 'Text to display if the table does not contain any row. Defaults to "no data".', 'TEXT', TRUE, TRUE), + ('freeze_columns', 'Whether to freeze the leftmost column of the table.', 'BOOLEAN', TRUE, TRUE), + ('freeze_headers', 'Whether to freeze the top row of the table.', 'BOOLEAN', TRUE, TRUE), + ('freeze_footers', 'Whether to freeze the footer (bottom row) of the table, only works if that row has the `_sqlpage_footer` property applied to it.', 'BOOLEAN', TRUE, TRUE), + ('raw_numbers', 'Name of a column whose values are numeric, but should be displayed as raw numbers without any formatting (no thousands separators, decimal separator is always a dot). This argument can be repeated multiple times.', 'TEXT', TRUE, TRUE), + ('money', 'Name of a numeric column whose values should be displayed as currency amounts, in the currency defined by the `currency` property. This argument can be repeated multiple times.', 'TEXT', TRUE, TRUE), + ('currency', 'The ISO 4217 currency code (e.g., USD, EUR, GBP, etc.) to use when formatting monetary values.', 'TEXT', TRUE, TRUE), + ('number_format_digits', 'Maximum number of decimal digits to display for numeric values.', 'INTEGER', TRUE, TRUE), + ('edit_url', 'If set, an edit button will be added to each row. The value of this property should be a URL, possibly containing the `{id}` placeholder that will be replaced by the value of the `_sqlpage_id` property for that row. Clicking the edit button will take the user to that URL. Added in v0.39.0', 'TEXT', TRUE, TRUE), + ('delete_url', 'If set, a delete button will be added to each row. The value of this property should be a URL, possibly containing the `{id}` placeholder that will be replaced by the value of the `_sqlpage_id` property for that row. Clicking the delete button will take the user to that URL. Added in v0.39.0', 'TEXT', TRUE, TRUE), + ('custom_actions', 'If set, a column of custom action buttons will be added to each row. The value of this property should be a JSON array of objects, each object defining a button with the following properties: `name` (the text to display on the button), `icon` (the tabler icon name or image link to display on the button), `link` (the URL to navigate to when the button is clicked, possibly containing the `{id}` placeholder that will be replaced by the value of the `_sqlpage_id` property for that row), and `tooltip` (optional text to display when hovering over the button). Added in v0.39.0', 'JSON', TRUE, TRUE), + -- row level + ('_sqlpage_css_class', 'For advanced users. Sets a css class on the table row. Added in v0.8.0.', 'TEXT', FALSE, TRUE), + ('_sqlpage_color', 'Sets the background color of the row. Added in v0.8.0.', 'COLOR', FALSE, TRUE), + ('_sqlpage_footer', 'Sets this row as the table footer. It is recommended that this parameter is applied to the last row. Added in v0.34.0.', 'BOOLEAN', FALSE, TRUE), + ('_sqlpage_id', 'Sets the id of the html tabler row element. Allows you to make links targeting a specific row in a table.', 'TEXT', FALSE, TRUE), + ('_sqlpage_actions', 'Sets custom action buttons for this specific row in addition to any defined at the table level, The value of this property should be a JSON array of objects, each object defining a button with the following properties: `name` (the text to display on the button), `icon` (the tabler icon name or image link to display on the button), `link` (the URL to navigate to when the button is clicked, possibly containing the `{id}` placeholder that will be replaced by the value of the `_sqlpage_id` property for that row), and `tooltip` (optional text to display when hovering over the button). Added in v0.39.0', 'JSON', FALSE, TRUE) +) x; + +INSERT INTO example(component, description, properties) VALUES + ('table', 'The most basic table.', + json('[{"component":"table"}, {"a": 1, "b": 2}, {"a": 3, "b": 4}]')), + ('table', 'A table of users with filtering and sorting.', + json('[ + {"component":"table", "sort":true, "search":true, "search_placeholder": "Filter by name"}, + {"First Name": "Ophir", "Last Name": "Lojkine", "Pseudonym": "lovasoa"}, + {"First Name": "Linus", "Last Name": "Torvalds", "Pseudonym": "torvalds"} + ]')), + ('table', 'A table that uses markdown to display links', + json('[{"component":"table", "markdown": "Name", "icon": "icon", "search": true}, '|| + '{"icon": "table", "name": "[Table](?component=table)", "description": "Displays SQL results as a searchable table.", "_sqlpage_color": "red"}, + {"icon": "timeline", "name": "[Chart](?component=chart)", "description": "Show graphs based on numeric data."} + ]')), + ('table', 'A sortable table with a colored footer showing the average value of its entries.', + json('[{"component":"table", "sort":true}, '|| + '{"Person": "Rudolph Lingens", "Height": 190},' || + '{"Person": "Jane Doe", "Height": 150},' || + '{"Person": "John Doe", "Height": 200},' || + '{"_sqlpage_footer":true, "_sqlpage_color": "green", "Person": "Average", "Height": 180}]')), + ( + 'table', + 'A table with column sorting. Sorting sorts numbers in numeric order, and strings in alphabetical order. + +Numbers can be displayed + - as raw digits without formatting using the `raw_numbers` property, + - as currency using the `money` property to define columns that contain monetary values and `currency` to define the currency, + - as numbers with a fixed maximum number of decimal digits using the `number_format_digits` property. +', + json( + '[{"component":"table", "sort": true, "align_right": ["Price", "Amount in stock"], "align_center": ["part_no"], "raw_numbers": ["id"], "currency": "USD", "money": ["Price"] }, + {"id": 31456, "part_no": "SQL-TABLE-856-G", "Price": 12, "Amount in stock": 5}, + {"id": 996, "part_no": "SQL-FORMS-86-M", "Price": 1, "Amount in stock": 1234}, + {"id": 131456, "part_no": "SQL-CARDS-56-K", "Price": 127, "Amount in stock": 98} + ]' + )), + ( + 'table', + 'A table with some presentation options', + json( + '[{"component":"table", + "hover": true, "striped_rows": true, + "description": "Some Star Trek Starfleet starships", + "small": true, "initial_search_value": "NCC-" + }, + {"name": "USS Enterprise", "registry": "NCC-1701-C", "class":"Ambassador"}, + {"name": "USS Archer", "registry": "NCC-44278", "class":"Archer"}, + {"name": "USS Endeavour", "registry": "NCC-06", "class":"Columbia"}, + {"name": "USS Constellation", "registry": "NCC-1974", "class":"Constellation"}, + {"name": "USS Dakota", "registry": "NCC-63892", "class":"Akira"}, + {"name": "USS Defiant", "registry": "IX-74205", "class":"Defiant"} + ]' + )), + ( + 'table', + 'An empty table with a friendly message', + json('[{"component":"table", "empty_description": "Nothing to see here at the moment."}]') + ), + ( + 'table', + 'A large table with many rows and columns, with frozen columns on the left and headers on top. This allows users to browse large datasets without loosing track of their position.', + json('[ + {"component": "table", "freeze_columns": true, "freeze_headers": true}, + { + "feature": "SQL Execution", + "description": "Fully compatible with existing databases SQL dialects, executes any SQL query.", + "benefits": "Short learning curve, easy to use, interoperable with existing tools." + }, + { + "feature": "Data Visualization", + "description": "Automatic visualizations of query results: graphs, plots, pie charts, heatmaps, etc.", + "benefits": "Quickly analyze data trends, attractive and easy to understand, no external visualization tools or languages to learn." + }, + { + "feature": "User Authentication", + "description": "Supports user sessions, from basic auth to single sign-on.", + "benefits": "Secure, enforces access control policies, and provides a customizable security layer." + }, + { + "feature": "APIs", + "description": "Allows building JSON REST APIs and integrating with external APIs.", + "benefits": "Enables automation and integration with other platforms, facilitates data exchange." + }, + { + "feature": "Files", + "description": "File uploads, downloads and processing. Supports local filesystem and database storage.", + "benefits": "Convenient file management, secure data handling, flexible storage options, integrates with existing systems." + }, + { + "feature": "Maps", + "description": "Supports GeoJSON and is compatible with GIS data for map visualization.", + "benefits": "Geospatial data representation, integrates with geographic information systems." + }, + { + "feature": "Custom Components", + "description": "Build advanced features using HTML, JavaScript, and CSS.", + "benefits": "Tailor-made user experiences, easy to implement custom UI requirements." + }, + { + "feature": "Forms", + "description": "Insert and update data in databases based on user input.", + "benefits": "Simplified data input and management, efficient user interactions with databases." + }, + { + "feature": "DB Compatibility", + "description": "Works with MySQL, PostgreSQL, SQLite, Microsoft SQL Server and compatible databases.", + "benefits": "Broad compatibility with popular database systems, ensures seamless integration." + }, + { + "feature": "Security", + "description": "Built-in protection against common web vulnerabilities: no SQL injection, no XSS.", + "benefits": "Passes audits and security reviews, reduces the risk of data breaches." + }, + { + "feature": "Performance", + "description": "Designed for performance, with a focus on efficient data processing and minimal overhead.", + "benefits": "Quickly processes large datasets, handles high volumes of requests, and minimizes server load." + }, + { + "_sqlpage_footer": true, + "feature": "Summary", + "description": "Summarizes the features of the product.", + "benefits": "Provides a quick overview of the product''s features and benefits." + } +]') + ), + ( + 'table', + '# Dynamic column names in a table + +In all the previous examples, the column names were hardcoded in the SQL query. +This makes it very easy to quickly visualize the results of a query as a table, +but it can be limiting if you want to include columns that are not known in advance. +In situations when the number and names of the columns depend on the data, or on variables, +you can use the `dynamic` component to generate the table columns dynamically. + +For that, you will need to return JSON objects from your SQL query, where the keys are the column names, +and the values are the cell contents. + +Databases [offer utilities to generate JSON objects from query results](/blog.sql?post=JSON%20in%20SQL%3A%20A%20Comprehensive%20Guide) + - In PostgreSQL, you can use the [`json_build_object`](https://www.postgresql.org/docs/current/functions-json.html#FUNCTIONS-JSON-PROCESSING) +function for a fixed number of columns, or [`json_object_agg`](https://www.postgresql.org/docs/current/functions-aggregate.html#FUNCTIONS-AGGREGATE) for a dynamic number of columns. + - In SQLite, you can use the [`json_object`](https://www.sqlite.org/json1.html) function for a fixed number of columns, +or the `json_group_object` function for a dynamic number of columns. + - In MySQL, you can use the [`JSON_OBJECT`](https://dev.mysql.com/doc/refman/8.0/en/json-creation-functions.html#function_json-object) function for a fixed number of columns, +or the [`JSON_OBJECTAGG`](https://dev.mysql.com/doc/refman/8.4/en/aggregate-functions.html#function_json-objectagg) function for a dynamic number of columns. + - In Microsoft SQL Server, you can use the [`FOR JSON PATH`](https://docs.microsoft.com/en-us/sql/relational-databases/json/format-query-results-as-json-with-for-json-sql-server?view=sql-server-ver15) clause. + +For instance, let''s say we have a table with three columns: store, item, and quantity_sold. +We want to create a pivot table where each row is a store, and each column is an item. +We will return a set of json objects that look like this: `{"store":"Madrid", "Item1": 42, "Item2": 7, "Item3": 0}` + +```sql +SELECT ''table'' AS component; +with filled_data as ( + select + stores.store, items.item, + (select coalesce(sum(quantity_sold), 0) from store_sales where store=stores.store and item=items.item) as quantity + from (select distinct store from store_sales) as stores + cross join (select distinct item from store_sales) as items + order by stores.store, items.item +) +SELECT + ''dynamic'' AS component, + JSON_PATCH( -- SQLite-specific, refer to your database documentation for the equivalent JSON functions + JSON_OBJECT(''store'', store), + JSON_GROUP_OBJECT(item, quantity) + ) AS properties +FROM + filled_data +GROUP BY + store; +``` + +This will generate a table with the stores in the first column, and the items in the following columns, with the quantity sold in each store for each item. + +', NULL + ), + ( + 'table', +'## Using Action Buttons in a table. + +### Preset Actions: `edit_url` & `delete_url` +Since edit and delete are common actions, the `table` component has dedicated `edit_url` and `delete_url` properties to add buttons for these actions. +The value of these properties should be a URL, containing the `{id}` placeholder that will be replaced by the value of the `_sqlpage_id` property for that row. + +### Column with fixed action buttons + +You may want to add custom action buttons to your table rows, for instance to view details, download a file, or perform a custom operation. +For this, the `table` component has a `custom_actions` top-level property that lets you define a column of buttons, each button defined by a name, an icon, a link, and an optional tooltip. + +### Column with variable action buttons + +The `table` component also supports the row level `_sqlpage_actions` column in your data table. +This is helpful if you want a more complex logic, for instance to disable a button on some rows, or to change the link or icon based on the row data. + +> WARNING! +> If the number of array items in `_sqlpage_actions` is not consistent across all rows, the table may not render correctly. +> You can leave blank spaces by including an object with only the `name` property. + +The table has a column of buttons, each button defined by the `custom_actions` column at the table level, and by the `_sqlpage_actions` property at the row level. + +### `custom_actions` & `_sqlpage_actions` JSON properties. + +Each button is defined by the following properties: +* `name`: sets the column header and the tooltip if no tooltip is provided, +* `tooltip`: text to display when hovering over the button, +* `link`: the URL to navigate to when the button is clicked, possibly containing the `{id}` placeholder that will be replaced by the value of the `_sqlpage_id` property for that row, +* `icon`: the tabler icon name or image link to display on the button + +### Example using all of the above +' + , + json('[ + { + "component": "table", + "edit_url": "/examples/show_variables.sql?action=edit&update_id={id}", + "delete_url": "/examples/show_variables.sql?action=delete&delete_id={id}", + "custom_actions": { + "name": "history", + "tooltip": "View Standard History", + "link": "/examples/show_variables.sql?action=history&standard_id={id}", + "icon": "history" + } + }, + { + "name": "CalStd", + "vendor": "PharmaCo", + "Product": "P1234", + "lot number": "T23523", + "status": "Available", + "expires on": "2026-10-13", + "_sqlpage_id": 32, + "_sqlpage_actions": [ + { + "name": "View PDF", + "tooltip": "View Presentation", + "link": "https://sql-page.com/pgconf/2024-sqlpage-badass.pdf", + "icon": "file-type-pdf" + }, + { + "name": "Action", + "tooltip": "Set In Use", + "link": "/examples/show_variables.sql?action=set_in_use&standard_id=32", + "icon": "caret-right" + } + ] + }, + { + "name": "CalStd", + "vendor": "PharmaCo", + "Product": "P1234", + "lot number": "T2352", + "status": "In Use", + "expires on": "2026-10-14", + "_sqlpage_id": 33, + "_sqlpage_actions": [ + { + "name": "View PDF", + "tooltip": "View Presentation", + "link": "https://sql-page.com/pgconf/2024-sqlpage-badass.pdf", + "icon": "file-type-pdf" + }, + { + "name": "Action", + "tooltip": "Retire Standard", + "link": "/examples/show_variables.sql?action=retire&standard_id=33", + "icon": "test-pipe-off" + } + ] + }, + { + "name": "CalStd", + "vendor": "PharmaCo", + "Product": "P1234", + "lot number": "A123", + "status": "Discarded", + "expires on": "2026-09-30", + "_sqlpage_id": 31, + "_sqlpage_actions": [ + { + "name": "View PDF", + "tooltip": "View Presentation", + "link": "https://sql-page.com/pgconf/2024-sqlpage-badass.pdf", + "icon": "file-type-pdf" + }, + { + "name": "Action" + } + ] + } +]' +) +); + + + +INSERT INTO component(name, icon, description) VALUES + ('csv', 'download', 'Lets the user download data as a CSV file. +Each column from the items in the component will map to a column in the resulting CSV. + +When `csv` is used as a **header component** (without a [shell](?component=shell)), it will trigger a download of the CSV file directly on page load. +If the csv file to download is large, we recommend using this approach. + +When used inside a page (after calling the shell component), this will add a button to the page that lets the user download the CSV file. +The button will need to load the entire contents of the CSV file in memory, inside the browser, even if the user does not click on it. +If the csv file to download is large, we recommend using this component without a shell component in order to efficiently stream the data to the browser. +'); + +INSERT INTO parameter(component, name, description, type, top_level, optional) SELECT 'csv', * FROM (VALUES + -- top level + ('separator', 'How individual values should be separated in the CSV. "," by default, set it to "\t" for tab-separated values.', 'TEXT', TRUE, TRUE), + ('title', 'The text displayed on the download button.', 'TEXT', TRUE, FALSE), + ('filename', 'The name of the file that should be downloaded (without the extension).', 'TEXT', TRUE, TRUE), + ('icon', 'Name of the icon (from tabler-icons.io) to display in the button. Ignored when used as a header component.', 'ICON', TRUE, TRUE), + ('color', 'Color of the button. Ignored when used as a header component.', 'COLOR', TRUE, TRUE), + ('size', 'The size of the button (e.g., sm, lg). Ignored when used as a header component.', 'TEXT', TRUE, TRUE), + ('bom', 'Whether to include a Byte Order Mark (a special character indicating the character encoding) at the beginning of the file. This is useful for Excel compatibility.', 'BOOLEAN', TRUE, TRUE) +) x; + +INSERT INTO example(component, description, properties) VALUES + ('csv', ' +### Header component: creating a CSV download URL + +You can create a page that will trigger a download of the CSV file when the user visits it. +The contents will be streamed efficiently from the database to the browser, without being fully loaded in memory. +This makes it possible to download even very large files without overloading the database server, the web server, or the client''s browser. + +#### `csv_download.sql` + +```sql +select ''csv'' as component, ''example.csv'' as filename; +SELECT * FROM my_large_table; +``` + +#### `index.sql` +', + json('[{"component":"button"}, {"title": "Download my data", "link": "/examples/csv_download.sql"}]')), + ('csv', ' +### CSV download button + +This will generate a button to download the CSV file. +The button element itself will embed the entire contents of the CSV file, so it should not be used for large files. +The file will be entirely loaded in memory on the user''s browser, even if the user does not click on the button. +For smaller files, this is easier and faster to use than creating a separate SQL file to generate the CSV. +', + json('[{"component":"csv", "title": "Download my data", "filename": "people", "icon": "file-download", "color": "green", "separator": ";", "bom": true}, '|| + '{"Forename": "Ophir", "Surname": "Lojkine", "Pseudonym": "lovasoa"},' || + '{"Forename": "Linus", "Surname": "Torvalds", "Pseudonym": "torvalds"}]')); + + +INSERT INTO component(name, icon, description) VALUES + ('dynamic', 'repeat', 'Renders other components, given their properties as JSON. +If you are looking for a way to run FOR loops, to share similar code between pages of your site, +or to render multiple components for every line returned by your SQL query, then this is the component to use'); + +INSERT INTO parameter(component, name, description, type, top_level, optional) SELECT 'dynamic', * FROM (VALUES + -- top level + ('properties', 'A json object or array that contains the names and properties of other components.', 'JSON', TRUE, TRUE) +) x; + +INSERT INTO example(component, description, properties) VALUES + ('dynamic', 'The dynamic component has a single top-level property named `properties`, but it can render any number of other components. +Let''s start with something simple to illustrate the logic. We''ll render a `text` component with two row-level properties: `contents` and `italics`. +', json('[{"component":"dynamic", "properties": "[{\"component\":\"text\"}, {\"contents\":\"Hello, I am a dynamic component !\", \"italics\":true}]"}]')), + ('dynamic', ' +## Static component data stored in `.json` files + +You can also store the data for a component in a `.json` file, and load it using the `dynamic` component. + +This is particularly useful to create a single [shell](?component=shell#component) defining the site''s overall appearance and menus, +and displaying it on all pages without duplicating its code. + +The following will load the data for a `shell` component from a file named `shell.json`, +using the [`sqlpage.read_file_as_text`](/functions.sql?function=read_file_as_text) function. + +```sql +SELECT ''dynamic'' AS component, sqlpage.read_file_as_text(''shell.json'') AS properties; +``` + +and `shell.json` would be placed at the website''s root and contain the following: + +```json +{ + "component": "shell", + "title": "SQLPage documentation", + "link": "/", + "menu_item": [ + {"link": "index.sql", "title": "Home"}, + {"title": "Community", "submenu": [ + {"link": "blog.sql", "title": "Blog"}, + {"link": "https//github.com/sqlpage/SQLPage/issues", "title": "Issues"}, + {"link": "https//github.com/sqlpage/SQLPage/discussions", "title": "Discussions"}, + {"link": "https//github.com/sqlpage/SQLPage", "title": "Github"} + ]} + ] +} +``` +', NULL), +('dynamic', ' +## Including another SQL file + +To avoid repeating the same code on multiple pages, you can include another SQL file using the `dynamic` component +together with the [`sqlpage.run_sql`](/functions.sql?function=run_sql) function. + +For instance, the following will include the file `shell.sql` at the top of the page, +and pass it a `$title` variable to display the page title. + +```sql +SELECT ''dynamic'' AS component, + sqlpage.run_sql(''shell.sql'', json_object(''title'', ''SQLPage documentation'')) AS properties; +``` + +And `shell.sql` could contain the following: + +```sql +SELECT ''shell'' AS component, + COALESCE($title, ''Default title'') AS title, + ''/my_icon.png'' AS icon, + ''products'' AS menu_item, + ''about'' AS menu_item; +``` + +', NULL), + ('dynamic', ' +## Dynamic shell + +On databases without a native JSON type (such as the default SQLite database), +you can use the `dynamic` component to generate +json data to pass to components that expect it. + +This example generates a menu similar to the [shell example](?component=shell#component), but without using a native JSON type. + +```sql +SELECT ''dynamic'' AS component, '' +{ + "component": "shell", + "title": "SQLPage documentation", + "link": "/", + "menu_item": [ + {"link": "index.sql", "title": "Home"}, + {"title": "Community", "submenu": [ + {"link": "blog.sql", "title": "Blog"}, + {"link": "https//github.com/sqlpage/SQLPage/issues", "title": "Issues"}, + {"link": "https//github.com/sqlpage/SQLPage/discussions", "title": "Discussions"}, + {"link": "https//github.com/sqlpage/SQLPage", "title": "Github"} + ]} + ] +} +'' AS properties +``` + +[View the result of this query, as well as an example of how to generate a dynamic menu +based on the database contents](./examples/dynamic_shell.sql). +', NULL), + ('dynamic', ' +## Dynamic tables + +The `dynamic` component can be used to generate [tables](?component=table#component) with dynamic columns, +using [your database''s JSON functions](/blog.sql?post=JSON%20in%20SQL%3A%20A%20Comprehensive%20Guide). + +For instance, let''s say we have a table with three columns: user_id, name, and role. +We want to create a table where each row is a user, and each column is a role. +We will return a set of json objects that look like this: `{"name": "Alice", "admin": true, "editor": false, "viewer": true}` +```sql +SELECT ''table'' AS component; +SELECT ''dynamic'' AS component, + json_patch( + json_object(''name'', name), + json_object_agg(role, is_admin) + ) AS properties +FROM users +GROUP BY name; +``` +', NULL); + +INSERT INTO component(name, icon, description) VALUES + ('shell', 'layout-navbar', ' +Customize the overall layout, header and footer of the page. + +This is a special component that provides the page structure wrapping all other components on your page. + +It generates the complete HTML document including the `` section with metadata, title, and stylesheets, +as well as the navigation bar, main content area, and footer. + +If you don''t explicitly call the shell component at the top of your SQL file, SQLPage will automatically +add a default shell component before your first try to display data on the page. + +Use the shell component to customize page-wide settings like the page title, navigation menu, theme, fonts, +and to include custom visual styles (with CSS) or interactive behavior (with JavaScript) that should be loaded on the page. +'); + +INSERT INTO parameter(component, name, description_md, type, top_level, optional) SELECT 'shell', * FROM (VALUES + ('favicon', 'The URL of the icon the web browser should display in bookmarks and tabs. This property is particularly useful if multiple sites are hosted on the same domain with different [``site_prefix``](https://github.com/sqlpage/SQLPage/blob/main/configuration.md#configuring-sqlpage).', 'URL', TRUE, TRUE), + ('manifest', 'The location of the [manifest.json](https://developer.mozilla.org/en-US/docs/Web/Manifest) if the site is a [PWA](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps). Among other features, serving a manifest enables your site to be "installed" as an app on most mobile devices.', 'URL', TRUE, TRUE) +) x; +INSERT INTO parameter(component, name, description, type, top_level, optional) SELECT 'shell', * FROM (VALUES + -- top level + ('title', 'The title of your page. Will be shown in a top bar above the page contents. Also usually displayed by web browsers as the name of the web page''s tab.', 'TEXT', TRUE, TRUE), + ('layout', 'The general page layout. Can be "boxed" (the default), "horizontal" (for a full-width menu), "vertical"(vertical menu), "fluid" (removes side margins).', 'TEXT', TRUE, TRUE), + ('description', 'A description of the page. It can be displayed by search engines when your page appears in their results.', 'TEXT', TRUE, TRUE), + ('link', 'The target of the link in the top navigation bar.', 'URL', TRUE, TRUE), + ('css', 'The URL of a CSS file to load and apply to the page.', 'URL', TRUE, TRUE), + ('javascript', 'The URL of a Javascript file to load and execute on the page.', 'URL', TRUE, TRUE), + ('javascript_module', 'The URL of a javascript module in the ESM format (see javascript.info/modules)', 'URL', TRUE, TRUE), + ('rss', 'The URL of an RSS feed to display in the top navigation bar. You can use the rss component to generate the field.', 'URL', TRUE, TRUE), + ('image', 'The URL of an image to display next to the page title.', 'URL', TRUE, TRUE), + ('social_image', 'The URL of the preview image that will appear in the Open Graph metadata when the page is shared on social media.', 'URL', TRUE, TRUE), + ('icon', 'Name of an icon (from tabler-icons.io) to display next to the title in the navigation bar.', 'ICON', TRUE, TRUE), + ('menu_item', 'Adds a menu item in the navigation bar at the top of the page. The menu item will have the specified name, and will link to as .sql file of the same name. A dropdown can be generated by passing a json object with a `title` and `submenu` properties.', 'TEXT', TRUE, TRUE), + ('fixed_top_menu', 'Fixes the top bar with menu at the top (the top bar remains visible when scrolling long pages).', 'BOOLEAN', TRUE, TRUE), + ('search_target', 'When this is set, a search field will appear in the top navigation bar, and load the specified sql file with an URL parameter named "search" when the user searches something.', 'TEXT', TRUE, TRUE), + ('search_value', 'This value will be placed in the search field when "search_target" is set. Using the "$search" query parameter value will mirror the value that the user has searched for.', 'TEXT', TRUE, TRUE), + ('search_placeholder', 'Customizes the placeholder text shown in the search input field. Replaces the default "Search" with text that better describes what users should search for.', 'TEXT', TRUE, TRUE), + ('search_button', 'Customizes the text displayed on the search button. Replaces the default "Search" label with custom text that may better match your applications terminology or language.', 'TEXT', TRUE, TRUE), + ('norobot', 'Forbids robots to save this page in their database and follow the links on this page. This will prevent this page to appear in Google search results for any query, for instance.', 'BOOLEAN', TRUE, TRUE), + ('font', 'Specifies the font to be used for displaying text, which can be a valid font name from fonts.google.com or the path to a local WOFF2 font file starting with a slash (e.g., "/fonts/MyLocalFont.woff2").', 'TEXT', TRUE, TRUE), + ('font_size', 'Font size on the page, in pixels. Set to 18 by default.', 'INTEGER', TRUE, TRUE), + ('language', 'The language of the page. This can be used by search engines and screen readers to determine in which language the page is written.', 'TEXT', TRUE, TRUE), + ('rtl', 'Whether the page should be displayed in right-to-left mode. Used to display Arabic, Hebrew, Persian, etc.', 'BOOLEAN', TRUE, TRUE), + ('refresh', 'Number of seconds after which the page should refresh. This can be useful to display dynamic content that updates automatically.', 'INTEGER', TRUE, TRUE), + ('sidebar', 'Whether the menu defined by menu_item should be displayed on the left side of the page instead of the top. Introduced in v0.27.', 'BOOLEAN', TRUE, TRUE), + ('sidebar_theme', 'Used with sidebar property, It can be set to "dark" to exclusively set the sidebar into dark theme.', 'TEXT', TRUE, TRUE), + ('theme', 'Set to "dark" to use a dark theme.', 'TEXT', TRUE, TRUE), + ('footer', 'Muted text to display in the footer of the page. This can be used to display a link to the terms and conditions of your application, for instance. By default, shows "Built with SQLPage". Supports links with markdown.', 'TEXT', TRUE, TRUE), + ('preview_image', 'The URL of an image to display as a link preview when the page is shared on social media', 'URL', TRUE, TRUE), + ('navbar_title', 'The title to display in the top navigation bar. Used to display a different title in the top menu than the one that appears in the tab of the browser.', 'TEXT', TRUE, TRUE), + ('target', '"_blank" to open the link in a new tab, "_self" to open it in the same tab, "_parent" to open it in the parent frame, or "_top" to open it in the full body of the window', 'TEXT', TRUE, TRUE) +) x; + +INSERT INTO example(component, description, properties) VALUES + ('shell', ' +This example contains the values used for the shell of the page you are currently viewing. + +The `menu_item` property is used both in its simple string form, to generate a link named "functions" that points to "functions.sql", +and in its object form, to generate a dropdown menu named "Community" with links to the blog, the github repository, and the issues page. + +The object form can be used directly only on database engines that have a native JSON type. +On other engines (such as SQLite), you can use the [`dynamic`](?component=dynamic#component) component to generate the same result. + + +You see the [page layouts demo](./examples/layouts.sql) for a live example of the different layouts. +', + json('[{ + "component": "shell", + "title": "SQLPage: SQL websites", + "icon": "database", + "link": "/", + "menu_item": [ + {"title": "About", "submenu": [ + {"link": "/safety.sql", "title": "Security", "icon": "lock"}, + {"link": "/performance.sql", "title": "Performance", "icon": "bolt"}, + {"link": "//github.com/sqlpage/SQLPage/blob/main/LICENSE.txt", "title": "License", "icon": "file-text"}, + {"link": "/blog.sql", "title": "Articles", "icon": "book"} + ]}, + {"title": "Examples", "submenu": [ + {"link": "/examples/tabs/", "title": "Tabs", "icon": "layout-navbar"}, + {"link": "/examples/layouts.sql", "title": "Layouts", "icon": "layout"}, + {"link": "/examples/multistep-form", "title": "Forms", "icon": "edit"}, + {"link": "/examples/handle_picture_upload.sql", "title": "File uploads", "icon": "upload"}, + {"link": "/examples/authentication/", "title": "Password protection", "icon": "password-user"}, + {"link": "//github.com/sqlpage/SQLPage/blob/main/examples/", "title": "All examples & demos", "icon": "code"} + ]}, + {"title": "Community", "submenu": [ + {"link": "/blog.sql", "title": "Blog", "icon": "book"}, + {"link": "//github.com/sqlpage/SQLPage/issues", "title": "Report a bug", "icon": "bug"}, + {"link": "//github.com/sqlpage/SQLPage/discussions", "title": "Discussions", "icon": "message"}, + {"link": "//github.com/sqlpage/SQLPage", "title": "Github", "icon": "brand-github"} + ]}, + {"title": "Documentation", "submenu": [ + {"link": "/your-first-sql-website", "title": "Getting started", "icon": "book"}, + {"link": "/components.sql", "title": "All Components", "icon": "list-details"}, + {"link": "/functions.sql", "title": "SQLPage Functions", "icon": "math-function"}, + {"link": "/extensions-to-sql", "title": "Extensions to SQL", "icon": "cube-plus"}, + {"link": "/custom_components.sql", "title": "Custom Components", "icon": "puzzle"}, + {"link": "//github.com/sqlpage/SQLPage/blob/main/configuration.md#configuring-sqlpage", "title": "Configuration", "icon": "settings"} + ]}, + {"title": "Search", "link": "/search"} + ], + "layout": "boxed", + "language": "en-US", + "description": "Go from SQL queries to web applications in an instant.", + "preview_image": "https://sql-page.com/sqlpage_social_preview.webp", + "theme": "dark", + "font": "Poppins", + "javascript": [ + "https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11/build/highlight.min.js", + "https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11/build/languages/sql.min.js", + "https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11/build/languages/handlebars.min.js", + "https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11/build/languages/json.min.js", + "/assets/highlightjs-launch.js" + ], + "css": "/assets/highlightjs-and-tabler-theme.css", + "footer": "[Built with SQLPage](https://github.com/sqlpage/SQLPage/tree/main/examples/official-site)" + }]')), + ('shell', ' +This example shows how to set menu items as active in the navigation, so that they are highlighted in the nav bar. + +In this example you can see that two menu items are created, "Home" and "About" and the "Home" tab is marked as active. +', + json('[{ + "component": "shell", + "title": "SQLPage: SQL websites", + "icon": "database", + "link": "/", + "menu_item": [ + {"title": "Home", "active": true}, + {"title": "About"} + ] + }]')), + + ('shell', ' +### Sharing the shell between multiple pages + +It is common to want to share the same shell between multiple pages. + +#### Static menu + +If your menu is completely static (it does not depend on the database content), +you can use the [`dynamic`](?component=dynamic#component) component together with the +[`sqlpage.read_file_as_text`](functions.sql?function=read_file_as_text#function) function to load the shell from +a json file. + +```sql +SELECT ''dynamic'' AS component, sqlpage.read_file_as_text(''shell.json'') AS properties; +``` + +and in `shell.json`: + +```json +{ + "component": "shell", + "title": "SQL + JSON = <3", + "link": "/", + "menu_item": [ + {"link": "index.sql", "title": "Home"}, + {"title": "Community", "submenu": [ + {"link": "/blog.sql", "title": "Blog"}, + {"link": "//github.com/sqlpage/SQLPage", "title": "Github"} + ]} + ] +} +``` + +#### Dynamic menu + +If your menu depends on the database content, or on special `sqlpage` functions, +you can use the `dynamic` component, +but this time with the [`sqlpage.run_sql`](functions.sql?function=run_sql#function) +function to generate the menu from the database. + +```sql +SELECT ''dynamic'' AS component, sqlpage.run_sql(''shell.sql'') AS properties; +``` + +and in `shell.sql`: + +```sql +SELECT ''shell'' AS component, ''run_sql is cool'' as title, + json_group_array(json_object( + ''link'', link, + ''title'', title + )) as menu_item +FROM my_menu_items +``` + +(check your database documentation for the exact syntax of the `json_group_array` function). + +Another case when dynamic menus are useful is when you want to show some +menu items only in certain conditions. + +For instance, you could show an "Admin panel" menu item only to users with the "admin" role, +a "Profile" menu item only to authenticated users, +and a "Login" menu item only to unauthenticated users: + +```sql +set role = ( + SELECT role FROM users + INNER JOIN sessions ON users.id = sessions.user_id + WHERE sessions.session_id = sqlpage.cookie(''session_id'') +); -- Read more about how to handle user sessions in the "authentication" component documentation + +SELECT + ''shell'' AS component, + ''My authenticated website'' AS title, + + -- Add an admin panel link if the user is an admin + CASE WHEN $role = ''admin'' THEN ''{"link": "admin.sql", "title": "Admin panel"}'' END AS menu_item, + + -- Add a profile page if the user is authenticated + CASE WHEN $role IS NOT NULL THEN ''{"link": "profile.sql", "title": "My profile"}'' END AS menu_item, + + -- Add a login link if the user is not authenticated + CASE WHEN $role IS NULL THEN ''login'' END AS menu_item +; +``` + +More about how to handle user sessions in the [authentication component documentation](?component=authentication#component). + +### Menu with icons + +The "icon" attribute may be specified for items in the top menu and submenus to display an icon +before the title (or instead). Similarly, the "image" attribute defines a file-based icon. For +image-based icons, the "size" attribute may be specified at the top level of menu_item only to +reduce the size of image-based icons. The following snippet provides an example, which is also +available [here](examples/menu_icon.sql). + +```sql +SELECT + ''shell'' AS component, + ''SQLPage'' AS title, + ''database'' AS icon, + ''/'' AS link, + TRUE AS fixed_top_menu, + ''{"title":"About","icon": "settings","submenu":[{"link":"/safety.sql","title":"Security","icon": "logout"},{"link":"/performance.sql","title":"Performance"}]}'' AS menu_item, + ''{"title":"Examples","image": "https://upload.wikimedia.org/wikipedia/en/6/6b/Terrestrial_globe.svg","submenu":[{"link":"/examples/tabs/","title":"Tabs","image": "https://upload.wikimedia.org/wikipedia/en/6/6b/Terrestrial_globe.svg"},{"link":"/examples/layouts.sql","title":"Layouts"}]}'' AS menu_item, + ''{"title":"Examples","size":"sm","image": "https://upload.wikimedia.org/wikipedia/en/6/6b/Terrestrial_globe.svg","submenu":[{"link":"/examples/tabs/","title":"Tabs","image": "https://upload.wikimedia.org/wikipedia/en/6/6b/Terrestrial_globe.svg"},{"link":"/examples/layouts.sql","title":"Layouts"}]}'' AS menu_item, + ''Official [SQLPage](https://sql-page.com) documentation'' as footer; +``` +', NULL), + ('shell', ' +### Returning custom HTML, XML, plain text, or other formats + +Use `shell-empty` to opt out of SQLPage''s component system and return raw data directly. + +By default, SQLPage wraps all your content in a complete HTML page with navigation and styling. +The `shell-empty` component tells SQLPage to skip this HTML wrapper and return only the raw content you specify. + +Use it to create endpoints that return things like + - XML (for JSON, use the [json](?component=json) component) + - plain text or markdown content (for instance for consumption by LLMs) + - a custom data format you need + +When using `shell-empty`, you should use the [http_header](component.sql?component=http%5Fheader) component first +to set the correct [content type](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type) (like `application/json` or `application/xml`). +', + json('[ + { + "component":"http_header", + "Content-Type":"application/xml" + }, + { + "component":"shell-empty", + "contents": "\n \n 42\n john.doe\n " + } + ]') + ), + ('shell',' +### Generate your own HTML +If you generate your own HTML from a SQL query, you can also use the `shell-empty` component to include it in a page. +This is useful when you want to generate a snippet of HTML that can be dynamically included in a larger page. +Make sure you know what you are doing, and be careful to escape the HTML properly, +as you are stepping out of the safe SQLPage framework and into the wild world of HTML. + +In this scenario, you can use the `html` property, which serves as an alias for the `contents` property. +This property improves code readability by clearly indicating that you are generating HTML. +Since SQLPage returns HTML by default, there is no need to specify the content type in the HTTP header. +', + json('[{"component":"shell-empty", "html": "\n\n\n My page\n\n\n

My page

\n\n"}]')); diff --git a/examples/official-site/sqlpage/migrations/02_hero_component.sql b/examples/official-site/sqlpage/migrations/02_hero_component.sql new file mode 100644 index 0000000..d528453 --- /dev/null +++ b/examples/official-site/sqlpage/migrations/02_hero_component.sql @@ -0,0 +1,177 @@ +-- Hero +INSERT INTO + component(name, icon, description) +VALUES + ( + 'hero', + 'home', + 'Display a large title and description for your page, with an optional large illustrative image. Useful in your home page, for instance.' + ); + +INSERT INTO + parameter( + component, + name, + description, + type, + top_level, + optional + ) +SELECT + 'hero', + * +FROM + ( + VALUES + -- top level + ( + 'title', + 'The title of your page. Will be shown in very large characters at the top.', + 'TEXT', + TRUE, + TRUE + ), + ( + 'description', + 'A description of the page. Displayed below the title, in smaller characters and slightly greyed out.', + 'TEXT', + TRUE, + TRUE + ), + ( + 'description_md', + 'A description of the page. Displayed below the title, in smaller characters and slightly greyed out - formatted using markdown.', + 'TEXT', + TRUE, + TRUE + ), + ( + 'image', + 'The URL of an image to display next to the page title.', + 'URL', + TRUE, + TRUE + ), + ( + 'video', + 'The URL of a video to display next to the page title.', + 'URL', + TRUE, + TRUE + ), + ( + 'link', + 'Creates a large "call to action" button below the description, linking to the specified URL.', + 'URL', + TRUE, + TRUE + ), + ( + 'link_text', + 'The text to display in the call to action button. Defaults to "Go".', + 'TEXT', + TRUE, + TRUE + ), + ( + 'poster', + 'URL of the image to be displayed before the video starts. Ignored if no video is present.', + 'URL', + TRUE, + TRUE + ), + ( + 'nocontrols', + 'Hide the video controls (play, pause, volume, etc.), and autoplay the video.', + 'BOOLEAN', + TRUE, + TRUE + ), + ('muted', 'Mute the video', 'BOOLEAN', TRUE, TRUE), + ('autoplay', 'Automatically start playing the video', 'BOOLEAN', TRUE, TRUE), + ('loop', 'Loop the video', 'BOOLEAN', TRUE, TRUE), + ( + 'reverse', + 'Reverse the order of the image and text: the image will be on the left, and the text on the right.', + 'BOOLEAN', + TRUE, + TRUE + ), + -- item level + ( + 'title', + 'The name of a single feature section highlighted by this hero.', + 'TEXT', + FALSE, + TRUE + ), + ( + 'description', + 'Description of the feature section.', + 'TEXT', + FALSE, + TRUE + ), + ( + 'description_md', + 'Description of the feature section - formatted using markdown.', + 'TEXT', + FALSE, + TRUE + ), + ( + 'icon', + 'Icon of the feature section.', + 'ICON', + FALSE, + TRUE + ), + ( + 'link', + 'An URL to which the user should be taken when they click on the section title.', + 'TEXT', + FALSE, + TRUE + ) + ) x; + +INSERT INTO + example(component, description, properties) +VALUES + ( + 'hero', + 'The simplest possible hero section', + json( + '[{ + "component":"hero", + "title": "Welcome", + "description": "This is a very simple site built with SQLPage." + }]' + ) + ), + ( + 'hero', + 'A hero with a background image.', + json( + '[{ + "component":"hero", + "title": "SQLPage", + "description_md": "Documentation for the *SQLPage* low-code web application framework.", + "image": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e4/Lac_de_Zoug.jpg/640px-Lac_de_Zoug.jpg", + "link": "/documentation.sql", + "link_text": "Read Documentation !"},' || '{"title": "Fast", "description": "Pages load instantly, even on slow mobile networks.", "icon": "car", "color": "red", "link": "/"},' || '{"title": "Beautiful", "description": "Uses pre-defined components that look professional.", "icon": "eye", "color": "green", "link": "/"},' || '{"title": "Easy", "description_md": "You can teach yourself enough SQL to use [**SQLPage**](https://sql-page.com) in a weekend.", "icon": "sofa", "color": "blue", "link": "/"}' || ']' + ) + ), + ( + 'hero', + 'A hero with a video', + json( + '[{ + "component":"hero", + "title": "Databases", + "reverse": true, + "description_md": "# “The goal is to turn data into information, and information into insight.”", + "poster": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e4/Lac_de_Zoug.jpg/640px-Lac_de_Zoug.jpg", + "video": "/sqlpage_introduction_video.webm" + }]') + ); \ No newline at end of file diff --git a/examples/official-site/sqlpage/migrations/03_alert_component.sql b/examples/official-site/sqlpage/migrations/03_alert_component.sql new file mode 100644 index 0000000..0313bcd --- /dev/null +++ b/examples/official-site/sqlpage/migrations/03_alert_component.sql @@ -0,0 +1,188 @@ +-- Alert component +INSERT INTO component(name, icon, description) +VALUES ( + 'alert', + 'alert-triangle', + 'A visually distinctive message or notification.' + ); +INSERT INTO parameter( + component, + name, + description, + type, + top_level, + optional + ) +VALUES ( + 'alert', + 'title', + 'Title of the alert message.', + 'TEXT', + TRUE, + FALSE + ), + ( + 'alert', + 'icon', + 'Icon name (from tabler-icons.io) to display next to the alert message.', + 'TEXT', + TRUE, + TRUE + ), + ( + 'alert', + 'color', + 'The color theme for the alert message.', + 'COLOR', + TRUE, + TRUE + ), + ( + 'alert', + 'description', + 'Detailed description or content of the alert message.', + 'TEXT', + TRUE, + TRUE + ), + ( + 'alert', + 'description_md', + 'Detailed description or content of the alert message, in Markdown format, allowing you to use rich text formatting, including **bold** and *italic* text.', + 'TEXT', + TRUE, + TRUE + ), + ( + 'alert', + 'dismissible', + 'Whether the user can close the alert message.', + 'BOOLEAN', + TRUE, + TRUE + ), + ( + 'alert', + 'important', + 'Set this to TRUE to make the alert message more prominent.', + 'BOOLEAN', + TRUE, + TRUE + ), + ( + 'alert', + 'link', + 'A URL to link to from the alert message.', + 'URL', + TRUE, + TRUE + ), + ( + 'alert', + 'link_text', + 'Customize the text of the link in the alert message. The default is "Ok".', + 'TEXT', + TRUE, + TRUE + ), + ( + 'alert', + 'link', + 'A URL to link to from the alert message.', + 'URL', + FALSE, + TRUE + ), + ( + 'alert', + 'title', + 'Customize the text of the link in the alert message. The default is "Ok".', + 'TEXT', + FALSE, + TRUE + ), + ( + 'alert', + 'color', + 'Customize the color of the link.', + 'COLOR', + FALSE, + TRUE + ); +-- Insert example(s) for the component +INSERT INTO example(component, description, properties) +VALUES ( + 'alert', + 'A basic alert message', + JSON( + '[{"component":"alert", "title":"Attention", "description":"This is an important message."}]' + ) + ), + ( + 'alert', + 'A list of notifications', + JSON( + '[ + {"component" :"alert", "title":"Success","description":"Item successfully added to your cart.", "icon":"check", "color": "green"}, + {"component":"alert", "title":"Warning","description":"Your cart is almost full.", "icon":"alert-triangle", "color": "yellow"}, + {"component":"alert", "title":"Error","description":"Your cart is full.", "icon":"alert-circle", "color": "red"} + ]' + ) + ), + ( + 'alert', + 'A full-featured notification message with multiple links', + JSON( + '[ + { + "component" :"alert", + "title": "Your dashboard is ready!", + "icon":"analyze", + "color":"teal", + "dismissible": true, + "description":"Your public web dashboard was successfully created." + }, + { + "link":"dashboard.sql", + "title": "View your dashboard" + }, + { + "link" :"index.sql", + "title": "Back to home", + "color": "secondary" + } + ]' + ) + ), + ( + 'alert', + 'An important danger alert message with an icon and color', + JSON( + '[ + { + "component":"alert", + "title":"Alert", + "icon":"alert-circle", + "color":"red", + "important": true, + "dismissible": true, + "description":"SQLPage is entirely free and open source.", + "link":"https://github.com/sqlpage/SQLPage", + "link_text":"See source code" + }]' + ) + ), + ( + 'alert', + 'An alert message with a Markdown-formatted description', + JSON( + '[ + { + "component":"alert", + "title":"Free and open source", + "icon": "free-rights", + "color": "info", + "description_md":"*SQLPage* is entirely free and open source. You can **contribute** to it on [GitHub](https://github.com/sqlpage/SQLPage)." + }]' + ) + ); diff --git a/examples/official-site/sqlpage/migrations/04_http_header.sql b/examples/official-site/sqlpage/migrations/04_http_header.sql new file mode 100644 index 0000000..1667e42 --- /dev/null +++ b/examples/official-site/sqlpage/migrations/04_http_header.sql @@ -0,0 +1,116 @@ +-- Insert the http_header component into the component table +INSERT INTO component (name, description, icon) +VALUES ( + 'http_header', + ' +An advanced component to set arbitrary HTTP headers: can be used to set a custom caching policy to your pages, or implement custom redirections, for example. +If you are a beginner, you probably don''t need this component. + +When used, this component has to be the first component in the page, because once the page is sent to the browser, it is too late to change the headers. + +HTTP headers are additional pieces of information sent with responses to web requests that provide instructions +or metadata about the data being sent — for example, +setting cache control directives to control caching behavior +or specifying the content type of a response. + +Any valid HTTP header name can be used as a top-level parameter for this component. +The examples shown here are just that, examples; and you can create any custom header +if needed simply by declaring it. + +If your header''s name contains a dash or any other special character, +you will have to use your database''s quoting mechanism to declare it. +In standard SQL, you can use double quotes to quote identifiers (like "X-My-Header"), +in Microsoft SQL Server, you can use square brackets (like [X-My-Header]). + ', + 'world-www' + ); +-- Insert the parameters for the http_header component into the parameter table +INSERT INTO parameter ( + component, + name, + description, + type, + top_level, + optional + ) +VALUES ( + 'http_header', + 'Cache-Control', + 'Directives for how long the page should be cached by the browser. Set this to max-age=N to keep the page in cache for N seconds.', + 'TEXT', + TRUE, + TRUE + ), + ( + 'http_header', + 'Content-Disposition', + 'Provides instructions on how the response content should be displayed or handled by the client, such as inline or as an attachment.', + 'TEXT', + TRUE, + TRUE + ), + ( + 'http_header', + 'Location', + 'Specifies the URL to redirect the client to, usually used in 3xx redirection responses.', + 'TEXT', + TRUE, + TRUE + ), + ( + 'http_header', + 'Set-Cookie', + 'Sets a cookie in the client browser, used for session management and storing user-related information.', + 'TEXT', + TRUE, + TRUE + ), + ( + 'http_header', + 'Access-Control-Allow-Origin', + 'Specifies which origins are allowed to access the resource in a cross-origin request, used for implementing Cross-Origin Resource Sharing (CORS).', + 'TEXT', + TRUE, + TRUE + ); + +-- Insert an example usage of the http_header component into the example table +INSERT INTO example (component, description, properties) +VALUES ( + 'http_header', + 'Set cache control directives for caching behavior. In this example, the response can be cached by the browser + and served from the cache for up to 600 seconds (10 minutes) after it is first requested. + During that time, even if the cached response becomes stale (outdated), the browser can still use it (stale-while-revalidate) + for up to 3600 seconds (1 hour) while it retrieves a fresh copy from the server in the background. + If there is an error while trying to retrieve a fresh copy from the server, + the browser can continue to serve the stale response for up to 86400 seconds (24 hours) (stale-if-error) instead of showing an error page. + This caching behavior helps improve the performance and responsiveness of the website by reducing the number of requests made to the server + and allowing the browser to serve content from its cache when appropriate.', + JSON( + '[{ + "component": "http_header", + "Cache-Control": "public, max-age=600, stale-while-revalidate=3600, stale-if-error=86400" + }]' + ) + ), + ( + 'http_header', + 'Redirect the user to another page. In this example, the user is redirected to a file named another-page.sql at the root of the website. The current page will not be displayed at all. + This is useful in particular for content creation pages that contain only INSERT statements, because you can redirect the user to the page that lists the content after it has been created.', + JSON( + '[{ + "component": "http_header", + "Location": "/another-page.sql" + }]' + ) + ), + ( + 'http_header', + 'Set a custom non-standard header for the response. In this example, the response will include a custom header named X-My-Header with the value "my value".', + JSON( + '[{ + "component": "http_header", + "X-My-Header": "my value" + }]' + ) + ); \ No newline at end of file diff --git a/examples/official-site/sqlpage/migrations/05_cookie.sql b/examples/official-site/sqlpage/migrations/05_cookie.sql new file mode 100644 index 0000000..efd197a --- /dev/null +++ b/examples/official-site/sqlpage/migrations/05_cookie.sql @@ -0,0 +1,125 @@ +-- Insert the http_header component into the component table +INSERT INTO component (name, description, icon) +VALUES ( + 'cookie', + ' +Sets a cookie in the client browser, used for session management and storing user-related information. + +This component creates a single cookie. Since cookies need to be set before the response body is sent to the client, +this component should be **placed at the top of the page**, before any other components that generate output. + +After being set, a cookie can be accessed anywhere in your SQL code using the `sqlpage.cookie(''cookie_name'')` pseudo-function. + +Note that if your site is accessed over HTTP (and not HTTPS), you have to set `false as secure` to force browsers to accept your cookies.', + 'cookie' + ); +-- Insert the parameters for the http_header component into the parameter table +INSERT INTO parameter ( + component, + name, + description, + type, + top_level, + optional + ) +VALUES ( + 'cookie', + 'name', + 'The name of the cookie to set.', + 'TEXT', + TRUE, + FALSE + ), + ( + 'cookie', + 'value', + 'The value of the cookie to set.', + 'TEXT', + TRUE, + TRUE + ), + ( + 'cookie', + 'path', + 'The path for which the cookie will be sent. If not specified, the cookie will be sent for all paths.', + 'TEXT', + TRUE, + TRUE + ), + ( + 'cookie', + 'domain', + 'The domain for which the cookie will be sent. If not specified, the cookie will be sent for all domains.', + 'TEXT', + TRUE, + TRUE + ), + ( + 'cookie', + 'secure', + 'Whether the cookie should only be sent over a secure (HTTPS) connection. Defaults to TRUE.', + 'BOOLEAN', + TRUE, + TRUE + ), + ( + 'cookie', + 'http_only', + 'Whether the cookie should only be accessible via HTTP and not via client-side scripts. If not specified, the cookie will be accessible via both HTTP and client-side scripts.', + 'BOOLEAN', + TRUE, + TRUE + ), + ( + 'cookie', + 'remove', + 'Set to TRUE to remove the cookie from the client browser. When specified, other parameters are ignored.', + 'BOOLEAN', + TRUE, + TRUE + ), + ( + 'cookie', + 'max_age', + 'The maximum age of the cookie in seconds. number of seconds until the cookie expires. If both Expires and Max-Age are set, Max-Age has precedence.', + 'INTEGER', + TRUE, + TRUE + ), + ( + 'cookie', + 'expires', + 'The date at which the cookie expires (either a timestamp or a date object). If not specified, the cookie will expire when the browser is closed.', + 'TIMESTAMP', + TRUE, + TRUE + ), + ( + 'cookie', + 'same_site', + 'Whether the cookie should only be sent for requests originating from the same site. See owasp.org/www-community/SameSite. `strict` is the recommended and default value, but you may want to set it to `lax` if you want your users to keep their session when they click on a link to your site from an external site.', + 'TEXT', + TRUE, + TRUE + ); +-- Insert an example usage of the http_header component into the example table +INSERT INTO example (component, description) +VALUES ( + 'cookie', + 'Create a cookie named `username` with the value `John Doe`... + +```sql +SELECT ''cookie'' as component, + ''username'' as name, + ''John Doe'' as value + FALSE AS secure; -- You can remove this if the site is served over HTTPS. +``` + +and then display the value of the cookie using the [`sqlpage.cookie`](functions.sql?function=cookie) function: + +```sql +SELECT ''text'' as component, + ''Your name is '' || COALESCE(sqlpage.cookie(''username''), ''not known to us''); +``` + ' + ); diff --git a/examples/official-site/sqlpage/migrations/06_debug.sql b/examples/official-site/sqlpage/migrations/06_debug.sql new file mode 100644 index 0000000..7df4263 --- /dev/null +++ b/examples/official-site/sqlpage/migrations/06_debug.sql @@ -0,0 +1,25 @@ +-- Insert the http_header component into the component table +INSERT INTO component (name, description, icon) +VALUES ( + 'debug', + 'Visualize any set of values as JSON. +Can be used to display all the parameters passed to the component. +Useful for debugging: just replace the name of the component you want to debug with ''debug'', and see all the top-level and row-level parameters that are passed to it, and their types.', + 'bug' + ); +-- Insert an example usage of the http_header component into the example table +INSERT INTO example (component, description, properties) +VALUES ( + 'debug', + 'At any time, if you are confused about what data you are passing to a component, just replace the component name with ''debug'' to see all the parameters that are passed to it.', + JSON('[{"component": "debug", "my_top_level_property": true}, {"x": "y", "z": 42}, {"a": "b", "c": null}]') + ), + ( + 'debug', + 'Show the result of a SQLPage function: + +```sql +select ''debug'' as component, sqlpage.environment_variable(''HOME''); +```', + NULL + ); \ No newline at end of file diff --git a/examples/official-site/sqlpage/migrations/07_authentication.sql b/examples/official-site/sqlpage/migrations/07_authentication.sql new file mode 100644 index 0000000..c759dee --- /dev/null +++ b/examples/official-site/sqlpage/migrations/07_authentication.sql @@ -0,0 +1,189 @@ +-- Insert the http_header component into the component table +INSERT INTO component (name, description, icon, introduced_in_version) +VALUES ( + 'authentication', + ' +Create pages with password-restricted access. + + +When you want to add user authentication to your SQLPage application, +you have two main options: + +1. The `authentication` component: + - lets you manage usernames and passwords yourself + - does not require any external service + - gives you fine-grained control over + - which pages and actions are protected + - the look of the [login form](?component=login) + - the duration of the session + - the permissions of each user +2. [**Single sign-on**](/sso) + - lets users log in with their existing accounts (like Google, Microsoft, or your organization''s own identity provider) + - requires setting up an external service (Google, Microsoft, etc.) + - frees you from implementing a lot of features like password reset, account creation, user management, etc. + +This page describes the first option. + +When used, this component has to be at the top of your page, +because once the page has begun being sent to the browser, +it is too late to restrict access to it. + +The authentication component checks if the user has sent the correct password, +and if not, redirects them to the URL specified in the link parameter. + +If you don''t want to re-check the password on every page (which is an expensive operation), +you can check the password only once and store a session token in your database +(see the session example below). + +You can use the [cookie component](?component=cookie) to set the session token cookie in the client browser, +and then check whether the token matches what you stored in subsequent pages.', + 'lock', + '0.7.2' + ); +-- Insert the parameters for the http_header component into the parameter table +INSERT INTO parameter ( + component, + name, + description, + type, + top_level, + optional + ) +VALUES ( + 'authentication', + 'link', + 'The URL to redirect the user to if they are not logged in. If this parameter is not specified, the user will stay on the current page, but be asked to log in using a popup in their browser (HTTP basic authentication).', + 'TEXT', + TRUE, + TRUE + ), + ( + 'authentication', + 'password', + 'The password that was sent by the user. You can set this to :password if you have a login form leading to your page.', + 'TEXT', + TRUE, + TRUE + ), + ( + 'authentication', + 'password_hash', + 'The hash of the password that you stored for the user that is currently trying to log in. These hashes can be generated ahead of time using a tool like https://argon2.online/.', + 'TEXT', + TRUE, + TRUE + ); + +-- Insert an example usage of the http_header component into the example table +INSERT INTO example (component, description) +VALUES ( + 'authentication', + ' + +### Usage with HTTP basic authentication + +The most basic usage of the authentication component is with the +[`sqlpage.basic_auth_username()`](functions.sql?function=basic_auth_username#function) and +[`sqlpage.basic_auth_password()`](functions.sql?function=basic_auth_password#function) functions. +The component will check if the provided password matches the stored [password hash](/examples/hash_password.sql), +and if not, it will prompt the user to enter a password in a browser popup: + +```sql +SELECT ''authentication'' AS component, + ''$argon2i$v=19$m=8,t=1,p=1$YWFhYWFhYWE$oKBq5E8XFTHO2w'' AS password_hash, -- this is a hash of the password ''password'' + sqlpage.basic_auth_password() AS password; -- this is the password that the user entered in the browser popup +``` + +You can [generate a password hash using the `hash_password` function](/examples/hash_password.sql). + +If you want to have multiple users with different passwords, +you could store them with their password hashes in the database, +or just hardcode them use a `CASE` statement: + +```sql +SELECT ''authentication'' AS component, + case sqlpage.basic_auth_username() + when ''admin'' + then ''$argon2i$v=19$m=8,t=1,p=1$YWFhYWFhYWE$oKBq5E8XFTHO2w'' -- the password is ''password'' + when ''user'' + then ''$argon2i$v=19$m=8,t=1,p=1$YWFhYWFhYWE$qsrWdjgl96ooYw'' -- the password is ''user'' + end AS password_hash, -- this is a hash of the password ''password'' + sqlpage.basic_auth_password() AS password; -- this is the password that the user entered in the browser popup +``` + +Try this example online: [SQL Basic Auth](/examples/authentication/basic_auth.sql). + +### Advanced user session management + +*Basic auth* is the simplest way to password-protect a page, +but it is not very flexible nor user-friendly, +because the browser will show an unstyled popup asking for the username and password. + +For more advanced authentication, you can store user information and user sessions in your database. +You can then use the [`form`](components.sql?component=form#component) component to create a custom login form. +When the user submits the form, you check if the password is correct using the `authentication` component. +You then store a unique string of numbers and letters (a session token) both in the user''s browser +using the [`cookie`](components.sql?component=cookie#component) component and in your database. +Then, in all the pages that require authentication, you check if the cookie is present and matches the session token in your database. + +You can check if the user has sent the correct password in a form, and if not, redirect them to a login page. + +Create a login form in a file called `login.sql` that uses the [login component](?component=login): + +```sql +select ''login'' as component; +``` + +And then, in `create_session_token.sql` : + +```sql +SELECT ''authentication'' AS component, + ''login.sql'' AS link, + ''$argon2id$v=19$m=16,t=2,p=1$TERTd0lIcUpraWFTcmRQYw$+bjtag7Xjb6p1dsuYOkngw'' AS password_hash, -- generated using sqlpage.hash_password + :password AS password; -- this is the password that the user sent through our form + +-- The code after this point is only executed if the user has sent the correct password + +``` + +and in `login.sql` : + +```sql +SELECT ''form'' AS component, ''Login'' AS title, ''my_protected_page.sql'' AS action; +SELECT ''password'' AS type, ''password'' AS name, ''Password'' AS label; +``` + +### Advanced: usage with a session token + +Calling the `authentication` component is expensive. +The password hashing algorithm is designed to be slow, so that it is difficult to brute-force the password, +even if an attacker gets access to the database. + +If you want to avoid calling the `authentication` component on every page, you can use a session token. +A session token is a random string that is generated when the user logs in, and stored in the database. +It has a limited lifetime, and is stored in a cookie in the user''s browser. +When the user visits a page, the session token is sent to the server, and the server checks if it is valid. + +```sql +SELECT ''authentication'' AS component, + ''login.sql'' AS link, + (SELECT password_hash FROM user WHERE username = :username) AS password_hash, + :password AS password; + +-- The code after this point is only executed if the user has sent the correct password + +-- Generate a random session token +INSERT INTO session (id, username) +VALUES (sqlpage.random_string(32), :username) +RETURNING + ''cookie'' AS component, + ''session_token'' AS name, + id AS value; +``` + +### Single sign-on with OIDC (OpenID Connect) + +If you don''t want to manage your own user database, +you can [use OpenID Connect and OAuth2](/sso) to authenticate users. +This allows users to log in with their Google, Microsoft, or internal company account. +'); diff --git a/examples/official-site/sqlpage/migrations/08_functions.sql b/examples/official-site/sqlpage/migrations/08_functions.sql new file mode 100644 index 0000000..3a5fd4a --- /dev/null +++ b/examples/official-site/sqlpage/migrations/08_functions.sql @@ -0,0 +1,480 @@ +CREATE TABLE IF NOT EXISTS sqlpage_functions ( + "name" TEXT PRIMARY KEY, + "icon" TEXT, + "description_md" TEXT, + "return_type" TEXT, + "introduced_in_version" TEXT +); +CREATE TABLE IF NOT EXISTS sqlpage_function_parameters ( + "function" TEXT REFERENCES sqlpage_functions("name"), + "index" INTEGER, + "name" TEXT, + "description_md" TEXT, + "type" TEXT +); +INSERT INTO sqlpage_functions ( + "name", + "return_type", + "introduced_in_version", + "icon", + "description_md" + ) +VALUES ( + 'cookie', + 'TEXT', + '0.7.1', + 'cookie', + 'Reads a [cookie](https://en.wikipedia.org/wiki/HTTP_cookie) with the given name from the request. +Returns the value of the cookie as text, or NULL if the cookie is not present. + +Cookies can be set using the [cookie component](documentation.sql?component=cookie#component). + +### Example + +#### Set a cookie + +Set a cookie called `username` to greet the user by name every time they visit the page: + +```sql +select ''cookie'' as component, ''username'' as name, :username as value; + +SELECT ''form'' as component; +SELECT ''username'' as name, ''text'' as type; +``` + +#### Read a cookie + +Read a cookie called `username` and greet the user by name: + +```sql +SELECT ''text'' as component, + ''Hello, '' || sqlpage.cookie(''username'') || ''!'' as contents; +``` +' + ); +INSERT INTO sqlpage_function_parameters ( + "function", + "index", + "name", + "description_md", + "type" + ) +VALUES ( + 'cookie', + 1, + 'name', + 'The name of the cookie to read.', + 'TEXT' + ); +INSERT INTO sqlpage_functions ( + "name", + "introduced_in_version", + "icon", + "description_md" + ) +VALUES ( + 'header', + '0.7.2', + 'heading', + 'Reads a [header](https://en.wikipedia.org/wiki/List_of_HTTP_header_fields) with the given name from the request. + Returns the value of the header as text, or NULL if the header is not present. + +### Example + +Log the [`User-Agent`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/User-Agent) of the browser making the request in the database: + +```sql +INSERT INTO user_agent_log (user_agent) VALUES (sqlpage.header(''user-agent'')); +``` + +If you need access to all headers at once, use [`sqlpage.headers()`](?function=headers) instead. + ' + ); +INSERT INTO sqlpage_function_parameters ( + "function", + "index", + "name", + "description_md", + "type" + ) +VALUES ( + 'header', + 1, + 'name', + 'The name of the HTTP header to read.', + 'TEXT' + ); +INSERT INTO sqlpage_functions ( + "name", + "return_type", + "introduced_in_version", + "icon", + "description_md" + ) +VALUES ( + 'basic_auth_username', + 'TEXT', + '0.7.2', + 'user', + 'Returns the username from the [Basic Authentication](https://en.wikipedia.org/wiki/Basic_access_authentication) header of the request. + If the header is not present, this function raises an authorization error that will prompt the user to enter their credentials. + +### Example + +```sql +SELECT ''authentication'' AS component, + (SELECT password_hash from users where name = sqlpage.basic_auth_username()) AS password_hash, + sqlpage.basic_auth_password() AS password; +``` + +' + ), + ( + 'basic_auth_password', + 'TEXT', + '0.7.2', + 'key', + 'Returns the password from the [Basic Authentication](https://en.wikipedia.org/wiki/Basic_access_authentication) header of the request. + If the header is not present, this function raises an authorization error that will prompt the user to enter their credentials. + +### Example + +```sql +SELECT ''authentication'' AS component, + (SELECT password_hash from users where name = sqlpage.basic_auth_username()) AS password_hash, + sqlpage.basic_auth_password() AS password; +``` +' + ); +INSERT INTO sqlpage_functions ( + "name", + "introduced_in_version", + "icon", + "description_md" + ) +VALUES ( + 'hash_password', + '0.7.2', + 'spy', + ' +Hashes a password with the Argon2id variant and outputs it in the [PHC string format](https://github.com/P-H-C/phc-string-format/blob/master/phc-sf-spec.md), ready to store in your users table. + +Every call generates a brand new cryptographic salt so that two people choosing the same password still end up with different hashes, which defeats rainbow-table attacks and lets you safely reveal only the hash. + +Use this function only when creating or resetting a password (for example while inserting a brand new user): it writes the stored value. Later, at login time, the [authentication component](documentation.sql?component=authentication#component) reads the stored hash, hashes the visitor''s password with the embedded salt and parameters, and grants access only if they match. + +### Example + +```sql +SELECT ''form'' AS component; +SELECT ''username'' AS name; +SELECT ''password'' AS name, ''password'' AS type; + +INSERT INTO users (name, password_hash) VALUES (:username, sqlpage.hash_password(:password)); +``` + +### Try online + +You can try the password hashing function [on this page](/examples/hash_password.sql). + ' + ); +INSERT INTO sqlpage_function_parameters ( + "function", + "index", + "name", + "description_md", + "type" + ) +VALUES ( + 'hash_password', + 1, + 'password', + 'The password to hash.', + 'TEXT' + ); +INSERT INTO sqlpage_functions ( + "name", + "introduced_in_version", + "icon", + "description_md" + ) +VALUES ( + 'random_string', + '0.7.2', + 'arrows-shuffle', + 'Returns a cryptographically secure random string of the given length. + +### Example + +Generate a random string of 32 characters and use it as a session ID stored in a cookie: + +```sql +INSERT INTO login_session (session_token, username) VALUES (sqlpage.random_string(32), :username) +RETURNING + ''cookie'' AS component, + ''session_id'' AS name, + session_token AS value; +``` +' + ); +INSERT INTO sqlpage_function_parameters ( + "function", + "index", + "name", + "description_md", + "type" + ) +VALUES ( + 'random_string', + 1, + 'length', + 'The length of the string to generate.', + 'INTEGER' + ); +INSERT INTO sqlpage_functions ( + "name", + "introduced_in_version", + "icon", + "description_md" + ) +VALUES ( + 'current_working_directory', + '0.11.0', + 'folder-question', + 'Returns the [current working directory](https://en.wikipedia.org/wiki/Working_directory) of the SQLPage server process. + +### Example + +```sql +SELECT ''text'' AS component; +SELECT ''Currently running from '' AS contents; +SELECT sqlpage.current_working_directory() as contents, true as code; +``` + +#### Result + +Currently running from `/home/user/my_sqlpage_website` + +#### Notes + +The current working directory is the directory from which the SQLPage server process was started. +By default, this is also the directory from which `.sql` files are loaded and served. +However, this can be changed by setting the `web_root` [configuration option](https://github.com/sqlpage/SQLPage/blob/main/configuration.md). +' + ); +INSERT INTO sqlpage_functions ( + "name", + "introduced_in_version", + "icon", + "description_md" + ) +VALUES ( + 'web_root', + '0.42.0', + 'folder-code', + 'Returns the web root directory where SQLPage serves `.sql` files from. + +### Example + +```sql +SELECT ''text'' AS component; +SELECT ''SQL files are served from '' AS contents; +SELECT sqlpage.web_root() as contents, true as code; +``` + +#### Result + +SQL files are served from `/home/user/my_sqlpage_website` + +#### Notes + +The web root is the directory from which `.sql` files are loaded and served. +By default, it is the current working directory, but it can be changed using: + - the `--web-root` command line argument + - the `web_root` [configuration option](https://github.com/sqlpage/SQLPage/blob/main/configuration.md) in `sqlpage.json` + - the `WEB_ROOT` environment variable + +This is more reliable than `sqlpage.current_working_directory()` when you need to reference the location of your SQL files. +' + ); +INSERT INTO sqlpage_functions ( + "name", + "introduced_in_version", + "icon", + "description_md" + ) +VALUES ( + 'configuration_directory', + '0.42.0', + 'folder-cog', + 'Returns the configuration directory where SQLPage looks for `sqlpage.json`, templates, and migrations. + +### Example + +```sql +SELECT ''text'' AS component; +SELECT ''Configuration files are in '' AS contents; +SELECT sqlpage.configuration_directory() as contents, true as code; +``` + +#### Result + +Configuration files are in `/home/user/my_sqlpage_website/sqlpage` + +#### Notes + +The configuration directory is where SQLPage looks for: + - `sqlpage.json` (the configuration file) + - `templates/` (custom component templates) + - `migrations/` (database migration files) + +By default, it is `./sqlpage` relative to the current working directory, but it can be changed using: + - the `--config-dir` command line argument + - the `SQLPAGE_CONFIGURATION_DIRECTORY` or `CONFIGURATION_DIRECTORY` environment variable + +This function is useful when you need to reference configuration-related files in your SQL code. +' + ); +INSERT INTO sqlpage_functions ( + "name", + "introduced_in_version", + "icon", + "description_md" + ) +VALUES ( + 'environment_variable', + '0.11.0', + 'variable', + 'Returns the value of the given [environment variable](https://en.wikipedia.org/wiki/Environment_variable). + +### Example + +```sql +SELECT ''text'' AS component; +SELECT ''The value of the HOME environment variable is '' AS contents; +SELECT sqlpage.environment_variable(''HOME'') as contents, true as code; +```' + ); +INSERT INTO sqlpage_function_parameters ( + "function", + "index", + "name", + "description_md", + "type" + ) +VALUES ( + 'environment_variable', + 1, + 'name', + 'The name of the environment variable to read. Must be a literal string.', + 'TEXT' + ); +INSERT INTO sqlpage_functions ( + "name", + "introduced_in_version", + "icon", + "description_md" + ) +VALUES ( + 'version', + '0.11.0', + 'git-commit', + 'Returns the current version of SQLPage as a string.' + ); +INSERT INTO sqlpage_functions ( + "name", + "introduced_in_version", + "icon", + "description_md" + ) +VALUES ( + 'exec', + '0.12.0', + 'terminal-2', + 'Executes a shell command and returns its output as text. + +### Example + +#### Fetch data from a remote API using curl + +```sql +select ''card'' as component; +select value->>''name'' as title, value->>''email'' as description +from json_each(sqlpage.exec(''curl'', ''https://jsonplaceholder.typicode.com/users'')); +``` + +#### Notes + + - This function is disabled by default for security reasons. You can enable it by setting `"allow_exec" : true` in `sqlpage/sqlpage.json`. Enable it only if you trust all the users that can access your SQLPage server files (both locally and on the database). + - Be careful when using this function, as it can be used to execute arbitrary shell commands on your server. Do not use it with untrusted input. + - The command is executed in the current working directory of the SQLPage server process. + - The command is executed with the same user as the SQLPage server process. + - The environment variables of the SQLPage server process are passed to the command, including potentially sensitive variables such as `DATABASE_URL`. + - The command is executed asynchronously, but the SQLPage server has to wait for it to finish before sending the result to the client. + This means that the SQLPage server will not be blocked while the command is running, it will be able to serve other requests, but it will not be able to serve the current request until the command has finished. + You should generally avoid long running commands. + - If the program name is NULL, the result will be NULL. + - If any argument is NULL, it will be passed to the command as an empty string. + - If the command exits with a non-zero exit code, the function will raise an error. + - Arbitrary SQL operations are not allowed as sqlpage function arguments. Use `SET` to assign the result of a SQL query to a variable, and then use that variable as an argument to `sqlpage.exec`. +' + ); +INSERT INTO sqlpage_function_parameters ( + "function", + "index", + "name", + "description_md", + "type" + ) +VALUES ( + 'exec', + 1, + 'program', + 'The name of the program to execute. Must be a literal string.', + 'TEXT' + ), + ( + 'exec', + 2, + 'arguments...', + 'The arguments to pass to the program.', + 'TEXT' + ); +INSERT INTO sqlpage_functions ( + "name", + "introduced_in_version", + "icon", + "description_md" + ) +VALUES ( + 'url_encode', + '0.12.0', + 'percentage', + 'Returns the given string, with all characters that are not allowed in a URL encoded. + +### Example + +```sql +select ''text'' as component; +select ''https://example.com/?q='' || sqlpage.url_encode($user_search) as contents; +``` + +#### Result + +`https://example.com/?q=hello%20world` +' + ); +INSERT INTO sqlpage_function_parameters ( + "function", + "index", + "name", + "description_md", + "type" + ) +VALUES ( + 'url_encode', + 1, + 'string', + 'The string to encode.', + 'TEXT' + ); diff --git a/examples/official-site/sqlpage/migrations/09_redirect.sql b/examples/official-site/sqlpage/migrations/09_redirect.sql new file mode 100644 index 0000000..eb69421 --- /dev/null +++ b/examples/official-site/sqlpage/migrations/09_redirect.sql @@ -0,0 +1,69 @@ +INSERT INTO component (name, description, icon, introduced_in_version) +VALUES ( + 'redirect', + 'Redirects the user to another page. + +This component helps you: +1. Send users to a different page +1. Stop execution of the current page + +### Conditional logic + +There is no `IF` statement in SQL. Even when you use a [`CASE` expression](https://modern-sql.com/caniuse/case_(simple)), all branches are always evaluated (and only one is returned). + +To conditionally execute a component or a [SQLPage function](/functions.sql), you can use the `redirect` component. +A common use case is error handling. You may want to proceed with the rest of a page only when certain pre-conditions are met. + +```sql +SELECT + ''redirect'' AS component, + ''error_page.sql'' AS link +WHERE NOT your_condition; + +-- The rest of the page is only executed if the condition is true +``` +### Technical limitation + +You must use this component **at the beginning of your SQL file**, before any other components that might send content to the browser. +Since the component needs to tell the browser to go to a different page by sending an *HTTP header*, +it will fail if the HTTP headers have already been sent by the time it is executed. + +> **Important difference from [http_header](?component=http_header)** +> +> This component completely stops the page from running after it''s called. +> This makes it a good choice for protecting sensitive information from unauthorized users. + +', + 'arrow-right', + '0.7.2' + ); +-- Insert the parameters for the http_header component into the parameter table +INSERT INTO parameter ( + component, + name, + description, + type, + top_level, + optional + ) +VALUES ( + 'redirect', + 'link', + 'The URL to redirect the user to.', + 'TEXT', + TRUE, + FALSE + ); +-- Insert an example usage of the http_header component into the example table +INSERT INTO example (component, description) +VALUES ( + 'redirect', + ' +Redirect a user to the login page if they are not logged in: + +```sql +SELECT ''redirect'' AS component, ''login.sql'' AS link +WHERE NOT EXISTS (SELECT 1 FROM login_session WHERE id = sqlpage.cookie(''session_id'')); +``` +' + ); \ No newline at end of file diff --git a/examples/official-site/sqlpage/migrations/10_map.sql b/examples/official-site/sqlpage/migrations/10_map.sql new file mode 100644 index 0000000..040614c --- /dev/null +++ b/examples/official-site/sqlpage/migrations/10_map.sql @@ -0,0 +1,257 @@ +INSERT INTO + component (name, description, icon, introduced_in_version) +VALUES + ( + 'map', + ' + +## Visualize SQL data on a map. + +The map component displays a custom interactive map with markers on it. + +In its simplest form, the component displays points on a map from a table of latitudes and longitudes. +But it can also be used by cartographers in combination with PostgreSQL''s PostGIS or SQLite''s spatialite, +to create custom visualizations of geospatial data. +Use the `geojson` property to generate rich maps from a GIS database. + +### Example Use Cases + +1. **Store Locator**: Build an interactive map to find the nearest store information using SQL-stored geospatial data. +2. **Delivery Route Optimization**: Visualize the results of delivery route optimization algorithms. +3. **Sales Heatmap**: Identify high-performing regions by mapping sales data stored in SQL. +4. **Real-Time Tracking**: Create dynamic dashboards that track vehicles, assets, or users live using PostGIS or MS SQL Server geospatial time series data. Use the [shell](?component=shell) component to auto-refresh the map. +5. **Demographic Insights**: Map customer demographics or trends geographically to uncover opportunities for growth or better decision-making. +', + 'map', + '0.8.0' + ); + +-- Insert the parameters for the http_header component into the parameter table +INSERT INTO + parameter ( + component, + name, + description, + type, + top_level, + optional + ) +VALUES + ( + 'map', + 'latitude', + 'Latitude of the center of the map. If omitted, the map will be centered on its markers.', + 'REAL', + TRUE, + TRUE + ), + ( + 'map', + 'longitude', + 'Longitude of the center of the map.', + 'REAL', + TRUE, + TRUE + ), + ( + 'map', + 'zoom', + 'Zoom Level to apply to the map. Defaults to 5.', + 'REAL', + TRUE, + TRUE + ), + ( + 'map', + 'max_zoom', + 'How far the map can be zoomed in. Defaults to 18. Added in v0.15.2.', + 'INTEGER', + TRUE, + TRUE + ), + ( + 'map', + 'tile_source', + 'Custom map tile images to use, as a URL. Defaults to "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png". Added in v0.15.2.', + 'URL', + TRUE, + TRUE + ), + ( + 'map', + 'attribution', + 'Text to display at the bottom right of the map. Defaults to "© OpenStreetMap".', + 'HTML', + TRUE, + TRUE + ), + ( + 'map', + 'height', + 'Height of the map, in pixels. Default to 350px', + 'INTEGER', + TRUE, + TRUE + ), + ( + 'map', + 'latitude', + 'Latitude of the marker. Required only if geojson is not set.', + 'REAL', + FALSE, + FALSE + ), + ( + 'map', + 'longitude', + 'Longitude of the marker. Required only if geojson is not set.', + 'REAL', + FALSE, + FALSE + ), + ( + 'map', + 'title', + 'Title of the marker, displayed on hover and in the tooltip when the marker is clicked.', + 'TEXT', + FALSE, + TRUE + ), + ( + 'map', + 'link', + 'A link to associate to the marker''s title. If set, the marker tooltip''s title will be clickable and will open the link.', + 'TEXT', + FALSE, + TRUE + ), + ( + 'map', + 'description', + 'Plain text description of the marker, to be displayed in a tooltip when the marker is clicked.', + 'TEXT', + FALSE, + TRUE + ), + ( + 'map', + 'description_md', + 'Description of the marker, in markdown, rendered in a tooltip when the marker is clicked.', + 'TEXT', + FALSE, + TRUE + ), + ( + 'map', + 'icon', + 'Name of the icon to use for the marker', + 'ICON', + FALSE, + TRUE + ), + ( + 'map', + 'color', + 'Background color of the marker on the map. Requires "icon" to be set.', + 'COLOR', + FALSE, + TRUE + ), + ( + 'map', + 'geojson', + 'A GeoJSON geometry (line, polygon, ...) to display on the map. Can be styled using geojson properties using the name of leaflet path options. Introduced in 0.15.1. Accepts raw strings in addition to JSON objects since 0.15.2.', + 'JSON', + FALSE, + TRUE + ), + ( + 'map', + 'size', + 'Size of the marker icon. Requires "icon" to be set. Introduced in 0.15.2.', + 'INTEGER', + FALSE, + TRUE + ); + +-- Insert an example usage of the map component into the example table +INSERT INTO + example (component, description, properties) +VALUES + ( + 'map', + ' +### Adding a marker to a map + +Showing how to place a marker on a map. Useful for basic location displays like showing a single office location, event venue, or point of interest. The marker shows basic hover and click interactions. +', + JSON ( + '[{ "component": "map" }, { "title": "New Delhi", "latitude": 28.6139, "longitude": 77.2090 }]' + ) + ), + ( + 'map', + ' +### Advanced map customization using GeoJSON and custom map tiles + +This example demonstrates using topographic map tiles, custom marker styling, +and clickable markers that link to external content - perfect for educational or tourism applications. + +It uses [GeoJSON](https://en.wikipedia.org/wiki/GeoJSON) to display polygons and lines. + + - You can generate GeoJSON data from PostGIS geometries using the [`ST_AsGeoJSON`](https://postgis.net/docs/ST_AsGeoJSON.html) function. + - In spatialite, you can use the [`AsGeoJSON`](https://www.gaia-gis.it/gaia-sins/spatialite-sql-5.1.0.html#p3misc) function. + - In MySQL, you can use the [`ST_AsGeoJSON()`](https://dev.mysql.com/doc/refman/8.0/en/spatial-geojson-functions.html#function_st-asgeojson) function. +', + JSON ( + '[{ "component": "map", "zoom": 5, "max_zoom": 8, "height": 600, "latitude": -25, "longitude": 28, "tile_source": "https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png", "attribution": "" }, + { "icon": "peace", + "size": 20, + "link": "https://en.wikipedia.org/wiki/Nelson_Mandela", + "geojson": "{\"type\":\"Feature\", \"properties\": { \"title\":\"Mvezo, Birth Place of Nelson Mandela\" }, \"geometry\": { \"type\":\"Point\", \"coordinates\": [28.49, -31.96] }}"}]' + ) + ), + ( + 'map', + ' +### Maps with links and rich descriptions + +Demonstrates how to create an engaging map with custom icons, colors, rich descriptions with markdown support, and connecting points with lines. +Perfect for visualizing multi-dimensional relationships between points on a map, like routes between locations. + +Note that the map tile source is set to a MapTiler map. The API key included in the URL in this demo will not work on your own website. +You should get your own API key at [MapTiler](https://www.maptiler.com/cloud/). +', + JSON ( + '[ + { "component": "map", "title": "Paris", "zoom": 13, "latitude": 48.85, "longitude": 2.34, "tile_source": "https://api.maptiler.com/maps/streets-v2/{z}/{x}/{y}.png?key=RwoF6Y3gcKx4OBMbvqOY" }, + { "title": "Notre Dame", "icon": "building-castle", "color": "indigo", "latitude": 48.8530, "longitude": 2.3498, "description_md": "A beautiful cathedral.", "link": "https://en.wikipedia.org/wiki/Notre-Dame_de_Paris" }, + { "title": "Eiffel Tower", "icon": "tower", "color": "red", "latitude": 48.8584, "longitude": 2.2945, "description_md": "A tall tower. [Wikipedia](https://en.wikipedia.org/wiki/Eiffel_Tower)" }, + { "title": "Tower to Cathedral", "geojson": {"type": "LineString", "coordinates": [[2.2945, 48.8584], [2.3498, 48.8530]]}, "color": "teal", "description": "A nice 45 minutes walk." } + ]' + ) + ), + ( + 'map', + ' +### Abstract geometric visualizations + +Example showing how to create abstract geometric visualizations without a base map. +Useful for displaying spatial data that doesn''t need geographic context, like floor plans, seating charts, +or abstract 2D data visualizations. +', + JSON ( + '[ + { "component": "map", "tile_source": false }, + { "title": "MySQL", + "color": "red", "description": "This literal red square is defined as a GeoJSON polygon. Each (x,y) coordinate is a JSON array.", + "geojson": {"type": "Polygon", "coordinates": [[[0, 0], [0, 4], [4, 4], [4, 0], [0, 0]]]} + }, + { + "title": "SQLite", + "color": "blue", "description": "This 2D shape was generated by a SQL query.", + "geojson": {"type": "Polygon", "coordinates": [[[5, 0], [9, 0], [7, 4], [5, 0]]]} + } + ]' + ) + ); diff --git a/examples/official-site/sqlpage/migrations/11_json.sql b/examples/official-site/sqlpage/migrations/11_json.sql new file mode 100644 index 0000000..8384ab5 --- /dev/null +++ b/examples/official-site/sqlpage/migrations/11_json.sql @@ -0,0 +1,211 @@ +INSERT INTO + component (name, description, icon, introduced_in_version) +VALUES + ( + 'json', + 'Converts SQL query results into the JSON machine-readable data format. Ideal to quickly build APIs for interfacing with external systems. + +**JSON** is a widely used data format for programmatic data exchange. +For example, you can use it to integrate with web services written in different languages, +with mobile or desktop apps, or with [custom client-side components](/custom_components.sql) inside your SQLPage app. + +Use it when your application needs to expose data to external systems. +If you only need to render standard web pages, +and do not need other software to access your data, +you can ignore this component. + +This component **must appear at the top of your SQL file**, before any other data has been sent to the browser. +An HTTP response can have only a single datatype, and it must be declared in the headers. +So if you have already called the `shell` component, or another traditional HTML component, +you cannot use this component in the same file. + +SQLPage can also return JSON or JSON Lines when the incoming request says it prefers them with an HTTP `Accept` header, so the same `/users.sql` page can show a table in a browser but return raw data to `curl -H "Accept: application/json" http://localhost:8080/users.sql`. + +Use this component when you want to control the payload or force JSON output even for requests that would normally get HTML. +', + 'code', + '0.9.0' + ); + +-- Insert the parameters for the http_header component into the parameter table +INSERT INTO + parameter ( + component, + name, + description, + type, + top_level, + optional + ) +VALUES + ( + 'json', + 'contents', + 'A single JSON payload to send. You can use your database''s built-in json functions to build the value to enter here. If not provided, the contents will be taken from the next SQL statements and rendered as a JSON array.', + 'TEXT', + TRUE, + TRUE + ), + ( + 'json', + 'type', + 'The type of the JSON payload to send: "array", "jsonlines", or "sse". +In "array" mode, each query result is rendered as a JSON object in a single top-level array. +In "jsonlines" mode, results are rendered as JSON objects in separate lines, without a top-level array. +In "sse" mode, results are rendered as JSON objects in separate lines, prefixed by "data: ", which allows you to read the results as server-sent events in real-time from javascript.', + 'TEXT', + TRUE, + TRUE + ); + +-- Insert an example usage of the http_header component into the example table +INSERT INTO + example (component, description) +VALUES + ( + 'json', + ' +## Send query results as a single JSON array: `''array'' as type` + +The default `array` mode sends the query results as a single JSON array. + +If a query returns an error, the array will contain an object with an `error` property. + +If multiple queries are executed, all query results will be concatenated into a single array +of heterogeneous objects. + +### SQL + +```sql +select ''json'' AS component; +select * from users; +``` + +### Result + +```json +[ + {"username":"James","userid":1}, + {"username":"John","userid":2} +] +``` + +Clients can also receive JSON or JSON Lines automatically by requesting the same SQL file with an HTTP `Accept` header such as `application/json` or `application/x-ndjson` when the component is omitted, for example: + +``` +curl -H "Accept: application/json" http://localhost:8080/users.sql +``` + ' + ), + ( + 'json', + ' +## Send a single JSON object: `''jsonlines'' as type` + +In `jsonlines` mode, each query result is rendered as a JSON object in a separate line, +without a top-level array. + +If there is a single query result, the response will be a valid JSON object. +If there are multiple query results, you will need to parse each line of the response as a separate JSON object. + +If a query returns an error, the response will be a JSON object with an `error` property. + +### SQL + +The following SQL creates an API endpoint that takes a `user_id` URL parameter +and returns a single JSON object containing the user''s details, with one json object key per column in the `users` table. + +```sql +select ''json'' AS component, ''jsonlines'' AS type; +select * from users where id = $user_id LIMIT 1; +``` + +> Note the `LIMIT 1` clause. The `jsonlines` type will send one JSON object per result row, +> separated only by a single newline character (\n). +> So if your query returns multiple rows, the result will not be a single valid JSON object, +> like most JSON parsers expect. + +### Result + +```json +{ "username":"James", "userid":1 } +``` +' + ), + ( + 'json', + ' +## Create a complex API endpoint: the `''contents''` property + +You can create an API endpoint that will return a JSON value in any format you want, +to implement a complex API. + +You should use [the json functions provided by your database](/blog.sql?post=JSON%20in%20SQL%3A%20A%20Comprehensive%20Guide) to form the value you pass to the `contents` property. +To build a json array out of rows from the database, you can use: + - `json_group_array()` in SQLite, + - `json_agg()` in Postgres, or + - `JSON_ARRAYAGG()` in MySQL. + - `FOR JSON PATH` in SQL Server. + + +```sql +SELECT ''json'' AS component, + JSON_OBJECT( + ''users'', ( + SELECT JSON_GROUP_ARRAY( + JSON_OBJECT( + ''username'', username, + ''userid'', id + ) + ) FROM users + ) + ) AS contents; +``` + +This will return a JSON response that looks like this: + +```json +{ + "users" : [ + { "username":"James", "userid":1 } + ] +} +``` + +If you want to handle custom API routes, like `POST /api/users/:id`, +you can use + - the [`404.sql` file](/your-first-sql-website/custom_urls.sql) to handle the request despite the URL not matching any file, + - the [`request_method` function](/functions.sql?function=request_method#function) to differentiate between GET and POST requests, + - and the [`path` function](/functions.sql?function=path#function) to extract the `:id` parameter from the URL. +' + ), + ( + 'json', + ' +## Access query results in real-time with server-sent events: `''sse'' as type` + +Using server-sent events, you can stream large query results to the client in real-time, +row by row. + +This allows building sophisticated dynamic web applications that will start processing and displaying +the first rows of data in the browser while the database server is still processing the end of the query. + +### SQL + +```sql +select ''json'' AS component, ''sse'' AS type; +select * from users; +``` + +### JavaScript + +```javascript +const eventSource = new EventSource("users.sql"); +eventSource.onmessage = function (event) { + const user = JSON.parse(event.data); + console.log(user.username); +} +eventSource.onerror = () => eventSource.close(); // do not reconnect after reading all the data +``` +' + ); diff --git a/examples/official-site/sqlpage/migrations/12_blog.sql b/examples/official-site/sqlpage/migrations/12_blog.sql new file mode 100644 index 0000000..773e29e --- /dev/null +++ b/examples/official-site/sqlpage/migrations/12_blog.sql @@ -0,0 +1,185 @@ +CREATE TABLE blog_posts ( + title TEXT PRIMARY KEY, + description TEXT NOT NULL, + icon TEXT NOT NULL, + external_url TEXT, + content TEXT, + created_at TIMESTAMP NOT NULL +); + +INSERT INTO blog_posts (title, description, icon, created_at, content) +VALUES + ( + 'SQLPage versus No-Code tools', + 'What are the advantages and disadvantages of SQLPage compared to No-Code tools?', + 'code-minus', + '2023-08-03', + ' +# Choosing Your Path: No-Code, Low-Code, or SQL-Based Development + +The platform you select shapes the entire trajectory of your application. +Each approach offers distinct advantages, yet demands different compromises - a choice that warrants careful consideration. + +## No-Code Platforms: Speed with Limitations + +No-Code platforms present a visual canvas for building applications without traditional programming. Whilst brilliant for rapid prototypes and straightforward departmental tools, they falter when confronted with complexity and scale. + +**Best suited to**: Quick internal tools and simple workflows + +### **Notable examples** + + - [NocoBase](https://www.nocobase.com/) + - [NocoDB](https://www.nocodb.com/) + - [Saltcorn](https://github.com/saltcorn/saltcorn) + + +## Low-Code Platforms: The Flexible Middle Ground + +These platforms artfully combine visual development with traditional coding. They maintain the power of custom code whilst accelerating development through carefully designed components. + +**Best suited to**: Complex applications requiring both speed and customisation + +### **Notable examples** + + - [Budibase](https://budibase.com/) + - [Directus](https://github.com/directus/directus) + - [Rowy](https://github.com/rowyio/rowy) + +## SQL-Based Development: Elegant Simplicity + +SQLPage offers a refreshingly direct approach: pure SQL-driven web applications. + +For those versed in SQL, it enables sophisticated data-driven applications without the overhead of additional frameworks. + +**Best suited to**: Data-centric applications and dashboards + +**Details**: [SQLPage on GitHub](https://github.com/sqlpage/SQLPage) + +## The AI Revolution in Development + +The emergence of Large Language Models (LLMs) has fundamentally shifted the landscape of application development. Tools that once demanded extensive coding expertise have become remarkably more accessible. AI assistants like ChatGPT excel particularly at generating SQL queries and database operations, making SQL-based platforms surprisingly approachable even for those with limited database experience. These AI companions serve as expert pair programmers, offering suggestions, debugging assistance, and ready-to-use code snippets. + +This transformation especially benefits platforms like SQLPage, where the AI''s prowess in SQL generation can bridge the traditional expertise gap. Even complex queries and database operations can be created through natural language conversations with AI assistants, democratising access to sophisticated data manipulation capabilities. + +## Making an Informed Choice + +Selecting the right development approach requires weighing multiple factors against your project''s specific needs. + +Consider these key decision points to guide your platform selection: + +### **Time Constraints** + - Immediate delivery required → No-Code + - Several days available → SQLPage or Low-Code + +### **Data Complexity** + - Structured data manipulation → SQLPage + - Complex workflows → Low-Code + +### **Team Expertise** + - SQL skills → SQLPage + - Limited technical expertise → No-Code + - Varied technical capabilities → Low-Code + +### **Control Requirements** + - Precise data layer control → SQLPage + - Visual design flexibility → Low-Code + - Speed over customisation → No-Code + +## Further Investigation + +For a thorough demonstration of SQLPage''s capabilities: [Building a Full Web Application with SQLPage](https://www.youtube.com/watch?v=mXdgmSdaXkg) +'); + +INSERT INTO blog_posts (title, description, icon, created_at, external_url) +VALUES ( + 'Repeating yourself thrice won''t make you a 3X developer', + 'A dive into the traditional 3-tier architecture and the DRY principle, and how tools like SQLPage helps you avoid repeating yourself.', + 'box-multiple-3', + '2023-08-01', + 'https://yrashk.medium.com/repeating-yourself-thrice-doesnt-turn-you-into-a-3x-developer-a778495229c0' +); + +INSERT INTO blog_posts (title, description, icon, created_at, content) +VALUES ( + '3 solutions to the 3 layer problem', + 'What is the 3 layer problem, and how SQLPage solves it?', + 'adjustments-question', + '2023-08-10', + ' +# 3 solutions to the 3 layer problem + +> Some interesting questions emerged from the article [Repeating yourself thrice doesn''t turn you into a 3X developer](https://yrashk.medium.com/repeating-yourself-thrice-doesnt-turn-you-into-a-3x-developer-a778495229c0). +> This short follow-up article aims to answer them and clarify some points. + +Hello all, + +I am Ophir Lojkine, the main contributor of the open-source application server **SQLPage**. + +The previous article focused on the conventional model of splitting applications into three distinct tiers: + +1. a graphical interface (_front-end_), +2. an application server (_back-end_), +3. and a database. + +In many projects, this results in three distinct implementations of the application’s data model: + +1. First, in SQL, in the form of tables, views, and relationships in the database, +2. Then, in _server side_ languages such as Java, Python, or PHP, to create an API managing access to the data, and to implement the business logic of the application, +3. Finally, in JavaScript or TypeScript to implement data manipulation in the user interface. + +![Traditional tiers model](blog/three-layers.svg) + +--- + +The topic of interest here is the duplication of the data model between the different layers, +and the communication overhead between them. +We are not talking about how the code is structured within each layer. +It can follow a Model-View-Controller pattern or not, it doesn''t matter. + +This three-layer model has several advantages: +specialization of the programmers, +parallelization of work, +scalability, +separation of concerns, +and an optimal exploitation of the capacities of the infrastructure on which each layer is deployed: +web browser, server application and database. + +Nevertheless, in large-scale projects, +there is often a certain redundancy of the code between the different layers, +as well as a non-negligible share of code dedicated to communication between them. +For small teams and solo developers, this becomes a major drawback. + +## 3 solutions + +Fortunately, there are several approaches to solving this problem: + +1. For **UI-centric applications** without complicated data processing needs, +you can almost completely abandon server-side development and **directly expose the data to the frontend**. +Open-source tools available in this space include Supabase, PocketBase or Hasura. +2. For **applications with a predominant business logic**, traditional _web frameworks_ +solve this problem by centralizing frontend and database control in the backend code. +A common solution involves using an ORM and templating system instead of a dedicated javascript application. +Popular solutions include Django, Ruby on Rails, or Symphony. +3. For simpler applications, it is possible to **avoid both backend and frontend development** +by adopting a _database-first_ approach. +This alternative, although less widespread, allows taking advantage of under-exploited modern capacities of relational databases. +The purpose of the original article was to introduce this lesser known approach. + * **SQLPage** is representative of this last category, + which allows designing a complete web application _in SQL_. + This leads to a loss of control over the precise visual appearance of the application, + which will get a “standardized” look and feel. + On the other hand, this translates into significant gains in terms of development speed, + simplicity and performance. + This solution is not intended to compete with traditional _frameworks_, + but rather integrate earlier in the life cycle of a project. + It thus makes it possible to quickly develop a data structure adapted to the application, + and to iterate over it while benefiting from continuous visual feedback on the final result. + Then, when the application grows, it’s easy to add a classic frontend and backend on top of the existing database, + without having to start from scratch. + +Whichever approach is chosen in the end, +a solid understanding of the conventional three-tier architecture, +as well as a clear perspective on the challenges it creates and the possible solutions, +facilitates decision-making and the evolution of the project with the best suited technologies. + ' + ); \ No newline at end of file diff --git a/examples/official-site/sqlpage/migrations/13_tab.sql b/examples/official-site/sqlpage/migrations/13_tab.sql new file mode 100644 index 0000000..1755404 --- /dev/null +++ b/examples/official-site/sqlpage/migrations/13_tab.sql @@ -0,0 +1,121 @@ +INSERT INTO component (name, description, icon, introduced_in_version) +VALUES ( + 'tab', + 'Build a tabbed interface, with each tab being a link to a page. Each tab can be in two states: active or inactive.', + 'row-insert-bottom', + '0.9.5' + ); +INSERT INTO parameter ( + component, + name, + description, + type, + top_level, + optional + ) +VALUES ( + 'tab', + 'title', + 'Text to display on the tab. If link is not set, the link will be the current page with a ''$tab'' parameter set to the tab''s title. If ''id'' is set, the page will be scrolled to the tab.', + 'TEXT', + FALSE, + FALSE + ), + ( + 'tab', + 'link', + 'Link to the page to display when the tab is clicked. By default, the link refers to the current page, with a ''tab'' parameter set to the tab''s title and hash set to the id (if passed) - this brings us back to the location of the tab after submission.', + 'TEXT', + FALSE, + TRUE + ), + ( + 'tab', + 'active', + 'Whether the tab is active or not. Defaults to false.', + 'BOOLEAN', + FALSE, + TRUE + ), + ( + 'tab', + 'icon', + 'Name of the icon to display on the tab. See tabler-icons.io for a list of available icons.', + 'TEXT', + FALSE, + TRUE + ), + ( + 'tab', + 'color', + 'Color of the tab. See preview.tabler.io/colors.html for a list of available colors.', + 'TEXT', + FALSE, + TRUE + ), + ( + 'tab', + 'description', + 'Description of the tab. This is displayed when the user hovers over the tab.', + 'TEXT', + FALSE, + TRUE + ), + ( + 'tab', + 'center', + 'Whether the tabs should be centered or not. Defaults to false.', + 'BOOLEAN', + TRUE, + TRUE + ) + ; + +INSERT INTO example (component, description, properties) +VALUES ( + 'tab', + 'This example shows a very basic set of three tabs. The first tab is active. You could use this at the top of a page for easy navigation. + +To implement contents that change based on the active tab, use the `tab` parameter in the page query string. +For example, if the page is `/my-page.sql`, then the first tab will have a link of `/my-page.sql?tab=My+First+tab`. + +You could then for instance display contents coming from the database based on the value of the `tab` parameter. +For instance: `SELECT ''text'' AS component, contents_md FROM my_page_contents WHERE tab = $tab`. +Or you could write different queries for different tabs and use the `$tab` parameter with a static value in a where clause to switch between tabs: + +```sql +select ''tab'' as component; +select ''Projects'' as title, $tab = ''Projects'' as active; +select ''Tasks'' as title, $tab = ''Tasks'' as active; + +select ''table'' as component; + +select * from my_projects where $tab = ''Projects''; +select * from my_tasks where $tab = ''Tasks''; +``` + +Note that the example below is completely static, and does not use the `tab` parameter to actually switch between tabs. +View the [dynamic tabs example](/examples/tabs/). +', + JSON( + '[ + { "component": "tab" }, + { "title": "This tab does not exist", "active": true, "link": "?component=tab&tab=tab_1" }, + { "title": "I am not a true tab", "link": "?component=tab&tab=tab_2" }, + { "title": "Do not click here", "link": "?component=tab&tab=tab_3" } + ]' + ) + ), + ( + 'tab', + 'This example shows a more sophisticated set of tabs. The tabs are centered, the active tab has a different color, and all the tabs have a custom link and icon.', + JSON( + '[ + { "component": "tab", "center": true }, + { "title": "Hero", "link": "?component=hero#component", "icon": "home", "description": "The hero component is a full-width banner with a title and an image." }, + { "title": "Tab", "link": "?component=tab#component", "icon": "user", "color": "purple" }, + { "title": "Card", "link": "?component=card#component", "icon": "credit-card" } + ]' + ) + ) + ; diff --git a/examples/official-site/sqlpage/migrations/14_sorry_i_forked.sql b/examples/official-site/sqlpage/migrations/14_sorry_i_forked.sql new file mode 100644 index 0000000..a21276b --- /dev/null +++ b/examples/official-site/sqlpage/migrations/14_sorry_i_forked.sql @@ -0,0 +1,97 @@ +INSERT INTO blog_posts (title, description, icon, created_at, content) +VALUES ( + 'I’m sorry I forked you', + 'SQLPage forked the sqlx database drivers library. Here is why.', + 'git-fork', + '2023-08-13', + ' +# I’m sorry I forked you + +I’ve been immersed in open source coding since my teenage years, and now, I can’t fathom a world without it. +Throughout my career as a computer engineer, I’ve yet to encounter a tech company that isn’t built on the foundation of free and open source software. +Each company adds its proprietary touch to this vast open source landscape, +but they are ultimately just forming a unique blend atop a colossal iceberg of shared resources. +In the software world, open source truly is the driving force behind innovation. + +## Unconventional Financial Currents in Software + +The software industry operates on a financial current that defies conventional norms. +Unlike other sectors where key players like oil companies rake in the riches by supplying essentials to other businesses, +the software realm flips the script. +In this landscape, it’s user-facing giants like Google that reap the profits, +while the very creators crafting the software forming the bedrock for Google and countless others often find themselves on a different end of the economic spectrum. + + +Open source developers observe this intriguing dynamic, +sometimes even finding satisfaction in witnessing how their freely contributed software +fuels the creation of multimillion-dollar ventures. + +## SQLx: A Rust Marvel + +[sqlx](https://crates.io/crates/sqlx) is one of the numerous software libraries +that lie at the foundation of this software iceberg. +It’s a formidable SQL database driver for the *Rust* programming language, +that harmonizes connection to a multitude of databases. +It garners approximately 20,000 daily downloads. + +### Version 0.7 + +sqlx’s main maintainer sought to find a middle ground – crafting good open source software while seeking a sustainable livelihood. +This endeavor led to a pivotal decision: extracting the database drivers from the core library. +While retaining most drivers as open source, **compatibility with Microsoft SQL Server was relinquished**. +This significant architectural shift also necessitated the removal of some other features from the core framework, +and the introduction of a new API, making it non trivial to migrate from the previous version. + +## SQLPage + +As the principal caretaker of the [SQLPage web application server](/), which relies on sqlx, +I faced a pivotal juncture. The path ahead diverged into two distinct trails: +1. a challenging migration to sqlx v0.7, making a cross on MSSQL support; +2. persisting with v0.6, a realm housing outdated and potentially vulnerable dependencies. + +After a lot of hesitation I chose a third path: **forking sqlx**. + +## I’m sorry I forked you + +I’m sorry I forked you, sqlx. I really am all for financially sustainable open source. +My hope is that the newfound proprietary drivers find success, +duly compensating *@abonander* for the invaluable contributions made. + +But I really need a good fully open source set of database drivers for Rust, +I need some of the features that were removed in v0.7, and most importantly, +I want to support SQL Server in SQLPage. +So I created [sqlx-oldapi](https://lib.rs/crates/sqlx-oldapi), a fork of sqlx v0.6. + +In the fork: + - I’ve meticulously updated all dependencies to their latest iterations, ensuring the foundation remains robust and secure. + - Essential features that were missing have been thoughtfully incorporated to address specific needs, and longstanding bugs have been resolved. Notably, data type support has been fortified, with efficient lossy decoding of `DECIMAL` values as floats across all drivers. + - My endeavors have been focused on elevating the SQL Server driver to the same level as its peers. This involved fixing bugs and crashes, and supporting new data types like `DATETIME` and `DECIMAL`. + +The full list of changes can be found in the [changelog](https://github.com/lovasoa/sqlx/blob/main/CHANGELOG.md). + +## Concluding Notes + + - My best wishes extend to sqlx on their pursuit of successful monetization. + May their path be paved with prosperity as they navigate this new chapter. + - To fellow developers facing a similar crossroads as I did with SQLPage, + know that [sqlx-oldapi](https://lib.rs/crates/sqlx-oldapi) awaits you, ready to empower your endeavors, for free. + Contributions and bug reports are all welcomed [on github](https://github.com/lovasoa/sqlx-oldapi). + +And if you are curious about why this page’s URL ends in `.sql`, check out [SQLPage](/). + +--- + +**Important addendum**: +The main contributor to sqlx reacted to this post, and they wanted to clarify two things: + + - sqlx is a project of the *Launchbase* company, not a personal project of *@abonander*. + Although he is the main contributor, important decisions are taken in consultation with the company. + If new drivers are monetized, the money will go to the company, not to him personally. + The stated goal is to then allocate the money to the development of sqlx. + - Most importantly: the current plan for the new drivers **is not to release them as proprietary** + code as initially planned, but to release them as open source, under the more restrictive *AGPL* license. + This means that they will be free to use for similarly licensed open source projects + (unlike SQLPage, which is free to use even in conjunction with proprietary software, under the *MIT* license). + Companies that want to use these future sqlx drivers in proprietary software will have to pay for a commercial license. + That change in the decision has been announced in 2022, and I should have been aware of it when I wrote this post. My bad. +'); \ No newline at end of file diff --git a/examples/official-site/sqlpage/migrations/16_timeline.sql b/examples/official-site/sqlpage/migrations/16_timeline.sql new file mode 100644 index 0000000..78f19ba --- /dev/null +++ b/examples/official-site/sqlpage/migrations/16_timeline.sql @@ -0,0 +1,103 @@ +INSERT INTO component (name, description, icon, introduced_in_version) +VALUES ( + 'timeline', + 'A list of events with a vertical line connecting them.', + 'git-commit', + '0.13.0' + ); +INSERT INTO parameter ( + component, + name, + description, + type, + top_level, + optional + ) +VALUES ( + 'timeline', + 'simple', + 'If set to true, the timeline will be displayed in a condensed format without icons.', + 'BOOLEAN', + TRUE, + TRUE + ), + ( + 'timeline', + 'title', + 'Name of the event.', + 'TEXT', + FALSE, + FALSE + ), + ( + 'timeline', + 'date', + 'Date of the event.', + 'TEXT', + FALSE, + FALSE + ), + ( + 'timeline', + 'icon', + 'Name of the icon to display next to the event. See tabler-icons.io for a list of available icons.', + 'TEXT', + FALSE, + TRUE + ), + ( + 'timeline', + 'color', + 'Color of the icon. See preview.tabler.io/colors.html for a list of available colors.', + 'TEXT', + FALSE, + TRUE + ), + ( + 'timeline', + 'description', + 'Textual description of the event.', + 'TEXT', + FALSE, + TRUE + ), + ( + 'timeline', + 'description_md', + 'Description of the event in Markdown.', + 'TEXT', + FALSE, + TRUE + ), + ( + 'timeline', + 'link', + 'Link to a page with more information about the event.', + 'TEXT', + FALSE, + TRUE + ); +INSERT INTO example (component, description, properties) +VALUES ( + 'timeline', + 'A basic timeline with just names and dates.', + JSON( + '[ + { "component": "timeline", "simple": true }, + { "title": "New message from Elon Musk", "date": "13:00" }, + { "title": "Jeff Bezos assigned task \"work more\" to you.", "date": "yesterday, 16:35" } + ]' + ) + ), + ( + 'timeline', + 'A full-fledged timeline with icons, colors, and rich text descriptions.', + JSON( + '[ + { "component": "timeline" }, + { "title": "v0.13.0 was just released !", "link": "https://github.com/sqlpage/SQLPage/releases/", "date": "2023-10-16", "icon": "brand-github", "color": "green", "description_md": "This version introduces the `timeline` component." }, + { "title": "They are talking about us...", "description_md": "[This article](https://www.postgresql.org/about/news/announcing-sqlpage-build-dynamic-web-applications-in-sql-2672/) on the official PostgreSQL website mentions SQLPage.", "date": "2023-07-12", "icon": "database", "color": "blue" } + ]' + ) + ) + ; \ No newline at end of file diff --git a/examples/official-site/sqlpage/migrations/18_button.sql b/examples/official-site/sqlpage/migrations/18_button.sql new file mode 100644 index 0000000..2f6ad4c --- /dev/null +++ b/examples/official-site/sqlpage/migrations/18_button.sql @@ -0,0 +1,119 @@ +-- Button Component Documentation + +-- Component Definition +INSERT INTO component(name, icon, description, introduced_in_version) VALUES + ('button', 'hand-click', 'A versatile button component do display one or multiple button links of different styles.', '0.14.0'); + +-- Inserting parameter information for the button component +INSERT INTO parameter(component, name, description, type, top_level, optional) SELECT 'button', * FROM (VALUES + -- Top-level parameters (for the whole button list) + ('justify', 'The horizontal alignment of the button list (e.g., start, end, center, between).', 'TEXT', TRUE, TRUE), + ('size', 'The size of the buttons (e.g., sm, lg).', 'TEXT', TRUE, TRUE), + ('shape', 'Shape of the buttons (e.g., pill, square)', 'TEXT', TRUE, TRUE), + -- Item-level parameters (for each button) + ('link', 'The URL to which the button should navigate when clicked. If the form attribute is specified, then this overrides the page to which the form is submitted.', 'URL', FALSE, TRUE), + ('color', 'The color of the button (e.g., red, green, blue, but also primary, warning, danger, etc.). Only base color names are supported, not variations like "blue-lt" or "gray-300". Use a custom CSS stylesheet to further customize the colors.', 'COLOR', FALSE, TRUE), + ('title', 'The text displayed on the button.', 'TEXT', FALSE, TRUE), + ('tooltip', 'Text displayed when the user hovers over the button.', 'TEXT', FALSE, TRUE), + ('disabled', 'Whether the button is disabled or not.', 'BOOLEAN', FALSE, TRUE), + ('outline', 'Outline color of the button (e.g. red, purple, ...)', 'COLOR', FALSE, TRUE), + ('space_after', 'Whether there should be extra space to the right of the button. In a line of buttons, this will put the buttons before this one on the left, and the ones after on the right.', 'BOOLEAN', FALSE, TRUE), + ('icon_after', 'Name of an icon to display after the text in the button', 'ICON', FALSE, TRUE), + ('icon', 'Name of an icon to be displayed on the left side of the button.', 'ICON', FALSE, TRUE), + ('image', 'Path to image file (relative. relative to web root or URL) to be displayed on the button.', 'TEXT', FALSE, TRUE), + ('narrow', 'Whether to trim horizontal padding.', 'BOOLEAN', FALSE, TRUE), + ('form', 'Identifier (id) of the form to which the button should submit.', 'TEXT', FALSE, TRUE), + ('rel', '"nofollow" when the contents of the target link are not endorsed, "noopener" when the target is not trusted, and "noreferrer" to hide where the user came from when they open the link.', 'TEXT', FALSE, TRUE), + ('target', '"_blank" to open the link in a new tab, "_self" to open it in the same tab, "_parent" to open it in the parent frame, or "_top" to open it in the full body of the window.', 'TEXT', FALSE, TRUE), + ('download', 'If defined, the link will download the target instead of navigating to it. Set the value to the desired name of the downloaded file.', 'TEXT', FALSE, TRUE), + ('id', 'HTML Identifier to add to the button element.', 'TEXT', FALSE, TRUE) +) x; + +-- Inserting example information for the button component +INSERT INTO example(component, description, properties) VALUES + ('button', 'A basic button with a link', + json('[{"component":"button"}, {"link":"/documentation.sql", "title":"Enabled"}, {"link":"#", "title":"Disabled", "disabled":true}]')) +; + + +INSERT INTO example(component, description, properties) VALUES + ('button', 'A button with a custom shape, size, and outline color', + json('[{"component":"button", "size":"sm", "shape":"pill" }, + {"title":"Purple", "outline":"purple" }, + {"title":"Orange", "outline":"orange" }, + {"title":"Red", "outline":"red" }]') + ); + +INSERT INTO example(component, description, properties) VALUES + ('button', 'A list of buttons aligned in the center', + json('[{"component":"button", "justify":"center"}, + {"link":"#", "color":"light", "title":"Light"}, + {"link":"#", "color":"success", "title":"Success"}, + {"link":"#", "color":"info", "title":"Info"}, + {"link":"#", "color":"dark", "title":"Dark"}, + {"link":"#", "color":"warning", "title":"Warning"}, + {"link":"#", "color":"danger", "title":"Narrow"}]') + ); + +INSERT INTO example(component, description, properties) VALUES + ('button', 'Icon buttons using the narrow property', + json('[{"component":"button"}, + {"link":"#", "narrow":true, "icon":"edit", "color":"primary", "tooltip":"Edit" }, + {"link":"#", "narrow":true, "icon":"trash", "color":"danger", "tooltip":"Delete" }, + {"link":"#", "narrow":true, "icon":"corner-down-right", "color":"info", "tooltip":"Preview" }, + {"link":"#", "narrow":true, "icon":"download", "color":"success", "tooltip":"Download" }, + {"link":"#", "narrow":true, "icon":"upload", "color":"warning", "tooltip":"Upload" }, + {"link":"#", "narrow":true, "icon":"info-circle", "color":"cyan", "tooltip":"Info" }, + {"link":"#", "narrow":true, "icon":"help-circle", "color":"purple", "tooltip":"Help" }, + {"link":"#", "narrow":true, "icon":"settings", "color":"indigo", "tooltip":"Settings" }]') + ); + +INSERT INTO example(component, description, properties) VALUES + ('button', 'Buttons with icons and different sizes', + json('[{"component":"button", "size":"lg" }, + {"link":"#", "outline":"azure", "title":"Edit", "icon":"edit"}, + {"link":"#", "outline":"danger", "title":"Delete", "icon":"trash"}]') + ); + +INSERT INTO example(component, description, properties) VALUES + ('button', 'A row of square buttons with spacing in between', + json('[{"component":"button", "shape":"square"}, + {"link":"#", "color":"green", "title":"Save", "icon": "device-floppy" }, + {"link":"#", "title":"Cancel", "space_after":true, "tooltip": "This will delete your draft"}, + {"link":"#", "outline":"indigo", "title":"Preview", "icon_after": "corner-down-right", "tooltip": "View temporary draft" }]') + ); + +INSERT INTO example(component, description, properties) VALUES + ('button', 'Multiple buttons sending the same form to different pages. + +We use `'''' AS validate` to remove the submit button from inside the form itself, +and instead use the button component to submit the form to pages with different GET variables. + +In the target page, we could then use the GET variable `$action` to determine what to do with the form data. + ', + json('[{"component":"form", "id": "poem", "validate": ""}, + {"type": "textarea", "name": "Poem", "placeholder": "Write a poem"}, + {"component":"button"}, + {"link":"?action=save", "form":"poem", "color":"primary", "title":"Save" }, + {"link":"?action=preview", "form":"poem", "outline":"yellow", "title":"Preview" }]') + ); + +INSERT INTO example(component, description, properties) VALUES + ('button', 'A button that downloads a file when clicked, and prevents search engines from following the link.', + json('[{"component":"button"}, + {"link":"/sqlpage_introduction_video.webm", + "title":"Download Video", + "icon":"download", + "download":"Introduction Video.webm", + "rel":"nofollow" + }]') + ); + +INSERT INTO example(component, description, properties) VALUES + ('button', 'A button with an image-based icon.', + json('[{"component":"button"}, + {"link":"https://en.wikipedia.org/wiki/File:Globe.svg", + "title":"Open an article", + "image":"https://upload.wikimedia.org/wikipedia/commons/f/fa/Globe.svg" + }]') + ); diff --git a/examples/official-site/sqlpage/migrations/20_variables_function.sql b/examples/official-site/sqlpage/migrations/20_variables_function.sql new file mode 100644 index 0000000..7922856 --- /dev/null +++ b/examples/official-site/sqlpage/migrations/20_variables_function.sql @@ -0,0 +1,119 @@ +-- Insert the 'variables' function into sqlpage_functions table +INSERT INTO sqlpage_functions ( + "name", + "introduced_in_version", + "icon", + "description_md" +) +VALUES ( + 'variables', + '0.15.0', + 'variable', + 'Returns a JSON string containing variables from the HTTP request and user-defined variables. + +The [database''s json handling functions](/blog?post=JSON+in+SQL%3A+A+Comprehensive+Guide) can then be used to process the data. + +## Variable Types + +SQLPage distinguishes between three types of variables: + +- **GET variables**: URL parameters from the [query string](https://en.wikipedia.org/wiki/Query_string) (immutable) +- **POST variables**: Values from form fields [submitted](https://en.wikipedia.org/wiki/POST_(HTTP)#Use_for_submitting_web_forms) by the user (immutable) +- **SET variables**: User-defined variables created with the `SET` command (mutable) + +For more information about SQLPage variables, see the [*SQL in SQLPage* guide](/extensions-to-sql). + +## Usage + +- `sqlpage.variables()` - returns all variables (GET, POST, and SET combined). When multiple variables of the same name are present, the order of precedence is: set > post > get. +- `sqlpage.variables(''get'')` - returns only URL parameters +- `sqlpage.variables(''post'')` - returns only POST form data +- `sqlpage.variables(''set'')` - returns only user-defined variables created with `SET` + +When a SET variable has the same name as a GET or POST variable, the SET variable takes precedence in the combined result. + +## Example: a form with a variable number of fields + +### Making a form based on questions in a database table + +We can create a form which has a field for each value in a given table like this: + +```sql +select ''form'' as component, ''handle_survey_answer.sql'' as action; +select question_id as name, question_text as label from survey_questions; +``` + +### Handling form responses using `sqlpage.variables` + +In `handle_survey_answer.sql`, one can process the form results even if we don''t know in advance +how many fields it contains. +The function to parse JSON data varies depending on the database engine you use. + +#### In SQLite +In SQLite, one can use [`json_each`](https://www.sqlite.org/json1.html#jeach) : + +```sql +insert into survey_answers(question_id, answer) +select "key", "value" from json_each(sqlpage.variables(''post'')) +``` + +#### In Postgres + +Postgres has [`json_each_text`](https://www.postgresql.org/docs/9.3/functions-json.html) : + +```sql +INSERT INTO survey_answers (question_id, answer) +SELECT key AS question_id, value AS answer +FROM json_each_text(sqlpage.variables(''post'')::json); +``` + + +#### In Microsoft SQL Server + +```sql +INSERT INTO survey_answers +SELECT [key] AS question_id, [value] AS answer +FROM OPENJSON(sqlpage.variables(''post'')); +``` + +#### In MySQL + +MySQL has [`JSON_TABLE`](https://dev.mysql.com/doc/refman/8.0/en/json-table-functions.html), +and [`JSON_KEYS`](https://dev.mysql.com/doc/refman/8.0/en/json-search-functions.html#function_json-keys) +which are a little bit less straightforward to use: + +```sql +INSERT INTO survey_answers (question_id, answer) +SELECT + question_id, + json_unquote( + json_extract( + sqlpage.variables(''post''), + concat(''$."'', question_id, ''"'') + ) + ) +FROM json_table( + json_keys(sqlpage.variables(''post'')), + ''$[*]'' columns (question_id int path ''$'') +) as question_ids +``` + +' +); + +-- Insert the parameters for the 'variables' function into sqlpage_function_parameters table +-- Parameter 1: 'method' parameter +INSERT INTO sqlpage_function_parameters ( + "function", + "index", + "name", + "description_md", + "type" +) +VALUES ( + 'variables', + 1, + 'method', + 'Optional. Filter variables by source: ''get'' (URL parameters), ''post'' (form data), or ''set'' (user-defined variables). When not provided, all variables are returned with SET variables taking precedence over request parameters.', + 'TEXT' +); diff --git a/examples/official-site/sqlpage/migrations/21_path.sql b/examples/official-site/sqlpage/migrations/21_path.sql new file mode 100644 index 0000000..5fc1afa --- /dev/null +++ b/examples/official-site/sqlpage/migrations/21_path.sql @@ -0,0 +1,26 @@ +INSERT INTO sqlpage_functions ( + "name", + "introduced_in_version", + "icon", + "description_md" + ) +VALUES ( + 'path', + '0.15.0', + 'slashes', + 'Returns the request path of the current page. +This is useful to generate links to the current page, and when you have a proxy in front of your SQLPage server that rewrites the URL. + +### Example + +If we have a page in a file named `my page.sql` at the root of your SQLPage installation +then the following SQL query: + +```sql +select ''text'' as component, sqlpage.path() as contents; +``` + +will return `/my%20page.sql`. + +> Note that the path is URL-encoded. +'); \ No newline at end of file diff --git a/examples/official-site/sqlpage/migrations/22_pgconf_eu.sql b/examples/official-site/sqlpage/migrations/22_pgconf_eu.sql new file mode 100644 index 0000000..2647d3b --- /dev/null +++ b/examples/official-site/sqlpage/migrations/22_pgconf_eu.sql @@ -0,0 +1,29 @@ +INSERT INTO blog_posts (title, description, icon, created_at, content) +VALUES ( + 'Come see me build twitter live on stage in Prague', + 'I will speak about SQLPage at pgconf.eu in Prague on December 14th. Come see me !', + 'calendar-event', + '2023-11-11', + ' +# SQLPage live on stage + +Hello everyone ! + +If some of you european SQLPagers are around Prague this december, +I will be giving a talk about SQLPage at [pgconf.eu](https://2023.pgconf.eu/) on December 14th. + +I will be talking about website building in general, SQLPage in particular, +cool things you can do with it, +how it works, how and why I built it, +and why I still think it can cut web development and prototyping times by a factor of 10. + +I will also be building a tiny social network live on stage, using SQLPage. + +Come see me ! + + - [pgconf.eu](https://2023.pgconf.eu/) + - [Information about the talk](https://www.postgresql.eu/events/pgconfeu2023/schedule/session/4687-sqlpage-building-a-full-web-application-with-nothing-but-postgres-and-sql-queries/) + - When ? **December 14th, 2023**, **09:30 - 10:20** + - Where ? Zenit Room, [Clarion Congress Hotel, Prague, Czech Republic](https://maps.app.goo.gl/qHCAaRFRjXmx2kQA7) + +'); \ No newline at end of file diff --git a/examples/official-site/sqlpage/migrations/23_uploaded_file_functions.sql b/examples/official-site/sqlpage/migrations/23_uploaded_file_functions.sql new file mode 100644 index 0000000..30fcb2e --- /dev/null +++ b/examples/official-site/sqlpage/migrations/23_uploaded_file_functions.sql @@ -0,0 +1,195 @@ +-- Insert the 'variables' function into sqlpage_functions table +INSERT INTO sqlpage_functions ( + "name", + "introduced_in_version", + "icon", + "description_md" +) +VALUES ( + 'uploaded_file_path', + '0.17.0', + 'upload', + 'Returns the path to a temporary file containing the contents of an uploaded file. + +## Example: handling a picture upload + +### Making a form + +```sql +select ''form'' as component, ''handle_picture_upload.sql'' as action; +select ''myfile'' as name, ''file'' as type, ''Picture'' as label; +select ''title'' as name, ''text'' as type, ''Title'' as label; +``` + +### Handling the form response + +### Inserting an image file as a [data URL](https://en.wikipedia.org/wiki/Data_URI_scheme) into the database + +In `handle_picture_upload.sql`, one can process the form results like this: + +```sql +insert into pictures (title, path) values (:title, sqlpage.read_file_as_data_url(sqlpage.uploaded_file_path(''myfile''))); +``` + +> *Note*: Data URLs are larger than the original file, so it is not recommended to use them for large files. + +### Inserting file contents as text into the database + +When the uploaded file is a simple raw text file (e.g. a `.txt` file), +one can use the [`sqlpage.read_file_as_text`](?function=read_file_as_text#function) +function to insert the contents of the file into the database like this: + +```sql +insert into text_documents (title, path) values (:title, sqlpage.read_file_as_text(sqlpage.uploaded_file_path(''my_text_file''))); +``` + +### Saving the uploaded file to a permanent location + +When the uploaded file is larger than a few megabytes, it is not recommended to store it in the database. +Instead, one can save the file to a permanent location on the server, and store the path to the file in the database. + +You can move the file to a permanent location using the [`sqlpage.persist_uploaded_file`](?function=persist_uploaded_file#function) function. +### Advanced file handling + +For more advanced file handling, such as uploading files to a cloud storage service, +you can write a small script in your favorite programming language, +and call it using the [`sqlpage.exec`](?function=exec#function) function. + +For instance, one could save the following small bash script to `/usr/local/bin/upload_to_s3`: + +```bash +#!/bin/bash +aws s3 cp "$1" s3://your-s3-bucket-name/ +echo "https://your-s3-bucket-url/$(basename "$1")" +``` + +Then, you can call it from SQL like this: + +```sql +set url = sqlpage.exec(''upload_to_s3'', sqlpage.uploaded_file_path(''myfile'')); +insert into uploaded_files (title, path) values (:title, $url); +``` +' +), +( + 'uploaded_file_mime_type', + '0.18.0', + 'file-settings', + 'Returns the MIME type of an uploaded file. + +## Example: handling a picture upload + +When letting the user upload a picture, you may want to check that the uploaded file is indeed an image. + +```sql +select ''redirect'' as component, + ''invalid_file.sql'' as link +where sqlpage.uploaded_file_mime_type(''myfile'') not like ''image/%''; +``` + +In `invalid_file.sql`, you can display an error message to the user: + +```sql +select ''alert'' as component, ''Error'' as title, + ''Invalid file type'' as description, + ''alert-circle'' as icon, ''red'' as color; +``` + +## Example: white-listing file types + +You could have a database table containing the allowed MIME types, and check that the uploaded file is of one of those types: + +```sql +select ''redirect'' as component, + ''invalid_file.sql'' as link +where sqlpage.uploaded_file_mime_type(''myfile'') not in (select mime_type from allowed_mime_types); +``` +' +), +( + 'uploaded_file_name', + '0.23.0', + 'file-description', + 'Returns the `filename` value in the `content-disposition` header. + +## Example: saving uploaded file metadata for later download + +### Making a form + +```sql +select ''form'' as component, ''handle_file_upload.sql'' as action; +select ''myfile'' as name, ''file'' as type, ''File'' as label; +``` + +### Handling the form response + +### Inserting an arbitrary file as a [data URL](https://en.wikipedia.org/wiki/Data_URI_scheme) into the database + +In `handle_file_upload.sql`, one can process the form results like this: + +```sql +insert into uploaded_files (fname, content, uploaded) values ( + sqlpage.uploaded_file_name(''myfile''), + sqlpage.read_file_as_data_url(sqlpage.uploaded_file_path(''myfile'')), + CURRENT_TIMESTAMP +); +``` + +> *Note*: Data URLs are larger than the original file, so it is not recommended to use them for large files. + +### Downloading the uploaded files + +The file can be downloaded by clicking a link like this: +```sql +select ''button'' as component; +select name as title, content as link from uploaded_files where name = $file_name limit 1; +``` + +> *Note*: because the file is ecoded as a data uri, the file is transferred to the client whether or not the link is clicked + +### Large files + +See the [`sqlpage.uploaded_file_path`](?function=uploaded_file_path#function) function. + +See the [`sqlpage.persist_uploaded_file`](?function=persist_uploaded_file#function) function. +' +); + +INSERT INTO sqlpage_function_parameters ( + "function", + "index", + "name", + "description_md", + "type" +) +VALUES ( + 'uploaded_file_path', + 1, + 'name', + 'Name of the file input field in the form.', + 'TEXT' +), +( + 'uploaded_file_path', + 2, + 'allowed_mime_type', + 'Makes the function return NULL if the uploaded file is not of the specified MIME type. + If omitted, any MIME type is allowed. + This makes it possible to restrict the function to only accept certain file types.', + 'TEXT' +), +( + 'uploaded_file_mime_type', + 1, + 'name', + 'Name of the file input field in the form.', + 'TEXT' +), +( + 'uploaded_file_name', + 1, + 'name', + 'Name of the file input field in the form.', + 'TEXT' +) +; diff --git a/examples/official-site/sqlpage/migrations/24_read_file_as_data_url.sql b/examples/official-site/sqlpage/migrations/24_read_file_as_data_url.sql new file mode 100644 index 0000000..eba68ad --- /dev/null +++ b/examples/official-site/sqlpage/migrations/24_read_file_as_data_url.sql @@ -0,0 +1,66 @@ +-- Insert the 'variables' function into sqlpage_functions table +INSERT INTO sqlpage_functions ( + "name", + "introduced_in_version", + "icon", + "description_md" +) +VALUES ( + 'read_file_as_data_url', + '0.17.0', + 'file-dollar', + 'Returns a [data URL](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs) +containing the contents of the given file. + +The file path is relative to the `web root` directory, which is the directory from which your website is served. +By default, this is the directory SQLPage is launched from, but you can change it +with the `web_root` [configuration option](https://github.com/sqlpage/SQLPage/blob/main/configuration.md). + +If the given argument is null, the function will return null. + +As with other functions, if an error occurs during execution +(because the file does not exist, for instance), +the function will display an error message and the +database query will not be executed. + +If you are using a `sqlpage_files` table to store files directly in the database (serverless mode), +the function will attempt to read the file from the database filesystem if it is not found on the local disk, +using the same logic as for serving files in response to HTTP requests. + +## MIME type + +Data URLs contain the [MIME type](https://en.wikipedia.org/wiki/Media_type) of the file they represent. +If the first argument to this function is the result of a call to the `sqlpage.uploaded_file_path` function, +the declared MIME type of the uploaded file transmitted by the browser will be used. + +Otherwise, the MIME type will be guessed from the file extension, without looking at the file contents. + + +## Example: inlining a picture + +```sql +select ''card'' as component; +select ''Picture'' as title, sqlpage.read_file_as_data_url(''/path/to/picture.jpg'') as top_image; +``` + +> **Note:** Data URLs are larger than the original file they represent, so they should only be used for small files +> (under a few hundred kilobytes). +> Otherwise, the page will take a long time to load. +'); + +-- Insert the parameters for the 'variables' function into sqlpage_function_parameters table +-- Parameter 1: 'method' parameter +INSERT INTO sqlpage_function_parameters ( + "function", + "index", + "name", + "description_md", + "type" +) +VALUES ( + 'read_file_as_data_url', + 1, + 'name', + 'Path to the file to read.', + 'TEXT' +); diff --git a/examples/official-site/sqlpage/migrations/25_read_file_as_text.sql b/examples/official-site/sqlpage/migrations/25_read_file_as_text.sql new file mode 100644 index 0000000..9cdae13 --- /dev/null +++ b/examples/official-site/sqlpage/migrations/25_read_file_as_text.sql @@ -0,0 +1,54 @@ +-- Insert the 'variables' function into sqlpage_functions table +INSERT INTO sqlpage_functions ( + "name", + "introduced_in_version", + "icon", + "description_md" +) +VALUES ( + 'read_file_as_text', + '0.17.0', + 'file-invoice', + 'Returns a string containing the contents of the given file. + +The file must be a raw text file using UTF-8 encoding. + +The file path is relative to the `web root` directory, which is the directory from which your website is served +(not necessarily the directory SQLPage is launched from). + +If the given argument is null, the function will return null. + +As with other functions, if an error occurs during execution +(because the file does not exist, for instance), +the function will display an error message and the +database query will not be executed. + +If you are using a `sqlpage_files` table to store files directly in the database (serverless mode), +the function will attempt to read the file from the database filesystem if it is not found on the local disk, +using the same logic as for serving files in response to HTTP requests. + +## Example + +### Rendering a markdown file + +```sql +select ''text'' as component, sqlpage.read_file_as_text(''/path/to/file.md'') as contents_md; +``` +'); + +-- Insert the parameters for the 'variables' function into sqlpage_function_parameters table +-- Parameter 1: 'method' parameter +INSERT INTO sqlpage_function_parameters ( + "function", + "index", + "name", + "description_md", + "type" +) +VALUES ( + 'read_file_as_text', + 1, + 'name', + 'Path to the file to read.', + 'TEXT' +); diff --git a/examples/official-site/sqlpage/migrations/26_v0.17_release.sql b/examples/official-site/sqlpage/migrations/26_v0.17_release.sql new file mode 100644 index 0000000..60bff59 --- /dev/null +++ b/examples/official-site/sqlpage/migrations/26_v0.17_release.sql @@ -0,0 +1,170 @@ +INSERT INTO blog_posts (title, description, icon, created_at, content) +VALUES ( + 'SQLPage v0.17', + 'SQLPage v0.17 introduces file uploads, HTTPS, and more.', + 'git-fork', + '2023-11-28', + ' +# SQLPage v0.17 is out ! + +[SQLPage](/) is a web application server that lets you build entire web applications with just SQL queries. +v0.17 was just released, and it''s worth a blog post to highlight some of the coolest new features. + +Mostly, this release makes it a matter of minutes to build a data import pipeline for your website, +and a matter of seconds to deploy your SQLPage website securely with automatic HTTPS certificates. + +## Uploads + +This release is all about a long awaited feature: **file uploads**. +Your SQLPage website can now accept file uploads from users, +store them either in a directory or directly in a database table. + +You can add a file upload button to a form with a simple + +```sql +select ''form'' as component; +select ''profile_picture'' as name, ''file'' as type; +``` + +when received by the server, the file will be saved in a temporary directory +(customizable with `TMPDIR` on linux). +You can access the temporary file path with +the new [`sqlpage.uploaded_file_path`](/functions.sql?function=uploaded_file_path#function) function. + +You can then persist the upload as a permanent file on the server with the +[`sqlpage.exec`](https://sql-page.com/functions.sql?function=exec#function) function: + +```sql +set file_path = sqlpage.uploaded_file_path(''profile_picture''); +select sqlpage.exec(''mv'', $file_path, ''/path/to/my/file''); +``` + +or you can store it directly in a database table with the new +[`sqlpage.read_file_as_data_url`](https://sql-page.com/functions.sql?function=read_file_as_data_url#function) and +[`sqlpage.read_file_as_text`](https://sql-page.com/functions.sql?function=read_file_as_text#function) functions: + +```sql +insert into files (url) values (sqlpage.read_file_as_data_url(sqlpage.uploaded_file_path(''profile_picture''))) +returning ''text'' as component, ''Uploaded new file with id: '' || id as contents; +``` + +The maximum size of uploaded files is configurable with the [`max_uploaded_file_size`](https://github.com/sqlpage/SQLPage/blob/main/configuration.md) +configuration parameter. By default, it is set to 5 MiB. + +### Parsing CSV files + +SQLPage can also parse uploaded [CSV](https://en.wikipedia.org/wiki/Comma-separated_values) files and insert them directly into a database table. +SQLPage re-uses PostgreSQL''s [`COPY` syntax](https://www.postgresql.org/docs/current/sql-copy.html) +to import the CSV file into the database. +When connected to a PostgreSQL database, SQLPage will use the native `COPY` statement, +for super fast and efficient on-database CSV parsing. +But it will also work with any other database as well, by +parsing the CSV locally and emulating the same behavior with simple `INSERT` statements. + +#### `user_file_upload.sql` +```sql +select ''form'' as component, ''bulk_user_import.sql'' as action; +select ''user_csv_file'' as name, ''file'' as type, ''text/csv'' as accept; +``` + +#### `bulk_user_import.sql` +```sql +-- create a temporary table to preprocess the data +create temporary table if not exists csv_import(name text, age text); +delete from csv_import; -- empty the table +-- If you don''t have any preprocessing to do, you can skip the temporary table and use the target table directly + +copy csv_import(name, age) from ''user_csv_file'' +with (header true, delimiter '','', quote ''"'', null ''NaN''); -- all the options are optional +-- since header is true, the first line of the file will be used to find the "name" and "age" columns +-- if you don''t have a header line, the first column in the CSV will be interpreted as the first column of the table, etc + +-- run any preprocessing you want on the data here + +-- insert the data into the users table +insert into users (name, birth_date) +select upper(name), date_part(''year'', CURRENT_DATE) - cast(age as int) from csv_import; +``` + +### New functions + +#### Handle uploaded files + + - [`sqlpage.uploaded_file_path`](https://sql-page.com/functions.sql?function=uploaded_file_path#function) to get the temprary local path of a file uploaded by the user. This path will be valid until the end of the current request, and will be located in a temporary directory (customizable with `TMPDIR`). You can use [`sqlpage.exec`](https://sql-page.com/functions.sql?function=exec#function) to operate on the file, for instance to move it to a permanent location. + - [`sqlpage.uploaded_file_mime_type`](https://sql-page.com/functions.sql?function=uploaded_file_mime_type#function) to get the type of file uploaded by the user. This is the MIME type of the file, such as `image/png` or `text/csv`. You can use this to easily check that the file is of the expected type before storing it. + + The new [*Image gallery* example](https://github.com/sqlpage/SQLPage/tree/main/examples/image%20gallery%20with%20user%20uploads) +in the official repository shows how to use these functions to create a simple image gallery with user uploads. + +#### Read files + +These new functions are useful to read the contents of a file uploaded by the user, +but can also be used to read any file on the computer where SQLPage is running: + + - [`sqlpage.read_file_as_text`](https://sql-page.com/functions.sql?function=read_file_as_text#function) reads the contents of a file on the server and returns a text string. + - [`sqlpage.read_file_as_data_url`](https://sql-page.com/functions.sql?function=read_file_as_data_url#function) reads the contents of a file on the server and returns a [data URL](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs). This is useful to embed images directly in web pages, or make link + +## HTTPS + +This is the other big feature of this release: SQLPage now supports HTTPS ! +Until now, if you wanted to use HTTPS with SQLPage, you had to put it behind a +*reverse proxy*, which is what the official documentation website does. + +This required a lot of manual configuration +that would compromise your security if you get it wrong. + +With SQLPage v0.17, you just give your domain name, +and it takes care of everything. + +And while we''re at it, SQLPage also supports HTTP/2, for even faster page loads. + +To enable HTTPS, you need to buy a [domain name](https://en.wikipedia.org/wiki/Domain_name) +and make it point to the server where SQLPage is running. +Then set the `https_domain` configuration parameter to `yourdomain.com` in your [`sqlpage.json` configuration file](./configuration.md). + +```json +{ + "https_domain": "my-cool-website.com" +} +``` + +That''s it. No external tool to install, no certificate to generate, no configuration to tweak. +No need to restart SQLPage regularly either, or to worry about renewing your certificate when it expires. +SQLPage will automatically request a certificate from [Let''s Encrypt](https://letsencrypt.org/) by default, +and does not even need to listen on port 80 to do so. + +## SQL parser improvements + +SQLPage needs to parse SQL queries to be able to bind the right parameters to them, +and to inject the results of built-in sqlpage functions in them. +The parser we use is very powerful and supports most SQL features, +but there are some edge cases where it fails to parse a query. +That''s why we contribute to it a lot, and bring the latest version of the parser to SQLPage as soon as it is released. + +### JSON functions in MS SQL Server + +SQLPage now supports the [`FOR JSON` syntax](https://learn.microsoft.com/en-us/sql/relational-databases/json/format-query-results-as-json-with-for-json-sql-server?view=sql-server-ver16&tabs=json-path) in MS SQL Server. + +This unlocks a lot of new possibilities, that were previously only available in other databases. + +This is particularly interesting to build complex menus with the `shell` component, +to build multiple-answer select inputs with the `form` component, +and to create JSON APIs. + +### Other sql syntax enhancements + + - SQLPage now supports the custom `CONVERT` expression syntax for MS SQL Server, and the one for MySQL. + - The `VARCHAR(MAX)` type in MS SQL Server new works. We now use it for all variables bound as parameters to your SQL queries (we used to use `VARCHAR(8000)` before). + - `INSERT INTO ... DEFAULT VALUES ...` is now parsed correctly. + +## Other news + + - Dates and timestamps returned from the database are now always formatted in ISO 8601 format, which is the standard format for dates in JSON. This makes it easier to use dates in SQLPage. + - The `cookie` component now supports setting an explicit expiration date for cookies. + - The `cookie` component now supports setting the `SameSite` attribute of cookies, and defaults to `SameSite=Strict` for all cookies. What this means in practice is that cookies set by SQLPage will not be sent to your website if the user is coming from another website. This prevents someone from tricking your users into executing SQLPage queries on your website by sending them a malicious link. + - Bugfix: setting `min` or `max` to `0` in a number field in the `form` component now works as expected. + - Added support for `.env` files to set SQLPage''s [environment variables](./configuration.md#environment-variables). + - Better responsive design in the card component. Up to 5 cards per line on large screens. The number of cards per line is still customizable with the `columns` attribute. + - [New icons](https://tabler-icons.io/changelog): + - ![new icons in tabler 42](https://github.com/tabler/tabler-icons/assets/1282324/00856af9-841d-4aa9-995d-121c7ddcc005) +'); \ No newline at end of file diff --git a/examples/official-site/sqlpage/migrations/27_protocol.sql b/examples/official-site/sqlpage/migrations/27_protocol.sql new file mode 100644 index 0000000..1b0e155 --- /dev/null +++ b/examples/official-site/sqlpage/migrations/27_protocol.sql @@ -0,0 +1,30 @@ +INSERT INTO sqlpage_functions ( + "name", + "introduced_in_version", + "icon", + "description_md" + ) +VALUES ( + 'protocol', + '0.17.1', + 'network', + 'Returns the protocol that was used to access the current page. + +This can be either `http` or `https`. + +This is useful to generate links to the current page. + +### Example + +```sql +select ''text'' as component, + sqlpage.protocol() || ''://'' || sqlpage.header(''host'') || sqlpage.path() as contents; +``` + +will return `https://example.com/example.sql`. + +> Note that the path is URL-encoded. The protocol is resolved in this order: +> - `Forwarded` header +> - `X-Forwarded-Proto` header +> request target / URI +'); \ No newline at end of file diff --git a/examples/official-site/sqlpage/migrations/28_tracking_component.sql b/examples/official-site/sqlpage/migrations/28_tracking_component.sql new file mode 100644 index 0000000..00a9748 --- /dev/null +++ b/examples/official-site/sqlpage/migrations/28_tracking_component.sql @@ -0,0 +1,128 @@ +INSERT INTO component (name, description, icon, introduced_in_version) +VALUES ( + 'tracking', + 'Component for visualising activity logs or other monitoring-related data.', + 'timeline-event-text', + '0.18.0' + ); + +INSERT INTO parameter ( + component, + name, + description, + type, + top_level, + optional + ) +VALUES ( + 'tracking', + 'title', + 'Title of the tracking component.', + 'TEXT', + TRUE, + FALSE + ), + ( + 'tracking', + 'information', + 'A short text displayed below the title.', + 'TEXT', + TRUE, + TRUE + ), + ( + 'tracking', + 'description', + 'A short paragraph.', + 'TEXT', + TRUE, + TRUE + ), + ( + 'tracking', + 'description_md', + 'A short paragraph formatted using markdown.', + 'TEXT', + TRUE, + TRUE + ), + ( + 'tracking', + 'width', + 'Width of the component, between 1 and 12.', + 'INTEGER', + TRUE, + TRUE + ), + ( + 'tracking', + 'placement', + 'Position of the tooltip (e.g. top, bottom, right, left)', + 'TEXT', + TRUE, + TRUE + ), + ( + 'tracking', + 'color', + 'Color of the tracked item (e.g. success, warning, danger)', + 'TEXT', + FALSE, + TRUE + ), + ( + 'tracking', + 'title', + 'Description of the state.', + 'TEXT', + FALSE, + FALSE + ), + ( + 'tracking', + 'center', + 'Whether to center the component.', + 'BOOLEAN', + TRUE, + TRUE + ); + +-- Insert example(s) for the component +INSERT INTO example(component, description, properties) +VALUES + ( + 'tracking', + 'A basic example of servers tracking component', + JSON( + '[ + {"component":"tracking","title":"Servers status"}, + {"title":"No data"}, + {"title":"No data"}, + {"title":"No data"}, + {"title":"No data"}, + {"title":"No data"}, + {"title":"No data"}, + {"title":"No data"}, + {"title":"No data"} + ]' + ) + ), + ( + 'tracking', + 'An example of servers tracking component', + JSON( + '[ + {"component":"tracking","title":"Servers status","information":"60% are running","description_md":"Status of all **currently running servers**","placement":"top","width":4}, + {"color":"success","title":"operational"}, + {"color":"success","title":"operational"}, + {"color":"success","title":"operational"}, + {"color":"danger","title":"Downtime"}, + {"title":"No data"}, + {"color":"success","title":"operational"}, + {"color":"warning","title":"Big load"}, + {"color":"success","title":"operational"} + ]' + ) + ); + + diff --git a/examples/official-site/sqlpage/migrations/29_divider_component.sql b/examples/official-site/sqlpage/migrations/29_divider_component.sql new file mode 100644 index 0000000..3acaa4e --- /dev/null +++ b/examples/official-site/sqlpage/migrations/29_divider_component.sql @@ -0,0 +1,152 @@ +INSERT INTO component (name, description, icon, introduced_in_version) +VALUES ( + 'divider', + 'Dividers help organize content and make the interface layout clear and uncluttered.', + 'separator', + '0.18.0' + ); + +INSERT INTO parameter ( + component, + name, + description, + type, + top_level, + optional + ) +VALUES ( + 'divider', + 'contents', + 'A text in the divider.', + 'TEXT', + TRUE, + TRUE + ), + ( + 'divider', + 'position', + 'Position of the text (e.g. left, right).', + 'TEXT', + TRUE, + TRUE + ), + ( + 'divider', + 'color', + 'The name of a color for this span of text.', + 'COLOR', + TRUE, + TRUE + ), + ( + 'divider', + 'size', + 'The size of the divider text, from 1 to 6.', + 'INTEGER', + TRUE, + TRUE + ), + ( + 'divider', + 'bold', + 'Whether the text is bold.', + 'BOOLEAN', + TRUE, + TRUE + ), + ( + 'divider', + 'italics', + 'Whether the text is italicized.', + 'BOOLEAN', + TRUE, + TRUE + ), + ( + 'divider', + 'underline', + 'Whether the text is underlined.', + 'BOOLEAN', + TRUE, + TRUE + ), + ( + 'divider', + 'link', + 'URL of the link for the divider text. Available only when contents is present.', + 'URL', + TRUE, + TRUE + ); + +-- Insert example(s) for the component +INSERT INTO example(component, description, properties) +VALUES + ( + 'divider', + 'An empty divider', + JSON( + '[ + { + "component":"divider" + } + ]' + ) + ), + ( + 'divider', + 'A divider with centered text', + JSON( + '[ + { + "component":"divider", + "contents":"Hello" + } + ]' + ) + ), + ( + 'divider', + 'A divider with text at left', + JSON( + '[ + { + "component":"divider", + "contents":"Hello", + "position":"left" + } + ]' + ) + ), + ( + 'divider', + 'A divider with blue text and a link', + JSON( + '[ + { + "component":"divider", + "contents":"SQLPage components", + "link":"/documentation.sql", + "color":"blue" + } + ]' + ) + ), + ( + 'divider', + 'A divider with bold, italic, and underlined text', + JSON( + '[ + { + "component":"divider", + "contents":"Important notice", + "position":"left", + "color":"red", + "size":5, + "bold":true, + "italics":true, + "underline":true + } + ]' + ) + ); \ No newline at end of file diff --git a/examples/official-site/sqlpage/migrations/30_breadcrumb_component.sql b/examples/official-site/sqlpage/migrations/30_breadcrumb_component.sql new file mode 100644 index 0000000..6ec4837 --- /dev/null +++ b/examples/official-site/sqlpage/migrations/30_breadcrumb_component.sql @@ -0,0 +1,76 @@ +INSERT INTO component (name, description, icon, introduced_in_version) +VALUES ( + 'breadcrumb', + 'A secondary navigation aid that helps users understand their location on a website or mobile application.', + 'dots', + '0.18.0' + ); + +INSERT INTO parameter ( + component, + name, + description, + type, + top_level, + optional + ) + VALUES ( + 'breadcrumb', + 'title', + 'Hyperlink text to display.', + 'TEXT', + FALSE, + FALSE + ), + ( + 'breadcrumb', + 'link', + 'Link to the page to display when the link is clicked. By default, the link refers to the current page, with a ''link'' parameter set to the link''s title.', + 'TEXT', + FALSE, + TRUE + ), + ( + 'breadcrumb', + 'active', + 'Whether the link is active or not. Defaults to false.', + 'TEXT', + FALSE, + TRUE + ), + ( + 'breadcrumb', + 'description', + 'Description of the link. This is displayed when the user hovers over the link.', + 'TEXT', + FALSE, + TRUE + ); + +-- Insert example(s) for the component +INSERT INTO example(component, description, properties) +VALUES + ( + 'breadcrumb', + 'Basic usage of the breadcrumb component', + JSON( + '[ + {"component":"breadcrumb"}, + {"title":"Home","link":"/"}, + {"title":"Components", "link":"/documentation.sql"}, + {"title":"Breadcrumb", "link":"?component=breadcrumb"} + ]' + ) + ), + ( + 'breadcrumb', + 'Description of a link and selection of the current page.', + JSON( + '[ + {"component":"breadcrumb"}, + {"title":"Home","link":"/","active": true}, + {"title":"Articles","link":"/blog.sql","description":"Stay informed with the latest news"}, + {"title":"JSON in SQL","link":"/blog.sql?post=JSON%20in%20SQL%3A%20A%20Comprehensive%20Guide", "description": "Learn advanced json functions for MySQL, SQLite, PostgreSQL, and SQL Server" } + ]' + ) + ); diff --git a/examples/official-site/sqlpage/migrations/31_card_docs_update.sql b/examples/official-site/sqlpage/migrations/31_card_docs_update.sql new file mode 100644 index 0000000..3131e59 --- /dev/null +++ b/examples/official-site/sqlpage/migrations/31_card_docs_update.sql @@ -0,0 +1,80 @@ +DELETE FROM component WHERE name = 'card'; + +INSERT INTO component(name, icon, description) VALUES + ('card', 'credit-card', 'A grid where each element is a small card that displays a piece of data.'); +INSERT INTO parameter(component, name, description, type, top_level, optional) SELECT 'card', * FROM (VALUES + -- top level + ('title', 'Text header at the top of the list of cards.', 'TEXT', TRUE, TRUE), + ('description', 'A short paragraph displayed below the title.', 'TEXT', TRUE, TRUE), + ('description_md', 'A short paragraph displayed below the title - formatted using markdown.', 'TEXT', TRUE, TRUE), + ('columns', 'The number of columns in the grid of cards. This is just a hint, the grid will adjust dynamically to the user''s screen size, rendering fewer columns if needed to fit the contents. To control the size of cards individually, use the `width` row-level property instead.', 'INTEGER', TRUE, TRUE), + -- item level + ('title', 'Name of the card, displayed at the top.', 'TEXT', FALSE, FALSE), + ('description', 'The body of the card, where you put the main text contents of the card. + This does not support rich text formatting, only plain text. + If you want to use rich text formatting, use the `description_md` property instead.', 'TEXT', FALSE, TRUE), + ('description_md', ' + The body of the card, in Markdown format. + This is useful if you want to display a lot of text in the card, with many options for formatting, such as + line breaks, **bold**, *italics*, lists, #titles, [links](target.sql), ![images](photo.jpg), etc.', 'TEXT', FALSE, TRUE), + ('top_image', 'The URL (absolute or relative) of an image to display at the top of the card.', 'URL', FALSE, TRUE), + ('top_image_lazy', 'Whether the top image must be lazily loaded. Defaults to false, meaning eagerly loaded.', 'BOOLEAN', FALSE, TRUE), + ('top_image_width', 'Specify the top image width, in pixels. Helps prevent layout shifts.', 'INTEGER', FALSE, TRUE), + ('top_image_height', 'Specify the top image height, in pixels. Helps prevent layout shifts.', 'INTEGER', FALSE, TRUE), + ('footer', 'Muted text to display at the bottom of the card.', 'TEXT', FALSE, TRUE), + ('footer_md', 'Muted text to display at the bottom of the card, with rich text formatting in Markdown format.', 'TEXT', FALSE, TRUE), + ('link', 'An URL to which the user should be taken when they click on the card.', 'URL', FALSE, TRUE), + ('footer_link', 'An URL to which the user should be taken when they click on the footer.', 'URL', FALSE, TRUE), + ('style', 'Inline style property to your iframe embed code. For example "background-color: #FFFFFF"', 'TEXT', FALSE, TRUE), + ('icon', 'Name of an icon to display on the right side of the card.', 'ICON', FALSE, TRUE), + ('color', 'The name of a color, to be displayed on the left of the card to highlight it. If the embed parameter is enabled and you don''t have a title or description, this parameter won''t apply.', 'COLOR', FALSE, TRUE), + ('background_color', 'The background color of the card.', 'COLOR', FALSE, TRUE), + ('active', 'Whether this item in the grid is considered "active". Active items are displayed more prominently.', 'BOOLEAN', FALSE, TRUE), + ('width', 'The width of the card, between 1 (smallest) and 12 (full-width). The default width is 3, resulting in 4 cards per line.', 'INTEGER', FALSE, TRUE) +) x; +INSERT INTO parameter(component, name, description_md, type, top_level, optional) SELECT 'card', * FROM (VALUES + ('embed', 'A url whose contents will be fetched and injected into the body of this card. + This can be used to inject arbitrary html content, but is especially useful for injecting + the output of other sql files rendered by SQLPage. For the latter case you can pass the + `?_sqlpage_embed` query parameter, which will skip the shell layout', 'TEXT', FALSE, TRUE), + ('embed_mode', 'Set to ''iframe'' to embed the target (specified through embed property) in an iframe. + Unless this is explicitly set, the embed target is fetched and injected within the parent page. If embed_mode is set to iframe, + You can also set height and width parameters to configure the appearance and the sandbox and allow parameters to configure + security aspects of the iframe. Refer to the [MDN page](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe) + for an explanation of these parameters.', 'TEXT', FALSE, TRUE) +) x; + +INSERT INTO example(component, description, properties) VALUES + ('card', 'A beautiful card grid with bells and whistles, showing examples of SQLPage features.', + json('[{"component":"card", "title":"Popular SQLPage features", "columns": 2}, + {"title": "Download as spreadsheet", "link": "?component=csv#component", "description": "Using the CSV component, you can download your data as a spreadsheet.", "icon":"file-plus", "color": "green", "footer_md": "SQLPage can both [read](?component=form#component) and [write](?component=csv#component) **CSV** files."}, + {"title": "Custom components", "link": "/custom_components.sql", "description": "If you know some HTML, you can create your own components for your application.", "icon":"code", "color": "orange", "footer_md": "You can look at the [source of the official components](https://github.com/sqlpage/SQLPage/tree/main/sqlpage/templates) for inspiration."} + ]')), + ('card', 'You can use cards to display a dashboard with quick access to important information. Use [markdown](https://www.markdownguide.org/basic-syntax) to format the text.', + json('[ + {"component": "card", "columns": 4}, + {"description_md": "**152** sales today", "active": true, "icon": "currency-euro"}, + {"description_md": "**13** new users", "icon": "user-plus", "color": "green"}, + {"description_md": "**2** complaints", "icon": "alert-circle", "color": "danger", "link": "?view_complaints", "background_color": "red-lt"}, + {"description_md": "**1** pending support request", "icon": "mail-question", "color": "warning"} + ]')), + ('card', 'A gallery of images.', + json('[ + {"component":"card", "title":"My favorite animals in pictures", "columns": 3}, + {"title": "Lynx", "description_md": "The **lynx** is a medium-sized **wild cat** native to Northern, Central and Eastern Europe to Central Asia and Siberia, the Tibetan Plateau and the Himalayas.", "top_image": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3a/Lynx_lynx_-_05.jpg/330px-Lynx_lynx_-_05.jpg", "top_image_width": 330, "top_image_height": 495, "top_image_lazy": true, "icon":"star" }, + {"title": "Squirrel", "description_md": "The **chipmunk** is a small, striped rodent of the family Sciuridae. Chipmunks are found in North America, with the exception of the Siberian chipmunk which is found primarily in Asia.", "top_image": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4a/American_squirrel_eating_nut%2C_13_Jun_2013.JPG/330px-American_squirrel_eating_nut%2C_13_Jun_2013.JPG", "top_image_width": 330, "top_image_height": 495, "top_image_lazy": true }, + {"title": "Spider", "description_md": "The **jumping spider family** (_Salticidae_) contains more than 600 described genera and about *6000 described species*, making it the largest family of spiders with about 13% of all species.", "top_image": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/0f/Australian_orb_weaver_spinning_web.jpg/330px-Australian_orb_weaver_spinning_web.jpg", "top_image_width": 330, "top_image_height": 495, "top_image_lazy": true } + ]')), + ('card', 'Beautifully colored cards with variable width. The blue card (width 6) takes half the screen, whereas of the red and green cards have the default width of 3', + json('[ + {"component":"card", "title":"Beautifully colored cards" }, + {"title": "Red card", "color": "red", "background_color": "red-lt", "description": "Penalty! You are out!", "icon":"play-football" }, + {"title": "Blue card", "color": "blue", "width": 6, "background_color": "blue-lt", "description": "The Blue Card facilitates migration of foreigners to Europe.", "icon":"currency-euro" }, + {"title": "Green card", "color": "green", "background_color": "green-lt", "description": "Welcome to the United States of America !", "icon":"user-dollar" } + ]')), + ('card', 'Cards with remote content', + json('[ + {"component":"card", "title":"Card with embedded remote content", "columns": 2}, + {"title": "Embedded Chart", "embed": "/examples/chart.sql?_sqlpage_embed" }, + {"title": "Embedded Video", "embed": "https://www.youtube.com/embed/mXdgmSdaXkg", "allow": "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share", "embed_mode": "iframe", "height": "350" } + ]')); diff --git a/examples/official-site/sqlpage/migrations/33_blog_v018.sql b/examples/official-site/sqlpage/migrations/33_blog_v018.sql new file mode 100644 index 0000000..627b6d9 --- /dev/null +++ b/examples/official-site/sqlpage/migrations/33_blog_v018.sql @@ -0,0 +1,44 @@ + +INSERT INTO blog_posts (title, description, icon, created_at, content) +VALUES + ( + 'New SQLPage, and a talk at PGConf.eu', + 'SQLPage v0.18.0 is out, and there is detailed introduction to SQLPage on youtube', + 'brand-youtube', + '2024-01-29', + ' +[SQLPage](https://sql-page.com) is a small web server that renders your SQL queries as beautiful interactive websites. This release has seen significant new features and fixes from new contributors, which is great and show the health of the project ! If you feel something is missing or isn''t working quite right, all your contributions are always welcome. + +On a side note, I [gave a talk about SQLPage last December at PGConf.eu](https://www.youtube.com/watch?v=mXdgmSdaXkg). +It is a great detailed introduction to SQLPage, and I recommend it if you want to learn more about the project. + +1. **New `tracking` component for beautiful and compact status reports:** This feature adds a new way to display status reports, making them more visually appealing and concise. + 1. ![screenshot](https://github.com/sqlpage/SQLPage/assets/552629/3e792953-3870-469d-a01d-898316b2ab32) + + +3. **New `divider` component to add a horizontal line between other components:** This simple yet useful addition allows for better separation of elements on your pages. + 1. ![image](https://github.com/sqlpage/SQLPage/assets/552629/09a2cc77-3b37-401f-ab3e-441637a2c022) + +5. **New `breadcrumb` component to display a breadcrumb navigation bar:** This component helps users navigate through your website''s hierarchical structure, providing a clear path back to the homepage. + 1. ![image](https://github.com/sqlpage/SQLPage/assets/552629/cbf2174a-1d75-499e-9d6b-e111136dbbbc) + +8. **Multi-column layouts with `embed` attribute in `card` component:** This feature enables you to create more complex and dynamic layouts within cards. + 1. ![image](https://github.com/sqlpage/SQLPage/assets/552629/3f4435f0-d89b-424e-8b8a-39385a61d5ad) + + +6. **Customizable y-axis step size in `chart` component with `ystep` attribute:** This feature gives you more control over the chart''s appearance, especially for situations with multiple series. + +7. **Updated default graph colors for better distinction:** This enhancement ensures clarity and easy identification of different data series. + +10. **ID and class attributes for all components for easier styling and referencing:** This improvement simplifies custom CSS customization and inter-page element linking. + +11. **Implementation of `uploaded_file_mime_type` function:** This function allows you to determine the MIME type of a uploaded file. + +12. **Upgraded built-in SQLite database to version 3.45.0:** This ensures compatibility with recent SQLite features and bug fixes. See [sqlite release notes](https://www.sqlite.org/releaselog/3_45_0.html) + +13. **Unicode support for built-in SQLite database:** This enables case-insensitive string comparisons and lower/upper case transformations. + +5. **Improved `card` component with smaller margin below footer text:** This fix ensures consistent and visually balanced card layouts. + + ' + ); \ No newline at end of file diff --git a/examples/official-site/sqlpage/migrations/34_carousel.sql b/examples/official-site/sqlpage/migrations/34_carousel.sql new file mode 100644 index 0000000..6736ccf --- /dev/null +++ b/examples/official-site/sqlpage/migrations/34_carousel.sql @@ -0,0 +1,160 @@ +INSERT INTO component (name, description, icon, introduced_in_version) +VALUES ( + 'carousel', + 'A carousel is used to display images. When used with multiple images, it will cycle through them automatically or with controls, creating a slideshow.', + 'carousel-horizontal', + '0.18.3' + ); +INSERT INTO parameter ( + component, + name, + description, + type, + top_level, + optional + ) +VALUES + ( + 'carousel', + 'title', + 'A name to display at the top of the carousel.', + 'TEXT', + TRUE, + TRUE + ), + ( + 'carousel', + 'indicators', + 'Style of image indicators (square or dot).', + 'TEXT', + TRUE, + TRUE + ), + ( + 'carousel', + 'vertical', + 'Whether to use the vertical image indicators.', + 'BOOLEAN', + TRUE, + TRUE + ), + ( + 'carousel', + 'controls', + 'Whether to show the control links to go previous or next item.', + 'BOOLEAN', + TRUE, + TRUE + ), + ( + 'carousel', + 'width', + 'Width of the component, between 1 and 12. Default is 12.', + 'INTEGER', + TRUE, + TRUE + ), + ( + 'carousel', + 'auto', + 'Whether to automatically cycle through the carousel items. Default is false.', + 'BOOLEAN', + TRUE, + TRUE + ), + ( + 'carousel', + 'center', + 'Whether to center the carousel.', + 'BOOLEAN', + TRUE, + TRUE + ), + ( + 'carousel', + 'fade', + 'Whether to apply the fading effect.', + 'BOOLEAN', + TRUE, + TRUE + ), + ( + 'carousel', + 'delay', + 'Specify the delay, in milliseconds, between two images.', + 'INTEGER', + TRUE, + TRUE + ), + ( + 'carousel', + 'image', + 'The URL (absolute or relative) of an image to display in the carousel.', + 'URL', + FALSE, + FALSE + ), + ( + 'carousel', + 'title', + 'Add caption to the slide.', + 'TEXT', + FALSE, + TRUE + ), + ( + 'carousel', + 'description', + 'A short paragraph.', + 'TEXT', + FALSE, + TRUE + ), + ( + 'carousel', + 'description_md', + 'A short paragraph formatted using markdown.', + 'TEXT', + FALSE, + TRUE + ), + ( + 'carousel', + 'width', + 'The width of the image, in pixels.', + 'INTEGER', + FALSE, + TRUE + ), + ( + 'carousel', + 'height', + 'The height of the image, in pixels.', + 'INTEGER', + FALSE, + TRUE + ); +-- Insert example(s) for the component +INSERT INTO example(component, description, properties) +VALUES ( + 'carousel', + 'A basic example of carousel', + JSON( + '[ + {"component":"carousel","name":"cats1","title":"Famous Database Animals"}, + {"image":"https://upload.wikimedia.org/wikipedia/commons/thumb/d/d7/Elefantes_africanos_de_sabana_%28Loxodonta_africana%29%2C_Elephant_Sands%2C_Botsuana%2C_2018-07-28%2C_DD_114-117_PAN.jpg/2560px-Elefantes_africanos_de_sabana_%28Loxodonta_africana%29%2C_Elephant_Sands%2C_Botsuana%2C_2018-07-28%2C_DD_114-117_PAN.jpg"}, + {"image":"https://upload.wikimedia.org/wikipedia/commons/thumb/9/99/Penguin_Island_panorama_with_ferry_and_dolphins_in_foreground%2C_March_2023_06.jpg/1280px-Penguin_Island_panorama_with_ferry_and_dolphins_in_foreground%2C_March_2023_06.jpg"} + ]' + ) + ), + ( + 'carousel', + 'An advanced example of carousel with controls', + JSON( + '[ + {"component":"carousel","title":"SQL web apps","width":6, "center":true,"controls":true,"auto":true}, + {"image":"/sqlpage_cover_image.webp","title":"SQLPage is modern","description":"Built by engineers who have built so many web applications the old way, they decided they just wouldn''t anymore.", "height": 512}, + {"image":"/sqlpage_illustration_alien.webp","title":"SQLPage is easy", "description":"SQLPage connects to your database, then it turns your SQL queries into nice websites.", "height": 512} + ]' + ) + ); \ No newline at end of file diff --git a/examples/official-site/sqlpage/migrations/35_code_title.sql b/examples/official-site/sqlpage/migrations/35_code_title.sql new file mode 100644 index 0000000..75419fa --- /dev/null +++ b/examples/official-site/sqlpage/migrations/35_code_title.sql @@ -0,0 +1,115 @@ +-- Documentation for the title component +INSERT INTO component (name, description, icon, introduced_in_version) VALUES ( + 'title', + 'Defines HTML headings. The level 1 is used for the maximal size and the level 6 is used for the minimal size.', + 'letter-case-upper', + '0.19.0' +); + +INSERT INTO parameter (component,name,description,type,top_level,optional) VALUES ( + 'title', + 'center', + 'Whether to center the title.', + 'BOOLEAN', + TRUE, + TRUE +),( + 'title', + 'contents', + 'A text to display.', + 'TEXT', + TRUE, + FALSE +),( + 'title', + 'level', + 'Set the heading level (default level is 1)', + 'INTEGER', + TRUE, + TRUE +); + +-- Insert example(s) for the component +INSERT INTO example(component, description, properties) VALUES ( + 'title', + 'Displays several titles with different levels.', + JSON( + '[ + {"component":"title","contents":"Level 1"}, + {"component":"title","contents":"Level 2","level": 2}, + {"component":"title","contents":"Level 3","level": 3} + ]' + ) +); + +INSERT INTO example(component, description, properties) VALUES ( + 'title', + 'Displays a centered title.', + JSON( + '[ + {"component":"title","contents":"Level 1","center": true} + ]' + ) +); + +-- Documentation for the code component +INSERT INTO component (name, description, icon, introduced_in_version) VALUES ( + 'code', + 'Displays one or many blocks of code from a programming language or formated text as XML or JSON.', + 'code', + '0.19.0' +); + +INSERT INTO parameter (component,name,description,type,top_level,optional) VALUES ( + 'code', + 'title', + 'Set the heading level (default level is 1)', + 'TEXT', + FALSE, + TRUE +),( + 'code', + 'contents', + 'A block of code.', + 'TEXT', + FALSE, + FALSE +),( + 'code', + 'description', + 'Description of the snipet of code.', + 'TEXT', + FALSE, + TRUE +),( + 'code', + 'description_md', + 'Rich text in the markdown format. Among others, this allows you to write bold text using **bold**, italics using *italics*, and links using [text](https://example.com).', + 'TEXT', + FALSE, + TRUE +),( + 'code', + 'language', + 'Set the programming language name.', + 'TEXT', + FALSE, + TRUE +); + +-- Insert example(s) for the component +INSERT INTO example(component, description, properties) VALUES ( + 'code', + 'Displays a block of HTML code.', + JSON( + '[ + {"component":"code"}, + { + "title":"A HTML5 example", + "language":"html", + "description":"Here’s the very minimum that an HTML document should contain, assuming it has CSS and JavaScript linked to it.", + "contents":"\n\n\t\n\t\t\n\t\ttitle\n\t\t\n\t\t\n\t\n\t\n\t\t\n\t\n" + } + ]' + ) +); \ No newline at end of file diff --git a/examples/official-site/sqlpage/migrations/37_rss.sql b/examples/official-site/sqlpage/migrations/37_rss.sql new file mode 100644 index 0000000..6ff2979 --- /dev/null +++ b/examples/official-site/sqlpage/migrations/37_rss.sql @@ -0,0 +1,273 @@ +-- Documentation for the RSS component +INSERT INTO component (name, description, icon, introduced_in_version) VALUES ( + 'rss', + 'Produces a data flow in the RSS format. +Can be used to generate a podcast feed. +To use this component, you must first return an HTTP header with the "application/rss+xml" content type (see http_header component). Next, you must use the shell-empty component to avoid that SQLPage generates HTML code.', + 'rss', + '0.20.0' +); + +INSERT INTO parameter (component,name,description,type,top_level,optional) VALUES ( + 'rss', + 'title', + 'Defines the title of the channel.', + 'TEXT', + TRUE, + FALSE +),( + 'rss', + 'link', + 'Defines the hyperlink to the channel.', + 'URL', + TRUE, + FALSE +),( + 'rss', + 'description', + 'Describes the channel.', + 'TEXT', + TRUE, + FALSE +),( + 'rss', + 'language', + 'Defines the language of the channel, specified in the ISO 639 format. For example, "en" for English, "fr" for French.', + 'TEXT', + TRUE, + TRUE +),( + 'rss', + 'category', + 'Defines the category of the channel. The value should be a string representing the category (e.g., "News", "Technology", etc.).', + 'TEXT', + TRUE, + TRUE +),( + 'rss', + 'explicit', + 'Indicates whether the channel contains explicit content. The value can be either TRUE or FALSE.', + 'BOOLEAN', + TRUE, + TRUE +),( + 'rss', + 'image_url', + 'Provides a URL linking to the artwork for the channel.', + 'URL', + TRUE, + TRUE +),( + 'rss', + 'author', + 'Defines the group, person, or people responsible for creating the channel.', + 'TEXT', + TRUE, + TRUE +),( + 'rss', + 'copyright', + 'Provides the copyright details for the channel.', + 'TEXT', + TRUE, + TRUE +),( + 'rss', + 'self_link', + 'URL of the RSS feed.', + 'URL', + TRUE, + TRUE +),( + 'rss', + 'funding_url', + 'Specifies the donation/funding links for the channel. The content of the tag is the recommended string to be used with the link.', + 'URL', + TRUE, + TRUE +),( + 'rss', + 'type', + 'Specifies the channel as either episodic or serial. The value can be either "episodic" or "serial".', + 'TEXT', + TRUE, + TRUE +),( + 'rss', + 'complete', + 'Specifies that a channel is complete and will not post any more items in the future.', + 'BOOLEAN', + TRUE, + TRUE +),( + 'rss', + 'locked', + 'Tells podcast hosting platforms whether they are allowed to import this feed.', + 'BOOLEAN', + TRUE, + TRUE +),( + 'rss', + 'guid', + 'The globally unique identifier (GUID) for a channel. The value is a UUIDv5.', + 'TEXT', + TRUE, + TRUE +),( + 'rss', + 'title', + 'Defines the title of the feed item (episode name, blog post title, etc.).', + 'TEXT', + FALSE, + FALSE +),( + 'rss', + 'link', + 'Defines the hyperlink to the item (blog post URL, etc.).', + 'URL', + FALSE, + FALSE +),( + 'rss', + 'description', + 'Describes the item', + 'TEXT', + FALSE, + FALSE +),( + 'rss', + 'date', + 'Indicates when the item was published (RFC-822 date-time).', + 'TEXT', + FALSE, + TRUE +),( + 'rss', + 'enclosure_url', + 'For podcast episodes, provides a URL linking to the audio/video episode content, in mp3, m4a, m4v, or mp4 format.', + 'URL', + FALSE, + TRUE +),( + 'rss', + 'enclosure_length', + 'The length in bytes of the audio/video episode content.', + 'INTEGER', + FALSE, + TRUE +),( + 'rss', + 'enclosure_type', + 'The MIME media type of the audio/video episode content (e.g., "audio/mpeg", "audio/m4a", "video/m4v", "video/mp4").', + 'TEXT', + FALSE, + TRUE +),( + 'rss', + 'guid', + 'The globally unique identifier (GUID) for an item.', + 'TEXT', + FALSE, + TRUE +),( + 'rss', + 'episode', + 'The chronological number that is associated with an item.', + 'INTEGER', + FALSE, + TRUE +),( + 'rss', + 'season', + 'The chronological number associated with an item''s season.', + 'INTEGER', + FALSE, + TRUE +),( + 'rss', + 'episode_type', + 'Defines the type of content for a specific item. The value can be either "full", "trailer", or "bonus".', + 'TEXT', + FALSE, + TRUE +),( + 'rss', + 'block', + 'Prevents a specific item from appearing in podcast listening applications.', + 'BOOLEAN', + FALSE, + TRUE +),( + 'rss', + 'explicit', + 'Indicates whether the item contains explicit content. The value can be either TRUE or FALSE.', + 'BOOLEAN', + FALSE, + TRUE +),( + 'rss', + 'image_url', + 'Provides a URL linking to the artwork for the item.', + 'URL', + FALSE, + TRUE +),( + 'rss', + 'duration', + 'The duration of an item in seconds.', + 'INTEGER', + FALSE, + TRUE +),( + 'rss', + 'transcript_url', + 'A link to a transcript or closed captions file for the item.', + 'URL', + FALSE, + TRUE +),( + 'rss', + 'transcript_type', + 'The type of the transcript or closed captions file for the item (e.g., "text/plain", "text/html", "text/vtt", "application/json", "application/x-subrip").', + 'TEXT', + FALSE, + TRUE +); + +-- Insert example(s) for the component +INSERT INTO example (component, description) +VALUES ( + 'rss', + ' +### An RSS channel about SQLPage latest news. + +```sql +select ''http_header'' as component, ''application/rss+xml'' as content_type; +select ''shell-empty'' as component; +select + ''rss'' as component, + ''SQLPage blog'' as title, + ''https://sql-page.com/blog.sql'' as link, + ''latest news about SQLpage'' as description, + ''en'' as language, + ''Technology'' as category, + FALSE as explicit, + ''https://sql-page.com/favicon.ico'' as image_url, + ''Ophir Lojkine'' as author, + ''https://github.com/sponsors/lovasoa'' as funding_url, + ''episodic'' as type; +select + ''Hello everyone !'' as title, + ''https://sql-page.com/blog.sql?post=Come%20see%20me%20build%20twitter%20live%20on%20stage%20in%20Prague'' as link, + ''If some of you european SQLPagers are around Prague this december, I will be giving a talk about SQLPage at pgconf.eu on December 14th.'' as description, + ''http://127.0.0.1:8080/sqlpage_introduction_video.webm'' as enclosure_url, + 123456789 as enclosure_length, + ''video/webm'' as enclosure_type, + ''2023-12-04'' as date; +``` + +Once you have your rss feed ready, you can submit it to podcast directories like +[Apple Podcasts](https://podcastsconnect.apple.com/my-podcasts), +[Spotify](https://podcasters.spotify.com/), +[Google Podcasts](https://podcastsmanager.google.com/)... +'); \ No newline at end of file diff --git a/examples/official-site/sqlpage/migrations/38_run_sql.sql b/examples/official-site/sqlpage/migrations/38_run_sql.sql new file mode 100644 index 0000000..27a9898 --- /dev/null +++ b/examples/official-site/sqlpage/migrations/38_run_sql.sql @@ -0,0 +1,93 @@ +INSERT INTO sqlpage_functions ( + "name", + "introduced_in_version", + "icon", + "description_md" + ) +VALUES ( + 'run_sql', + '0.20.0', + 'login', + 'Executes another SQL file and returns its result as a JSON array. + +### Example + +#### Include a common header in all your pages + +It is common to want to run the same SQL queries at the beginning of all your pages, +to check if an user is logged in, render a header, etc. +You can create a file called `common_header.sql`, +and use the [`dynamic`](documentation.sql?component=dynamic#component) component with the `run_sql` function +to include it in all your pages. + +```sql +select ''dynamic'' as component, sqlpage.run_sql(''common_header.sql'') as properties; +``` + +#### Factorize logic between pages + +Reuse a sqlpage query in multiple pages without duplicating code by storing the results of `run_sql` to variables: + +##### `reusable.sql` + +```sql +select some_field from some_table; +``` + +##### `index.sql` + +```sql +-- save the value of some_field from the first result row of reusable.sql into $my_var +set my_var = sqlpage.run_sql(''reusable.sql'')->>0->>''some_field''; +``` + +See [json in SQL](/blog.sql?post=JSON%20in%20SQL%3A%20A%20Comprehensive%20Guide) +for help with manipulating the json array returned by `run_sql`. + +#### Notes + + - **recursion**: you can use `run_sql` to include a file that itself includes another file, and so on. However, be careful to avoid infinite loops. SQLPage will throw an error if the inclusion depth is superior to `max_recursion_depth` (10 by default). + - **security**: be careful when using `run_sql` to include files. + - Never use `run_sql` with a user-provided parameter. + - Never run a file uploaded by a user, or a file that is not under your control. + - Remember that users can also run the files you include with `sqlpage.run_sql(...)` directly just by loading the file in the browser. + - Make sure this does not allow users to bypass security measures you put in place such as [access control](/component.sql?component=authentication). + - If you need to include a file, but make it inaccessible to users, you can use hidden files and folders (starting with a `.`), or put files in the special `sqlpage/` folder that is not accessible to users. + - **variables**: the included file will have access to the same variables (URL parameters, POST variables, etc.) + as the calling file. + If the included file changes the value of a variable or creates a new variable, the change will not be visible in the calling file. + +### Parameters + +You can pass parameters to the included file, as if it had been with a URL parameter. +For instance, you can use: + +```sql +sqlpage.run_sql(''included_file.sql'', json_object(''param1'', ''value1'', ''param2'', ''value2'')) +``` + +Which will make `$param1` and `$param2` available in the included file. +[More information about building JSON objects in SQL](/blog.sql?post=JSON%20in%20SQL%3A%20A%20Comprehensive%20Guide). +' + ); +INSERT INTO sqlpage_function_parameters ( + "function", + "index", + "name", + "description_md", + "type" + ) +VALUES ( + 'run_sql', + 1, + 'file', + 'Path to the SQL file to execute, can be absolute, or relative to the web root (the root folder of your website sql files). + In-database files, from the sqlpage_files(path, contents, last_modified) table are supported.', + 'TEXT' + ),( + 'run_sql', + 2, + 'parameters', + 'Optional JSON object to pass as parameters to the included SQL file. The keys of the object will be available as variables in the included file. By default, the included file will have access to the same variables as the calling file.', + 'JSON' + ); diff --git a/examples/official-site/sqlpage/migrations/39_persist_uploaded_file.sql b/examples/official-site/sqlpage/migrations/39_persist_uploaded_file.sql new file mode 100644 index 0000000..76619a2 --- /dev/null +++ b/examples/official-site/sqlpage/migrations/39_persist_uploaded_file.sql @@ -0,0 +1,77 @@ +INSERT INTO sqlpage_functions ( + "name", + "introduced_in_version", + "icon", + "description_md" + ) +VALUES ( + 'persist_uploaded_file', + '0.20.1', + 'device-floppy', + 'Persists an uploaded file to the local filesystem, and returns its path. +If the file input field is empty, the function returns NULL. + +### Example + +#### User profile picture + +##### `upload_form.sql` + +```sql +select ''form'' as component, ''persist_uploaded_file.sql'' as action; +select ''file'' as type, ''profile_picture'' as name, ''Upload your profile picture'' as label; +``` + +##### `persist_uploaded_file.sql` + +```sql +update user +set profile_picture = sqlpage.persist_uploaded_file(''profile_picture'', ''profile_pictures'', ''jpg,jpeg,png,gif,webp'') +where id = ( + select user_id from session where session_id = sqlpage.cookie(''session_id'') +); +``` + +' + ); +INSERT INTO sqlpage_function_parameters ( + "function", + "index", + "name", + "description_md", + "type" + ) +VALUES ( + 'persist_uploaded_file', + 1, + 'file', + 'Name of the form field containing the uploaded file. The current page must be referenced in the `action` property of a `form` component that contains a file input field.', + 'TEXT' + ), + ( + 'persist_uploaded_file', + 2, + 'destination_folder', + 'Optional. Path to the folder where the file will be saved, relative to the web root (the root folder of your website files). By default, the file will be saved in the `uploads` folder. + +**Security note**: this value must be a folder name you choose yourself in your SQL code (a trusted constant). Never build it from untrusted input such as a form field, a query parameter, a request header, or anything else the visitor controls. The folder is joined directly to the web root, so a value containing `..` or an absolute path would cause the uploaded file to be written *outside* the web root, anywhere the SQLPage process can write. Keeping `destination_folder` a fixed value chosen by the application author avoids this.', + 'TEXT' + ), + ( + 'persist_uploaded_file', + 3, + 'allowed_extensions', + 'Optional. Comma-separated list of allowed file extensions. By default: jpg,jpeg,png,gif,bmp,webp,pdf,txt,doc,docx,xls,xlsx,csv,mp3,mp4,wav,avi,mov. +Changing this may be dangerous ! If you add "sql", "svg" or "html" to the list, an attacker could execute arbitrary SQL queries on your database, or impersonate other users.', + 'TEXT' + ), + ( + 'persist_uploaded_file', + 4, + 'mode', + 'Optional. Unix permissions to set on the file, in octal notation. By default, the file will be saved with "600" (read/write for the owner only). +Octal notation works by using three digits from 0 to 7: the first for the owner, the second for the group, and the third for others. +For example, "644" means read/write for the owner, and read-only for others. +[Learn more about numeric notation for file-system permissions on Wikipedia](https://en.wikipedia.org/wiki/File-system_permissions#Numeric_notation).', + 'TEXT' + ); diff --git a/examples/official-site/sqlpage/migrations/40_fetch.sql b/examples/official-site/sqlpage/migrations/40_fetch.sql new file mode 100644 index 0000000..f93d6f5 --- /dev/null +++ b/examples/official-site/sqlpage/migrations/40_fetch.sql @@ -0,0 +1,128 @@ +INSERT INTO sqlpage_functions ( + "name", + "introduced_in_version", + "icon", + "description_md" + ) +VALUES ( + 'fetch', + '0.20.3', + 'transfer-vertical', + 'Sends an HTTP request and returns the results as a string. + +### Example + +#### Simple GET query + +In this example, we use an API call to find the latitude and longitude of a place +the user searched for, and we display it on a map. + +We use the simplest form of the fetch function, that takes the URL to fetch as a string. + + +```sql +set url = ''https://nominatim.openstreetmap.org/search?format=json&q='' || sqlpage.url_encode($user_search) +set api_results = sqlpage.fetch($url); + +select ''map'' as component; +select $user_search as title, + CAST($api_results->>0->>''lat'' AS FLOAT) as latitude, + CAST($api_results->>0->>''lon'' AS FLOAT) as longitude; +``` + +#### POST query with a body + +In this example, we use the complex form of the function to make an +authenticated POST request, with custom request headers and a custom request body. + +We use SQLite''s json functions to build the request body. +See [the list of SQL databases and their JSON functions](/blog.sql?post=JSON%20in%20SQL%3A%20A%20Comprehensive%20Guide) for +more information on how to build JSON objects in your database. + +```sql +set request = json_object( + ''method'', ''POST'', + ''url'', ''https://postman-echo.com/post'', + ''headers'', json_object( + ''Content-Type'', ''application/json'', + ''Authorization'', ''Bearer '' || sqlpage.environment_variable(''MY_API_TOKEN'') + ), + ''body'', json_object( + ''Hello'', ''world'' + ) +); +set api_results = sqlpage.fetch($request); + +select ''code'' as component; +select + ''API call results'' as title, + ''json'' as language, + $api_results as contents; +``` + + +#### Authenticated request using Basic Auth + +Here''s how to make a request to an API that requires [HTTP Basic Authentication](https://en.wikipedia.org/wiki/Basic_access_authentication): + +```sql +set request = json_object( + ''url'', ''https://api.example.com/data'', + ''username'', ''my_username'', + ''password'', ''my_password'' +); +set api_results = sqlpage.fetch($request); +``` + +> This will add the `Authorization: Basic bXlfdXNlcm5hbWU6bXlfcGFzc3dvcmQK` header to the request, +> where `bXlfdXNlcm5hbWU6bXlfcGFzc3dvcmQK` is the base64 encoding of the string `my_username:my_password`. + +# JSON parameter format + +The fetch function accepts either a URL string, or a JSON object with the following parameters: + - `url`: The URL to fetch. Required. + - `method`: The HTTP method to use. Defaults to `GET`. + - `headers`: A JSON object with the headers to send. Defaults to sending a User-Agent header containing the SQLPage version. + - `body`: The body of the request. If it is a JSON object, it will be sent as JSON. If it is a string, it will be sent as is. When omitted, no request body is sent. + - `timeout_ms`: The maximum time to wait for the request, in milliseconds. Defaults to 5000. + - `username`: Optional username for HTTP Basic Authentication. Introduced in version 0.33.0. + - `password`: Optional password for HTTP Basic Authentication. Only used if username is provided. Introduced in version 0.33.0. + - `response_encoding`: Optional charset to use for decoding the response body. Defaults to `utf8`, or `base64` if the response contains binary data. All [standard web encodings](https://encoding.spec.whatwg.org/#concept-encoding-get) are supported, plus `hex`, `base64`, and `base64url`. Introduced in version 0.37.0. + +# Error handling and reading response headers + +If the request fails, this function throws an error, that will be displayed to the user. +The response headers are not available for inspection. + +## Conditional data fetching + +Since v0.40, `sqlpage.fetch(null)` returns null instead of throwing an error. +This makes it easier to conditionnally query an API: + +```sql +set current_field_value = (select field from my_table where id = 1); +set target_url = nullif(''http://example.com/api/field/1'', null); -- null if the field is currently null in the db +set api_value = sqlpage.fetch($target_url); -- no http request made if the field is not null in the db +update my_table set field = $api_value where id = 1 and $api_value is not null; -- update the field only if it was not present before +``` + +## Advanced usage + +If you need to handle errors or inspect the response headers or the status code, +use [`sqlpage.fetch_with_meta`](?function=fetch_with_meta). +' + ); +INSERT INTO sqlpage_function_parameters ( + "function", + "index", + "name", + "description_md", + "type" + ) +VALUES ( + 'fetch', + 1, + 'url', + 'Either a string containing an URL to request, or a json object in the standard format of the request interface of the web fetch API.', + 'TEXT' + ); diff --git a/examples/official-site/sqlpage/migrations/41_blog_performance.sql b/examples/official-site/sqlpage/migrations/41_blog_performance.sql new file mode 100644 index 0000000..684640d --- /dev/null +++ b/examples/official-site/sqlpage/migrations/41_blog_performance.sql @@ -0,0 +1,37 @@ + +INSERT INTO blog_posts (title, description, icon, created_at, content) +VALUES + ( + 'SQLPage website update', + 'Performance, security, and new features', + 'browser', + '2024-05-01', + ' +Today is may day, and we are happy to announce that the SQLPage website has been updated with new contents. + +## Homepage + +The [homepage](/) has been updated to include prominent information about commonly asked questions, +such as the [security guarantees](/safety.sql) of SQLPage, and the [performance](/performance.sql) of SQLPage applications. + +## Performance of SQLPage applications + +We now have a [detailled explanation of the performance of SQLPage applications](/performance.sql) on the website. +It explains why and how SQLPage applications are often faster than equivalent applications written in other frameworks. + +## Single-Sign-On + +Since SQLPage v0.20.3, SQLPage can natively make requests to external HTTP APIs with [the `fetch` function](/documentation.sql#fetch), +which opens the door to many new possibilities. + +An example of this is the [**SSO demo**](https://github.com/sqlpage/SQLPage/tree/main/examples/single%20sign%20on), +which demonstrates how to use SQLPage to authenticate users on a website using a third-party authentication service, +such as Google, Facebook, an enterprise identity provider using [OIDC](https://openid.net/connect/), +or an academic institution using [CAS](https://apereo.github.io/cas/). + +## New architecture diagram + +The README of the SQLPage repository now includes a +[clear yet detailed architecture diagram](https://github.com/sqlpage/SQLPage?tab=readme-ov-file#how-it-works). +' + ); \ No newline at end of file diff --git a/examples/official-site/sqlpage/migrations/42_blog_video.sql b/examples/official-site/sqlpage/migrations/42_blog_video.sql new file mode 100644 index 0000000..4cd0a09 --- /dev/null +++ b/examples/official-site/sqlpage/migrations/42_blog_video.sql @@ -0,0 +1,30 @@ + +INSERT INTO blog_posts (title, description, icon, created_at, content) +VALUES + ( + 'Introduction video', + 'A 30-minute live presentation of SQLPage, its raison d''être, and how to use it.', + 'brand-youtube', + '2024-05-14', + ' +# Introduction video + +## Canadians love SQLPage + +The Kitchener-Waterloo Linux User Group had the pleasure of [hosting a presentation](https://kwlug.org/node/1374) +by Anton Avramov, an avid SQLPage user and community member, who gave a live demonstration of SQLPage. + +## The video + +The user group kindly invited me (Ophir, the initial creator and main contributor to SQLPage) +to record a video introduction to SQLPage, which I did. + +The video is a 5 minute introduction to the philosophy behind SQLPage, +followed by a 25 minute live demonstration of how to create +[this todo list application](https://github.com/sqlpage/SQLPage/tree/main/examples/todo%20application) +from scratch. + +Watch it on youtube: + +[![video cover](https://i.ytimg.com/vi/9NJgH_-zXjY/maxresdefault.jpg)](https://www.youtube.com/watch?v=9NJgH_-zXjY) +'); \ No newline at end of file diff --git a/examples/official-site/sqlpage/migrations/43_request_method.sql b/examples/official-site/sqlpage/migrations/43_request_method.sql new file mode 100644 index 0000000..ec63238 --- /dev/null +++ b/examples/official-site/sqlpage/migrations/43_request_method.sql @@ -0,0 +1,34 @@ +INSERT INTO + sqlpage_functions ( + "name", + "introduced_in_version", + "icon", + "description_md" + ) +VALUES + ( + 'request_method', + '0.21.0', + 'http-get', + 'Returns the HTTP request method (GET, POST, etc.) used to access the page. + +# HTTP request methods + +HTTP request methods (also known as verbs) are used to indicate the desired action to be performed on the identified resource. The most common methods are: + - **GET**: retrieve information from the server. This is the default method used by browsers when you click on a link. + - **POST**: submit data to be processed by the server. This is the default method used by browsers when you submit a form. + - **PUT**: replace the current representation of the target resource with the request payload. Most commonly used in REST APIs. + - **DELETE**: remove the target resource. + - **PATCH**, **HEAD**, **OPTIONS**, **CONNECT**, **TRACE**: less common methods that are used in specific situations. + +# Example + +```sql +select ''redirect'' as component, + ''/error?msg=expected+a+PUT+request'' as link, +where sqlpage.request_method() != ''PUT''; + +insert into my_table (column1, column2) values (:value1, :value2); +``` +' + ); \ No newline at end of file diff --git a/examples/official-site/sqlpage/migrations/44_authentication_example.sql b/examples/official-site/sqlpage/migrations/44_authentication_example.sql new file mode 100644 index 0000000..c2c4cc4 --- /dev/null +++ b/examples/official-site/sqlpage/migrations/44_authentication_example.sql @@ -0,0 +1,18 @@ +create table users ( + username text primary key, + password_hash text not null, + role text not null +); + +-- Create example users with trivial passwords for the website's demo +insert into users (username, password_hash, role) +values + ('admin', '$argon2i$v=19$m=8,t=1,p=1$YWFhYWFhYWE$ROyXNhK0utkzTA', 'admin'), -- password: admin + ('user', '$argon2i$v=19$m=8,t=1,p=1$YWFhYWFhYWE$qsrWdjgl96ooYw', 'user'); -- password: user +-- (the password hashes can be generated using the `sqlpage.hash_password` function) + +create table user_sessions ( + session_token text primary key, + username text not null references users(username), + created_at timestamp not null default current_timestamp +); \ No newline at end of file diff --git a/examples/official-site/sqlpage/migrations/45_blog_archeology.sql b/examples/official-site/sqlpage/migrations/45_blog_archeology.sql new file mode 100644 index 0000000..1421105 --- /dev/null +++ b/examples/official-site/sqlpage/migrations/45_blog_archeology.sql @@ -0,0 +1,186 @@ + +INSERT INTO blog_posts (title, description, icon, created_at, content) +VALUES + ( + 'How archaeology is gradually entering the era of free software', + 'A team of french archaeologists is working on the first all-digital excavation site, using SQLPage', + 'skull', + '2024-07-02', + ' +> This is the english translation of an article [originally published in French on linuxfr.org](https://linuxfr.org/news/comment-l-archeologie-entre-progressivement-dans-l-ere-du-logiciel-libre). +> It illustrates how SQLPage is used by non-developers + +# How archaeology is gradually entering the era of free software + +Archaeology has, since its beginnings, focused on cataloging, structuring and archiving data from excavations. In the field, it has long relied on creating forms, manually collecting information on paper, and hand drawing, transcribed during study phases onto digital media. It is only recently that some archaeologists have launched the movement of "all-digital" excavation. I propose to tell here the story of the digitization of archaeology, which, as you will see, relies in part on free software. + + +# What is an excavation site? + +French archaeology is divided into two main branches: preventive archaeology, which intervenes during construction projects, and programmed archaeology, conducted on sites chosen to address research issues. Supervised by the Regional Archaeological Services of the Ministry of Culture, these activities are carried out by different organizations: public and private operators for preventive archaeology, and associations, CNRS or universities for programmed archaeology. The latter often mobilizes volunteers, especially students, offering them complementary practical training. + +For the archaeologist, excavation is a tool, not an end in itself. What the archaeologist seeks is information. In essence, it''s about understanding the history of a site, its evolution, its inhabitants through the elements they left behind, whether it''s the ruins of their habitats, their crafts or their burials. This is all the more important as excavation is a destructive act, since the archaeologist dismantles his subject of study as the excavation progresses. + +To be exploited, archaeological information must be organized according to well-established principles. The first key concept is the sedimentary layer (*Stratigraphic Unit* - SU), which testifies to a human action or a natural phenomenon. The study of the arrangement of these layers reveals the chronology of the site, the succession of events that took place there. These layers can be grouped into archaeological *facts*: ditches, cellars, burials, are indeed groupings of layers that define a specific element. Finally, the objects found in these layers, or *artifacts*, are cataloged and identified by their layer of origin, providing crucial chronological and cultural indications. + +![mastraits site](https://github.com/sqlpage/SQLPage/assets/552629/3dbdf81e-b9d3-4268-a8e3-99e568feb695) + +*The excavation site of the Necropolis of Mastraits, in Noisy-le-Grand (93).* + +The actions carried out by the archaeologist throughout the site are also recorded. Indeed, the archaeologist carries out surveys, digs trenches, but also takes many photos, or drawings of everything he discovers as the site progresses. The documentation produced can be plethoric, and cataloging is essential. + +This descriptive information is complemented by **spatial information**, the plan of the uncovered remains being essential for the analysis and presentation of results. The study of this plan, associated with descriptive and chronological information, highlights the major evolutions of the site or specific details. Its realization is generally entrusted to a topographer in collaboration with archaeologists. + +At the end of the field phase, a phase of analysis of the collected data is carried out. This so-called post-excavation phase allows for processing all the information collected, carrying out a complete description, conducting the studies necessary for understanding the site by calling on numerous specialists: ceramologists, anthropologists, archaeozoologists, lithicists, carpologists, anthracologists, paleometallurgy specialists, etc. + +This post-excavation phase initially results in the production of an operation report, the most exhaustive account possible of the site and its evolution. These reports are submitted to the Ministry of Culture, which judges their quality. They are not intended to be widely disseminated, but are normally accessible to anyone who requests them from the concerned administration. They are an important working basis for the entire scientific community. + +Based on this report, the publication of articles in specialized journals allows for presenting the results of the operation more widely, sometimes according to specific themes or issues. + +# Practice of archaeology: example in preventive archaeology + +The use of numerous paper listings is a constant. These listings allow keeping up-to-date records of data in the form of inventory tables of layers, facts, surveys, photos, etc. Specific recording sheets are also used in many specialties of archaeology, such as funerary anthropology. + +In the field, the unearthed elements are still, for a very large majority, drawn by hand, on tracing or graph paper, whether it''s a plan of remains or the numerous stratigraphic section drawings. This of course requires significant time, especially in the case of complex remains. +The use of electronic tacheometers, then differential GPS, has made it possible to do without tape measures, or grid systems, when excavating sites. Topographers, specifically trained, then began to intervene on site for the realization of general plans. + +The documentary collection obtained at the end of an excavation is particularly precious. These are the only elements that will allow reconstructing the history of the site, by crossing these data with the result of the studies carried out. The fear of the disappearance of this data, or its use by others due to a remarkable discovery, is a feeling often shared within the archaeological community. The archaeologist may feel like a custodian of this information, even expressing a feeling of possession that goes completely against the idea of shared and open science. The idea that opening up data is the best way to protect it is far from obvious. + +![conservation sheet, illustrating manual coloring of found skeleton parts](https://github.com/sqlpage/SQLPage/assets/552629/ca9c0f99-a520-4f2b-9826-ae49a89f844b) +> *Conservation sheet, illustrating manual coloring of found skeleton parts* + +![Example of a descriptive sheet of an archaeological layer](https://gitlab.com/projet-r-d-bddsarcheo/tutos/-/raw/main/illustrations_diverses/fiche_us.svg) +> *Example, among many others, of a blank descriptive sheet of an archaeological layer* + +# The beginning of digitization + +It is essentially after the field phase that digital tools have been tamed by archaeologists. + +In post-excavation, paper documentation is still often a fundamental documentary basis for site analysis. The irruption of computing in the mid-80s led archaeologists to transcribe this data into digital form, to facilitate its analysis and presentation. Although the software has evolved, the process is practically the same today, with digitization of documentation in many formats. + +Listings can be integrated into databases (most often proprietary such as MS Access, FileMaker or 4D) or spreadsheets. Many databases have been developed internally, locally, by archaeologists themselves. Only attributive, they have gradually networked and adapted to the medium, allowing consideration of use in the field, without this being widely deployed. + +![Database](https://gitlab.com/projet-r-d-bddsarcheo/tutos/-/raw/main/illustrations_diverses/exemple_bdd_fmp.png) +> *Example of a database at the turn of the 2000s* + +All documentation drawn in the field is to be redrawn cleanly on digital media, in vector drawing software, very often Adobe Illustrator, sometimes Inkscape. +Plan data, surveyed by the topographer, is carried out under Autocad and was exported in .dxf or .dwg before being cleaned up under Adobe Illustrator, which is also the case for drawings made in the field. +The artifacts are entrusted to specialists who describe them, draw them, make an inventory, most often in spreadsheets. Their drawings are again scanned and cleaned up digitally. + +In hindsight, we find that digital tools are mainly used as tools for cleaning up information collected in the field. Many spreadsheets are thus the strict transcription of paper tables used by archaeologists, to which some totals, averages or medians will be added. Drawings made on paper are traced in vectorization software for better readability and the scientific added values are ultimately quite limited. + +This results in relatively disparate digital documentation, with the use of many proprietary tools, closed formats, and a very strong separation between spatial information and descriptive (or attributive) information. + +The progressive use of databases has, however, allowed for agglomerating certain data and gathering and relating information. University work has also helped to feed reflection on the structuring of archaeological data and to train many archaeologists, allowing for the adoption of more virtuous practices. + +# The all-digital movement + +Until now, going fully digital in the archaeological context seemed relatively utopian. It took new technologies to appear, portable and simple-to-use supports to be put in place, networks to develop, and archaeologists to seize these new tools. + +The Ramen collective (Archaeological Research in Digital Recording Modeling) was born from the exchanges and experiences of various archaeologists from the National Institute of Preventive Archaeological Research (Inrap) who grouped around the realization of [the programmed excavation of the medieval necropolis of Noisy-Le-Grand](https://archeonec.hypotheses.org/), excavation managed by the Necropolis Archaeology Association and entrusted to the scientific direction of Cyrille Le Forestier (Inrap). This programmed excavation allowed launching an experiment on the complete dematerialization of archaeological data based on photogrammetry, GIS, and a spatial database. + +## General principle + +While the topographer still intervenes for taking reference points, the detailed survey of remains is ensured, for this experiment, by the systematic implementation of photogrammetry. This method allows, by taking multiple photos of an object or scene, to create an accurate 3D model, and therefore exploitable a posteriori by the archaeologist in post-excavation. Photogrammetry constitutes in Noisy the only survey tool, purely and simply replacing drawing on paper. Indeed, from this 3D point cloud, it is possible to extract multiple 2D supports and add geometry or additional information to the database: burial contours, representation of the skeleton in situ, profiles, measurements, altitudes, etc. + +![Photogrammetric survey of a burial](https://gitlab.com/projet-r-d-bddsarcheo/tutos/-/raw/main/illustrations_diverses/photogrammetrie3.png) +[*Photogrammetric survey of a burial*](https://sketchfab.com/3d-models/973-5d7513dd1dc941228d4a4b7b984c7af7) + +Data recording is ensured by the use of a relational and spatial database whose interface is accessible in QGIS, but also via a web interface directly in the field, without going through paper inventories or listings. The web interface was created using [SQLPage](https://sql-page.com/), a web server that uses an SQL-based language for creating the graphical interface, without having to go through more complex programming languages classically used for creating web applications, such as PHP. + +Of course, this approach also continues in the laboratory during the site analysis stage. + +## Free software and formats + +But abandoning paper support requires us to question the durability of the files and the data they contain. + +Indeed, in a complete dematerialization process, the memory of the site is no longer contained on hundreds of handwritten sheets, but in digital files of which we do not know a priori if we will be able to preserve them in the long term. The impossibility of accessing this data with other software than those originally used during their creation is equivalent to their destruction. Only standard formats can address this issue, and they are particularly used by free software. For photogrammetry, the [`.ply`](https://en.wikipedia.org/wiki/PLY_(file_format)) and [`.obj`](https://en.wikipedia.org/wiki/Wavefront_.obj_file) formats, which are implemented in many software, free and proprietary, were chosen. For attributive and spatial data, it is recorded in free relational databases (Spatialite and Postgis), and easily exportable in `.sql`, which is a standardized format recognized by many databases. + +Unfortunately, free software remains little used in our archaeological daily life, and proprietary software is often very well established. Free software still suffers today from preconceptions and a bad image within the archaeological community, which finds it more complicated, less pretty, less effective, etc. + +However, free software has made a major incursion with the arrival of the free Geographic Information System (GIS) [QGIS](https://www.qgis.org/en/site/), which allowed installing a GIS on all the agents'' workstations of the institute and considering it as an analysis tool at the scale of an archaeological site. Through support and the implementation of an adequate training plan, many archaeologists have been trained in the use of the software within the Institute. + +QGIS has truly revolutionized our practices by allowing immediate interrogation of attributive data by spatial data (what is this remains I see on the plan?) or, conversely, locating remains by their attributive data (where is burial 525?). However, it is still very common to have on one side the attributive data in spreadsheets or proprietary databases, and spatial data in QGIS, with the interrogation of both relying on joins. + +Of course, QGIS also allows data analysis, the creation of thematic or chronological plans, essential supports for our reflections. We can, from these elements, create the numerous figures of the operation report, without going through vector drawing software, in plan as in section (vertical representation of stratigraphy). It allows normalizing figures through the use of styles, and, through the use of the Atlas tool, creating complete catalogs, provided that the data is rigorously structured. + +![spatial analysis](https://gitlab.com/projet-r-d-bddsarcheo/tutos/-/raw/main/illustrations_diverses/ex_plan_analyse.png?ref_type=heads) +> *Example of spatial analysis in Qgis of ceramic waste distribution on a Gallic site* + +In the context of the experiment on the Mastraits necropolis, while Qgis is indeed one of the pillars of the system, a few proprietary software are still used. + +The processing software used for photogrammetry is proprietary. The ultimate goal is to be able to use free software, MicMac, developed by IGN, being a possible candidate. However, it still lacks a fully intuitive interface for archaeologists to appropriate the tool autonomously. + +Similarly, the exciting latest developments of the Inkscape project should encourage us to turn more towards this software and systematically use .svg. The use of Scribus for DTP should also be seriously considered. + +Free software and its undeniable advantages are thus slowly taking place, mainly via QGIS, in the production chain of our archaeological data. We can only hope that this place will grow. The path still seems long, but the way is free... + +## Badass, spatial and attributive united + +The development of the Archaeological Database of Attributive and Spatial Data (Badass) aimed to integrate, within a single database, the attributive information provided by archaeologists and the spatial information collected by the topographer. It even involves gathering, within dedicated tables, attributive and spatial information, thus ensuring data integrity. +Its principle is based on the functioning of the operational chain in archaeology, namely the identification and recording by the archaeologist of the uncovered remains, followed by the three-dimensional survey carried out by the topographer. The latter has, in the database, specific tables in which he can pour the geometry and minimal attributive data (number, type). Triggers then feed the tables filled by archaeologists with geometry, according to their identifier and type. + +The database is thus the unique repository of attributive and spatial information throughout the operation, from field to post-excavation. + +The format of the database was originally SpatiaLite. But the mass of documentation produced by the Mastraits necropolis led us to port it to PostGIS. Many archaeological operations, however, only require a small SpatiaLite base, which also allows the archaeologist to have control over their data file. Only a few large sites may need a PostgreSQL solution, otherwise used for the ARchaeological VIsualization CATalogue (Caviar) which is intended to host spatial and attributive data produced at the institute. + +Naturally, Badass has been coupled with a QGIS project already offering default styles, but also some queries or views commonly used during an archaeological study. A QGIS extension has been developed by several students to allow automatic generation of the project and database. + +Here''s the translation into idiomatic American English, keeping the original formatting: + +## Entering Badass: The Bad''Mobil + +The question of the system''s portability remained. QGIS is a resource-intensive software with an interface ill-suited for small screens, which are preferred for their portability in the field (phones and tablets). + +Choosing to use a SpatiaLite or PostGIS database allowed us to consider a web interface from the start, which could then be used on any device. Initially, we considered developing in PHP/HTML/CSS with an Apache web server. However, this required having a web server and programming an entire interface. There were also some infrastructure questions to address: where to host it, how to finance it, and who would manage it all? + +It was on LinuxFR that one of the members of the collective discovered [SQLPage](https://sql-page.com/). This open-source software, developed by [lovasoa](https://linuxfr.org/users/lovasoa), provides a very simple web server and allows for the creation of a [CRUD](https://en.wikipedia.org/wiki/CRUD) application with an interface that only requires SQL development. + +SQLPage is based on an executable file which, when launched on a computer, turns it into a web server. A configuration file allows you to define the location of the database to be queried, among other things. For each web page of the interface, you write a `.sql` file to define the data to fetch or modify in the database, and the interface components to display it (tables, forms, graphs...). The interface is accessed through a web browser. If the computer is on a network, its IP address allows remote access, with an address like `http://192.168.1.5:8080`, for example. Using a VPN allows us to use the mobile phone network, eliminating the need for setting up a local network with routers, antennas, etc. + +![principle](https://gitlab.com/projet-r-d-bddsarcheo/tutos/-/raw/main/illustrations_diverses/sqlpage_badass.svg) +*General operating principle* + +Thus, the installation of the entire system is very simple and relies only on a file structure to be deployed on the server: the database, and a directory containing the SQLPage binary and the files making up the web pages. + +By relying on the documentation (and occasionally asking questions to the software''s author), we were able to develop a very comprehensive interface on our own that meets our needs in the field. Named Bad''Mobil, the web interface provides access to all the attribute data recorded by archaeologists and now allows, thanks to the constant development of SQLPage, **to visualize spatial data**. Documentation produced during the excavation can also be consulted if the files (photos, scanned drawings, etc.) are placed in the right location in the file structure. The pages mainly consist of creation or modification forms, as well as tables listing already recorded elements. The visualization of geometry allows for spatial orientation in the field, particularly in complex excavation sites, and interaction with attribute data. + +[![The BadMobil interface, with SQLPage](https://github.com/sqlpage/SQLPage/assets/552629/b421eebd-1d7a-446a-90d4-f360300453d5)](https://gitlab.com/projet-r-d-bddsarcheo/tutos/-/raw/main/illustrations_diverses/interface_badmobil.webp?ref_type=heads) +*The BadMobil interface, with SQLPage* + +# Use Cases and Concrete Benefits + +## First Experience at Les Mastraits + +The excavation of the [Les Mastraits Necropolis](https://www.inrap.fr/la-necropole-alto-medievale-des-mastraits-noisy-le-grand-15374) was the test site for these developments. The significant amount of data collected, as well as its status as a planned excavation, allows for this kind of experimentation with much less impact than in a preventive excavation where deadlines are particularly tight. + +The implementation of the SQLPage interface has allowed for the complete digitization of attribute recording and proves to be very efficient. This is a major change in our practices and will save us an enormous amount of time during data processing. + +This also allows for centralizing information, working with multiple people simultaneously without waiting for traditional recording binders to become available, and guiding archaeologists through the recording process, avoiding omissions and errors. Thanks to a simplified interface, data entry can be done very intuitively without the need for extensive training. + +The homogeneity of the entered data is thus better, and the possibilities for querying are much greater. + +## Future Prospects + +Following the development of Badass and Bad''mobil at the Les Mastraits necropolis, it seemed possible to consider its deployment in the context of preventive archaeology. While the question of the network infrastructure necessary for the operation of this solution may arise (need for stable electricity supply on remote sites in the countryside, availability of tablets, network coverage...), the benefits in terms of data homogeneity and ease of entry are very significant. A few preventive archaeology sites have thus been able to test the system, mostly on small-scale sites, benefiting from the support of collective members. + +Future developments will likely focus on integrating new forms or new monitoring tools. Currently, Badass allows for collecting observations common to all archaeological sites, as well as anthropological observations due to its use within the Les Mastraits necropolis. +We could consider integrating the many specialties of archaeology, but it''s likely that we would end up with a huge machine that could be complex to maintain. We therefore remain cautious on this subject. + +# Conclusion + +Gradually, the use of digital tools has become widespread in archaeological professions. After the word processors and spreadsheets of the 90s (often on Mac), the first vectorized drawings digitized in Adobe Illustrator, and databases in Filemaker, Access, or 4D, digital tools are now able to be used throughout the entire data acquisition chain. + +The contribution of open-source software and formats is major for this new step. + +QGIS has fundamentally revolutionized archaeological practice by offering GIS access to the greatest number, allowing for the connection and manipulation of attribute and spatial data. It has paved the way for new developments and the integration of technologies previously little used by archaeology (notably the use of relational and spatial databases in SQL format). +SQLpage has allowed us to offer archaeologists a complete and simple interface to access a networked database. While its development requires certain knowledge of SQL and website functioning, its deployment and maintenance are quite manageable. +SQLPage addresses a real need in the field. For archaeologists, it simplifies their practice while responding to the growing complexity in the face of the documentary mass to be processed, and the increasing qualitative demands of deliverables. + +The combination of QGIS, spatial and relational databases, and a web interface perfectly adapted to the field now fills the observed lack of an effective and reliable archaeological recording tool at the operation level. As such, Badass associated with Bad''Mobil fully meets the expectations of archaeologists who have experimented with them. + +While open-source software has, in recent years, begun a timid breakthrough among many archaeological operators (some have fully adopted them), reluctance remains, whether from users or sometimes from the IT departments of public administrations, who may prefer to opt for an all-in-one service with technical support. + +But the persistence of proprietary software usage is not without posing real problems regarding the sustainability of archaeological data, and archaeologists are just beginning to discover the issue. Their attachment to their data -- although it sometimes goes against the principle of open science -- should, however, encourage them to opt for formats whose durability appears certain, thereby guaranteeing access to this data in the future, regardless of the software or operating system used, if they don''t want their work to fall into oblivion... + ' + ); \ No newline at end of file diff --git a/examples/official-site/sqlpage/migrations/46_html.sql b/examples/official-site/sqlpage/migrations/46_html.sql new file mode 100644 index 0000000..272882b --- /dev/null +++ b/examples/official-site/sqlpage/migrations/46_html.sql @@ -0,0 +1,97 @@ +-- Documentation for the RSS component +INSERT INTO + component (name, description, icon, introduced_in_version) +VALUES + ( + 'html', + 'Include raw HTML in the output. For advanced users only. Use this component to create complex layouts or to include external content. + Be very careful when using this component with user-generated content, as it can lead to security vulnerabilities. + Use this component only if you are familiar with the security implications of including raw HTML, and understand the risks of cross-site scripting (XSS) attacks.', + 'html', + '0.25.0' + ); + +INSERT INTO + parameter ( + component, + name, + description, + type, + top_level, + optional + ) +VALUES + ( + 'html', + 'html', + 'Raw HTML content to include in the page. This will not be sanitized or escaped. If you include content from an untrusted source, your page will be vulnerable to cross-site scripting attacks.', + 'TEXT', + TRUE, + TRUE + ), + ( + 'html', + 'html', + 'Raw HTML content to include in the page. This will not be sanitized or escaped. If you include content from an untrusted source, your page will be vulnerable to cross-site scripting attacks.', + 'TEXT', + FALSE, + TRUE + ), + ( + 'html', + 'text', + 'Text content to include in the page. This will be sanitized and escaped. Use this property to include user-generated content that should not contain HTML tags.', + 'TEXT', + FALSE, + TRUE + ), + ( + 'html', + 'post_html', + 'Raw HTML content to include after the text content. This will not be sanitized or escaped. If you include content from an untrusted source, your page will be vulnerable to cross-site scripting attacks.', + 'TEXT', + FALSE, + TRUE + ); + +-- Insert example(s) for the component +INSERT INTO + example (component, description, properties) +VALUES + ( + 'html', + 'Include a simple HTML snippet. In this example, the HTML code is hardcoded in the SQL query, so it is safe. You should never include data that may be manipulated by a user in the HTML content. + ', + JSON ( + '[{ + "component": "html", + "html": "

This text is safe because it is hardcoded!

" + }]' + ) + ), + ( + 'html', + 'Include multiple html snippets as row-level parameters. Again, be careful what you include in the HTML content. If the data comes from a user, it can be manipulated to include malicious code.', + JSON ( + '[{"component":"html", "html":"
"}, + {"html":"
10%
"}, + {"html":"
80%
"}, + {"html":"
"} + ]' + ) + ), + ( + 'html', + 'In order to include user-generated content that should be sanitized, use the `text` property instead of `html`. The `text` property will display the text as-is, without interpreting any HTML tags.', + JSON ( + ' + [ + {"component": "html"}, + { + "html": "

The following will be sanitized: ", + "text": "", + "post_html": ". Phew! That was close!

" + } + ]' + ) + ); \ No newline at end of file diff --git a/examples/official-site/sqlpage/migrations/47_link.sql b/examples/official-site/sqlpage/migrations/47_link.sql new file mode 100644 index 0000000..267a728 --- /dev/null +++ b/examples/official-site/sqlpage/migrations/47_link.sql @@ -0,0 +1,77 @@ +INSERT INTO + sqlpage_functions ( + "name", + "introduced_in_version", + "icon", + "description_md" + ) +VALUES + ( + 'link', + '0.25.0', + 'link', + 'Returns the URL of a SQLPage file with the given parameters. + +### Example + +Let''s say you have a database of products, and you want the main page (`index.sql`) to link to the page of each product (`product.sql`) with the product name as a parameter. + +In `index.sql`, you can use the `link` function to generate the URL of the product page for each product. + +```sql +select ''list'' as component; +select + name as title, + sqlpage.link(''product'', json_object(''product_name'', name)) as link +from products; +``` + +In `product.sql`, you can then use `$product_name` to get the name of the product from the URL parameter: + +```sql +select ''hero'' as component, $product_name as title, product_info as description +from products +where name = $product_name; +``` + +> You could also have manually constructed the URL with `CONCAT(''product?product_name='', name)`, +> but using `sqlpage.link` is better because it ensures that the URL is properly encoded. +> `sqlpage.link` will work even if the product name contains special characters like `&`, while `CONCAT(...)` would break the URL. + +### Parameters + - `file` (TEXT): The name of the SQLPage file to link to. + - `parameters` (JSON): The parameters to pass to the linked file. + - `fragment` (TEXT): An optional fragment (hash) to append to the URL. This is useful for linking to a specific section of a page. For instance if `product.sql` contains `select ''text'' as component, ''product_description'' as id;`, you can link to the product description section with `sqlpage.link(''product.sql'', json_object(''product_name'', name), ''product_description'')`. +' + ); + +INSERT INTO + sqlpage_function_parameters ( + "function", + "index", + "name", + "description_md", + "type" + ) +VALUES + ( + 'link', + 1, + 'file', + 'The path of the SQLPage file to link to, relative to the current file.', + 'TEXT' + ), + ( + 'link', + 2, + 'parameters', + 'A JSON object with the parameters to pass to the linked file.', + 'JSON' + ), + ( + 'link', + 3, + 'fragment', + 'An optional fragment (hash) to append to the URL to link to a specific section of the target page.', + 'TEXT' + ); diff --git a/examples/official-site/sqlpage/migrations/48_status_code.sql b/examples/official-site/sqlpage/migrations/48_status_code.sql new file mode 100644 index 0000000..f80d426 --- /dev/null +++ b/examples/official-site/sqlpage/migrations/48_status_code.sql @@ -0,0 +1,65 @@ +-- Insert the status_code component into the component table +INSERT INTO + component (name, description, icon) +VALUES + ( + 'status_code', + 'Sets the HTTP response code for the current page. + +This is an advanced technical component. +You typically need it when building internet-facing APIs and websites, +but you may not need it for simple internal applications. + +- Indicating operation results when using [SQLPage as an API](?component=json) + - `200`: *OK*, for successful operations + - `201`: *Created*, for successful record insertion + - `404`: *Not Found*, for missing resources + - `500`: *Internal Server Error*, for failed operations +- Handling data validation errors + - `400`: *Bad Request*, for invalid data +- Enforcing access controls + - `403`: *Forbidden*, for unauthorized access + - `401`: *Unauthorized*, for unauthenticated access +- Tracking system health + - `500`: *Internal Server Error*, for failed operations + +For search engine optimization: +- Use `404` for deleted content to remove outdated URLs from search engines +- For redirection from one page to another, use + - `301` (moved permanently), or + - `302` (moved temporarily) +- Use `503` during maintenance', + 'error-404' + ); + +-- Insert the parameters for the status_code component into the parameter table +INSERT INTO + parameter ( + component, + name, + description, + type, + top_level, + optional + ) +VALUES + ( + 'status_code', + 'status', + 'HTTP status code (e.g., 200 OK, 401 Unauthorized, 409 Conflict)', + 'INTEGER', + TRUE, + FALSE + ); + +INSERT INTO example (component, description) +VALUES ( + 'status_code', + ' +Set the HTTP status code to 404, indicating that the requested resource was not found. +Useful in combination with [`404.sql` files](/your-first-sql-website/custom_urls.sql): + +```sql +SELECT ''status_code'' as component, 404 as status; +``` +'); diff --git a/examples/official-site/sqlpage/migrations/49_big_number.sql b/examples/official-site/sqlpage/migrations/49_big_number.sql new file mode 100644 index 0000000..e18db30 --- /dev/null +++ b/examples/official-site/sqlpage/migrations/49_big_number.sql @@ -0,0 +1,66 @@ +-- Big Number Component Documentation + +-- Component Definition +INSERT INTO component(name, icon, description, introduced_in_version) VALUES + ('big_number', 'chart-area', 'A component to display key metrics or statistics with optional description, change indicator, and progress bar. Useful in dashboards.', '0.28.0'); + +-- Inserting parameter information for the big_number component +INSERT INTO parameter(component, name, description, type, top_level, optional) SELECT 'big_number', * FROM (VALUES + -- Top-level parameters (for the whole big_number list) + ('columns', 'The number of columns to display the big numbers in (default is one column per item).', 'INTEGER', TRUE, TRUE), + ('id', 'An optional ID to be used as an anchor for links.', 'TEXT', TRUE, TRUE), + ('class', 'An optional CSS class to be added to the component for custom styling', 'TEXT', TRUE, TRUE), + -- Item-level parameters (for each big number) + ('title', 'The title or label for the big number.', 'TEXT', FALSE, TRUE), + ('title_link', 'A link for the Big Number title. If set, the entire title becomes clickable.', 'TEXT', FALSE, TRUE), + ('title_link_new_tab', 'If true, the title link will open in a new tab/window.', 'BOOLEAN', FALSE, TRUE), + ('value_link', 'A link for the Big Number value. If set, the entire value becomes clickable.', 'TEXT', FALSE, TRUE), + ('value_link_new_tab', 'If true, the value link will open in a new tab/window.', 'BOOLEAN', FALSE, TRUE), + ('value', 'The main value to be displayed prominently.', 'TEXT', FALSE, FALSE), + ('unit', 'The unit of measurement for the value.', 'TEXT', FALSE, TRUE), + ('description', 'A description or additional context for the big number.', 'TEXT', FALSE, TRUE), + ('change_percent', 'The percentage change in value (e.g., 7 for 7% increase, -8 for 8% decrease).', 'INTEGER', FALSE, TRUE), + ('progress_percent', 'The value of the progress (0-100).', 'INTEGER', FALSE, TRUE), + ('progress_color', 'The color of the progress bar (e.g., "primary", "success", "danger").', 'TEXT', FALSE, TRUE), + ('dropdown_item', 'A list of JSON objects containing links. e.g. {"label":"This week", "link":"?days=7"}', 'JSON', FALSE, TRUE), + ('color', 'The color of the card', 'COLOR', FALSE, TRUE) +) x; + +INSERT INTO example(component, description, properties) VALUES + ('big_number', 'Big numbers with change indicators and progress bars', + json('[ + {"component":"big_number"}, + { + "title":"Sales", + "value":75, + "unit":"%", + "title_link": "#sales_dashboard", + "title_link_new_tab": true, + "value_link": "#sales_details", + "value_link_new_tab": false, + "description":"Conversion rate", + "change_percent": 9, + "progress_percent": 75, + "progress_color": "blue" + }, + { + "title":"Revenue", + "value":"4,300", + "unit":"$", + "description":"Year on year", + "change_percent": -8 + } + ]')); + +INSERT INTO example(component, description, properties) VALUES + ('big_number', 'Big numbers with dropdowns and customized layout', + json('[ + {"component":"big_number", "columns":3, "id":"colorfull_dashboard"}, + {"title":"Users", "value":"1,234", "color": "red", "title_link": "#users", "title_link_new_tab": false, "value_link": "#users_details", "value_link_new_tab": true }, + {"title":"Orders", "value":56, "color": "green", "title_link": "#orders", "title_link_new_tab": true }, + {"title":"Revenue", "value":"9,876", "unit": "€", "color": "blue", "change_percent": -7, "dropdown_item": [ + {"label":"This week", "link":"?days=7&component=big_number#colorfull_dashboard"}, + {"label":"This month", "link":"?days=30&component=big_number#colorfull_dashboard"}, + {"label":"This quarter", "link":"?days=90&component=big_number#colorfull_dashboard"} + ]} + ]')); diff --git a/examples/official-site/sqlpage/migrations/50_blog_json.sql b/examples/official-site/sqlpage/migrations/50_blog_json.sql new file mode 100644 index 0000000..7e6aa46 --- /dev/null +++ b/examples/official-site/sqlpage/migrations/50_blog_json.sql @@ -0,0 +1,585 @@ +INSERT INTO blog_posts (title, description, icon, created_at, content) +VALUES + ( + 'JSON in SQL: A Comprehensive Guide', + 'A comprehensive guide to working with JSON data in SQLite, PostgreSQL, MySQL, and SQL Server.', + 'braces', + '2024-09-03', + ' +# JSON in SQL: A Comprehensive Guide + +## Introduction + +JSON (JavaScript Object Notation) is a popular data format for unstructured data. It allows storing composite data types, such as arrays and objects, in a single SQL value. +Many modern applications use JSON to store and exchange data. As a result, SQL databases have incorporated JSON support to allow developers to work with structured and semi-structured data within the same database. + +This guide will cover JSON operations in SQLite, PostgreSQL, MySQL, and SQL Server, focusing on querying JSON data. + +SQLPage uses JSON both to pass data to the database (when a SQLPage variable contains an array), and to pass data to components (when a component has a JSON parameter). +Thus, understanding how to work with JSON in SQL will allow you to fully leverage advanced SQLPage features. + +JSON supports the following data types: + +- **Objects**: A mapping between keys and values (`{ "key": "value" }`). Keys must be strings, and values can be of different types. +- **Arrays**: An ordered list of values enclosed in square brackets (`[ "value1", "value2" ]`). Values can be of different types. +- **Strings**: A sequence of characters enclosed in double quotes (`"Hello, World!"`). +- **Numbers**: An integer or floating-point number (`42`, `3.14`). +- **Boolean**: A true or false value (`true`, `false`). +- **Null**: A null value (`null`). + +## Sample Table + +We''ll use the following sample table for our examples: + +```sql +CREATE TABLE users ( + id INT PRIMARY KEY, + name VARCHAR(50), + birthday DATE, + group_name VARCHAR(50) +); + +INSERT INTO users (id, name, birthday, group_name) VALUES +(1, ''Alice'', ''1990-01-15'', ''Admin''), +(2, ''Bob'', ''1985-05-22'', ''User''), +(3, ''Charlie'', ''1992-09-30'', ''User''); +``` + +## SQLite + +SQLite provides increasingly better JSON support since version 3.38.0. +See [the list of JSON functions in SQLite](https://www.sqlite.org/json1.html) for more details. + +### Creating a JSON object + +We can use the standard `json_object()` function to create a JSON object from columns in a table: + +```sql +SELECT json_object(''name'', name, ''birthday'', birthday) AS user_json +FROM users; +``` + +| user_json | +|-----------| +| `{"name":"Alice","birthday":"1990-01-15"}` | +| `{"name":"Bob","birthday":"1985-05-22"}` | +| `{"name":"Charlie","birthday":"1992-09-30"}` | + +### Creating a JSON array + +```sql +SELECT json_array(name, birthday, group_name) AS user_array +FROM users; +``` + +| user_array | +|------------| +| `["Alice","1990-01-15","Admin"]` | +| `["Bob","1985-05-22","User"]` | +| `["Charlie","1992-09-30","User"]` | + +### Aggregating multiple values into a JSON array + +```sql +SELECT json_group_array(name) AS names +FROM users; +``` + +| names | +|-------| +| `["Alice","Bob","Charlie"]` | + +### Aggregating values into a JSON object + +```sql +SELECT json_group_object(name, group_name) AS name_group_map +FROM users; +``` + +| name_group_map | +|-------------------| +| `{"Alice":"Admin", "Bob":"User", "Charlie":"User"}` | + + +### Iterating over a JSON array + +SQLite provides the `json_each()` table-valued function to iterate over JSON arrays. This function returns one row for each element in the JSON array. + +```sql +SELECT value FROM json_each(''["Alice", "Bob", "Charlie"]''); +``` + +| value | +|-------| +| Alice | +| Bob | +| Charlie | + +The `json_each()` function returns a table with several columns. The most commonly used are: + +- `key`: The array index (0-based) for elements of a JSON array +- `value`: The value of the current element +- `type`: The type of the current element (e.g., ''text'', ''integer'', ''real'', ''true'', ''false'', ''null'') + +For more complex JSON structures, you can use the `json_tree()` function, which recursively walks through the entire JSON structure. + +These iteration functions can be used to check if specific values exist in a JSON array. +Here''s a practical example: +Let''s say you have a form with a [multiple-choice dropdown](documentation.sql?component=form#component) that allows selecting multiple users. +Some users might already be selected, and their IDs are stored in a JSON array passed as an URL parameter called `$selected_ids`. +You can create this dropdown using the following query: + +```sql +select json_group_array(json_object( + ''label'', name, + ''value'', id, + ''selected'', id in (select value from json_each_text($selected_ids)) +)) as options +from users; +``` + +This query will: +1. Create a dropdown option for each user +2. Use their name as the display label +3. Use their ID as the value +4. Mark the option as selected if the user''s ID exists in the $selected_ids array + +### Combining two JSON objects + +SQLite provides the `json_patch()` function to combine two JSON objects. This function takes two JSON objects as arguments and returns a new JSON object that is the result of merging the two input objects. + +```sql +SELECT json_patch(''{"name": "Alice"}'', ''{"birthday": "1990-01-15"}'') AS user_json; +``` + +| user_json | +|-----------| +| {"name": "Alice", "birthday": "1990-01-15"} | + +## PostgreSQL + +PostgreSQL has extensive support for JSON, including the `jsonb` type, which offers better performance and more functionality than the `json` type. +See [the list of JSON functions in PostgreSQL](https://www.postgresql.org/docs/current/functions-json.html) for more details. + +### Creating a JSON object + +```sql +SELECT + jsonb_build_object( + ''name'', name, + ''birthday'', birthday + ) AS user_json +FROM users; +``` + +| user_json | +|-----------| +| `{"name":"Alice","birthday":"1990-01-15"}` | +| `{"name":"Bob","birthday":"1985-05-22"}` | +| `{"name":"Charlie","birthday":"1992-09-30"}` | + +### Creating a JSON array + +```sql +SELECT + jsonb_build_array( + name, birthday, group_name + ) AS user_array +FROM users; +``` + +| user_array | +|------------| +| `["Alice", "1990-01-15", "Admin"]` | +| `["Bob", "1985-05-22", "User"]` | +| `["Charlie", "1992-09-30", "User"]` | + +### Aggregating multiple values into a JSON array + +```sql +SELECT jsonb_agg(name) AS names FROM users; +``` + +| names | +|-------| +| `["Alice","Bob","Charlie"]` | + +### Aggregating values into a JSON object + +```sql +SELECT + jsonb_object_agg( + name, birthday + ) AS name_birthday_map +FROM users; +``` + +| name_birthday_map | +|-------------------| +| `{"Alice":"1990-01-15","Bob":"1985-05-22","Charlie":"1992-09-30"}` | + + +### Iterating over a JSON array + +```sql +SELECT name FROM jsonb_array_elements_text(''["Alice", "Bob", "Charlie"]''::jsonb) AS name; +``` + +| name | +|------| +| Alice | +| Bob | +| Charlie | + +You can use this function to test whether a value is present in a JSON array. For instance, to create a +[multi-value select dropdown](documentation.sql?component=form#component) with pre-selected values, you can use the following query: + +```sql +SELECT jsonb_agg(jsonb_build_object( + ''label'', name, + ''value'', id, + ''selected'', id in (SELECT value FROM jsonb_array_elements_text($selected_ids::jsonb)) +)) AS options +FROM users; +``` + +### Iterating over a JSON object + +```sql +SELECT key, value +FROM jsonb_each_text(''{"name": "Alice", "birthday": "1990-01-15"}''::jsonb); +``` + +| key | value | +|-----|-------| +| name | Alice | +| birthday | 1990-01-15 | + +### Querying JSON data + +PostgreSQL allows you to query JSON data using the `->` and `->>` operators: + +```sql +SELECT name, user_data->>''age'' AS age +FROM ( + SELECT name, jsonb_build_object(''age'', EXTRACT(YEAR FROM AGE(birthday))) AS user_data + FROM users +) subquery +WHERE (user_data->>''age'')::int > 30; +``` + +| name | age | +|------|-----| +| Bob | 38 | + +### Combining two JSON objects + +PostgreSQL provides the `||` operator to combine two JSON objects. + +```sql +SELECT ''{"name": "Alice"}''::jsonb || ''{"birthday": "1990-01-15"}''::jsonb AS user_json; +``` + +| user_json | +|-----------| +| {"name": "Alice", "birthday": "1990-01-15"} | + +## MySQL / MariaDB + +MySQL has good support for JSON operations starting from version 5.7. +See [the list of JSON functions in MySQL](https://dev.mysql.com/doc/refman/8.0/en/json-functions.html) for more details. + +### Creating a JSON object + +```sql +SELECT JSON_OBJECT(''name'', name, ''birthday'', birthday) AS user_json +FROM users; +``` + +| user_json | +|-----------| +| `{"name":"Alice","birthday":"1990-01-15"}` | +| `{"name":"Bob","birthday":"1985-05-22"}` | +| `{"name":"Charlie","birthday":"1992-09-30"}` | + +### Creating a JSON array + +```sql +SELECT JSON_ARRAY(name, birthday, group_name) AS user_array +FROM users; +``` + +| user_array | +|------------| +| `["Alice","1990-01-15","Admin"]` | +| `["Bob","1985-05-22","User"]` | +| `["Charlie","1992-09-30","User"]` | + +### Aggregating multiple values into a JSON array + +```sql +SELECT JSON_ARRAYAGG(name) AS names +FROM users; +``` + +| names | +|-------| +| `["Alice","Bob","Charlie"]` | + +### Aggregating values into a JSON object + +```sql +SELECT JSON_OBJECTAGG(name, birthday) AS name_birthday_map +FROM users; +``` + +| name_birthday_map | +|-------------------| +| `{"Alice":"1990-01-15","Bob":"1985-05-22","Charlie":"1992-09-30"}` | + +### Iterating over a JSON array + +MySQL provides the JSON_TABLE() function to iterate over JSON arrays. This powerful function allows you to convert JSON data into a relational table format, making it easy to work with JSON arrays. + +Here''s an example of how to use JSON_TABLE() to iterate over a JSON array: + +```sql +SELECT jt.name +FROM JSON_TABLE( + ''["Alice", "Bob", "Charlie"]'', + ''$[*]'' COLUMNS( name VARCHAR(50) PATH ''$'' ) +) AS jt; +``` + +| name | +|---------| +| Alice | +| Bob | +| Charlie | + +In this example: +- The first argument to JSON_TABLE() is the JSON array. +- `''$[*]''` is the path expression that selects all elements of the array. +- The `COLUMNS` clause defines the structure of the output table. In our case, we want a single column named `name`: + - `name VARCHAR(50) PATH ''$''` creates a text column that contains the raw value of each array element in its entirety (`$` is the current element). + +You can also use JSON_TABLE() with more complex JSON structures: + +```sql +SELECT jt.* +FROM JSON_TABLE( + ''[{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}, {"id": 3, "name": "Charlie"}]'', + ''$[*]'' COLUMNS( + id INT PATH ''$.id'', + name VARCHAR(50) PATH ''$.name'' + ) +) AS jt; +``` + +| id | name | +|----|---------| +| 1 | Alice | +| 2 | Bob | +| 3 | Charlie | + +This approach allows you to easily iterate over JSON arrays and access their elements in a tabular format, which can be very useful for further processing or joining with other tables in your database. + +### Iterating over a JSON object + +The `JSON_TABLE` function can also be used to iterate over JSON objects: + +```sql +SELECT jt.* +FROM JSON_TABLE( + ''{"name": "Alice", "birthday": "1990-01-15"}'', + ''$.*'' COLUMNS ( + value JSON PATH ''$'' + ) +) AS jt; +``` + +| value | +|-------| +| "Alice" | +| "1990-01-15" | + +#### Iterating over key-value pairs + +You can use the `JSON_KEYS()` function to retrieve the list of keys in a JSON object as a JSON array, +then use that array to iterate over the keys of a JSON object: + +```sql +SELECT json_key, json_extract(json_str, CONCAT(''$.'', json_key)) as json_value +FROM + (select ''{"name": "Alice", "birthday": "1990-01-15"}'' as json_str) AS my_json, + JSON_TABLE(json_keys(json_str), ''$[*]'' COLUMNS (json_key JSON PATH ''$'')) AS json_keys; +``` + +| json_key | json_value | +|----------|------------| +| name | Alice | +| birthday | 1990-01-15 | + +### Querying JSON data + +MySQL allows you to query JSON data using the `->` and `->>` operators: + +```sql +SELECT name, user_data->''$.age'' AS age +FROM ( + SELECT name, JSON_OBJECT(''age'', YEAR(CURDATE()) - YEAR(birthday)) AS user_data + FROM users +) subquery +WHERE user_data->''$.age'' > 30; +``` + +| name | age | +|------|-----| +| Bob | 38 | + +## Microsoft SQL Server + +SQL Server has support for JSON operations starting from SQL Server 2016. +See [the list of JSON functions in SQL Server](https://learn.microsoft.com/en-us/sql/t-sql/functions/json-functions-transact-sql?view=sql-server-ver16) for more details. + +# JSON in SQL: A Comprehensive Guide + +[Previous sections remain unchanged] + +## Microsoft SQL Server + +SQL Server has support for JSON operations starting from SQL Server 2016. It provides a comprehensive set of functions for working with JSON data. +See [the list of JSON functions in SQL Server](https://learn.microsoft.com/en-us/sql/t-sql/functions/json-functions-transact-sql?view=sql-server-ver16) for more details. + +### Creating a JSON object + +Use the `FOR JSON PATH` clause to create a JSON object: + +```sql +SELECT (SELECT name, birthday FOR JSON PATH, WITHOUT_ARRAY_WRAPPER) AS user_json +FROM users; +``` + +| user_json | +|-----------| +| `{"name":"Alice","birthday":"1990-01-15"}` | +| `{"name":"Bob","birthday":"1985-05-22"}` | +| `{"name":"Charlie","birthday":"1992-09-30"}` | + +Alternatively, you can use the `JSON_OBJECT` function: + +```sql +SELECT JSON_OBJECT(''name'': name, ''birthday'': birthday) AS user_json +FROM users; +``` + +### Creating a JSON array + +Use the `FOR JSON PATH` clause to create a JSON array: + +```sql +SELECT (SELECT name, birthday, group_name FOR JSON PATH) AS user_array +FROM users; +``` + +| user_array | +|------------| +| `[{"name":"Alice","birthday":"1990-01-15","group_name":"Admin"}]` | +| `[{"name":"Bob","birthday":"1985-05-22","group_name":"User"}]` | +| `[{"name":"Charlie","birthday":"1992-09-30","group_name":"User"}]` | + +You can also use the `JSON_ARRAY` function: + +```sql +SELECT JSON_ARRAY(name, birthday, group_name) AS user_array +FROM users; +``` + +### Aggregating multiple values into a JSON array + +Use the `FOR JSON PATH` clause to aggregate values into a JSON array: + +```sql +SELECT (SELECT name FROM users FOR JSON PATH) AS names; +``` + +| names | +|-------| +| `[{"name":"Alice"},{"name":"Bob"},{"name":"Charlie"}]` | + +Alternatively, use the `JSON_ARRAYAGG` function: + +```sql +SELECT JSON_ARRAYAGG(name) AS names FROM users; +``` + +### Aggregating values into a JSON object + +```sql +SELECT JSON_OBJECTAGG(name: birthday) AS name_birthday_map FROM users; +``` + +### Iterating over a JSON array + +Use the `OPENJSON` function to iterate over JSON arrays: + +```sql +SELECT value FROM OPENJSON(''["Alice", "Bob", "Charlie"]''); +``` + +| value | +|-------| +| Alice | +| Bob | +| Charlie | + +### Iterating over a JSON object + +Use `OPENJSON` to iterate over JSON objects: + +```sql +SELECT * +FROM OPENJSON(''{"name": "Alice", "birthday": "1990-01-15"}'') +WITH ( + name NVARCHAR(50) ''$.name'', + birthday DATE ''$.birthday'' +); +``` + +| name | birthday | +|------|----------| +| Alice | 1990-01-15 | + +### Querying JSON data + +Use the `JSON_VALUE` function to extract scalar values from JSON: + +```sql +SELECT JSON_VALUE(''{"age": 38}'', ''$.age'') AS age +``` + +| age | +|-----| +| 38 | + +### Additional JSON Functions + +SQL Server provides several other useful JSON functions: + +- `ISJSON`: Tests whether a string contains valid JSON. +- `JSON_MODIFY`: Updates the value of a property in a JSON string. +- `JSON_PATH_EXISTS`: Tests whether a specified SQL/JSON path exists in the input JSON string. +- `JSON_QUERY`: Extracts an object or an array from a JSON string. + +Example using `JSON_MODIFY`: + +```sql +SELECT JSON_MODIFY(''{"name": "Alice", "age": 30}'', ''$.age'', 31) AS updated_json; +``` + +| updated_json | +|--------------| +| {"name": "Alice", "age": 31} | + +This comprehensive guide covers the basics of working with JSON in SQLite, PostgreSQL, MySQL, and SQL Server. Each database has its own set of functions and syntax for JSON operations, but the general concepts remain similar across all platforms. +'); \ No newline at end of file diff --git a/examples/official-site/sqlpage/migrations/51_column.sql b/examples/official-site/sqlpage/migrations/51_column.sql new file mode 100644 index 0000000..06674e9 --- /dev/null +++ b/examples/official-site/sqlpage/migrations/51_column.sql @@ -0,0 +1,118 @@ +-- Column Component Documentation + +-- Component Definition +INSERT INTO component(name, icon, description, introduced_in_version) VALUES + ('columns', 'columns', 'A component to display various items in a card layout, allowing users to choose options. Useful for showcasing different features or services, or KPIs. See also the big_number component.', '0.29.0'); + +-- Inserting parameter information for the column component +INSERT INTO parameter(component, name, description, type, top_level, optional) SELECT 'columns', * FROM (VALUES + ('title', 'The title or label for the item.', 'TEXT', FALSE, TRUE), + ('value', 'The value associated with the item.', 'TEXT', FALSE, TRUE), + ('description', 'A brief description of the item.', 'TEXT', FALSE, TRUE), + ('description_md', 'A brief description of the item, formatted using markdown.', 'TEXT', FALSE, TRUE), + ('item', 'A list of bullet points associated with the columns, represented either as text, or as a json object with "icon", "color", and "description" or "description_md" fields.', 'JSON', FALSE, TRUE), + ('link', 'A link associated with the item.', 'TEXT', FALSE, TRUE), + ('button_text', 'Text for the button.', 'TEXT', FALSE, TRUE), + ('button_color', 'Optional color for the button.', 'TEXT', FALSE, TRUE), + ('target', 'Optional target for the button. Set to "_blank" to open links in a new tab.', 'TEXT', FALSE, TRUE), + ('value_color', 'Color for the value text.', 'TEXT', FALSE, TRUE), + ('small_text', 'Optional small text to display after the value.', 'TEXT', FALSE, TRUE), + ('icon', 'Optional icon to display in a ribbon.', 'ICON', FALSE, TRUE), + ('icon_color', 'Color for the icon in the ribbon.', 'TEXT', FALSE, TRUE), + ('size', 'Size of the column, affecting layout.', 'INTEGER', FALSE, TRUE) +) x; + +INSERT INTO example(component, description, properties) VALUES + ('columns', 'Pricing Plans Display', + json('[ + {"component":"columns"}, + { + "title":"Start Plan", + "value":"€18", + "description":"Perfect for testing and small-scale projects", + "item": [ + "128MB Database", + "SQLPage hosting", + "Community support" + ], + "link":"https://datapage.app", + "button_text":"Start Free Trial", + "small_text":"/month" + }, + { + "title":"Pro Plan", + "value":"€40", + "icon":"rocket", + "description":"For growing projects needing enhanced features", + "item": [ + {"icon":"database", "color": "blue", "description":"1GB Database"}, + {"icon":"headset", "color": "green", "description":"Priority Support"}, + {"icon":"world", "color": "purple", "description":"Custom Domain"} + ], + "link":"https://datapage.app", + "button_text":"Start Free Trial", + "button_color":"indigo", + "value_color":"indigo", + "small_text":"/month" + }, + { + "title":"Enterprise Plan", + "value":"€600", + "icon":"building-skyscraper", + "description":"For large-scale operations with custom needs", + "item": [ + {"icon":"database-plus", "description_md":"**Custom Database Scaling**"}, + {"icon":"shield-lock", "description_md":"**Enterprise Auth** with Single Sign-On"}, + {"icon":"headset", "description_md":"**Monthly** Expert Support time"}, + {"icon":"file-certificate", "description_md":"**SLA** with guaranteed uptime"} + ], + "link":"mailto:contact@datapage.app", + "button_text":"Contact Us", + "small_text":"/month", + "target":"_blank" + } + ]')), + + ('columns', 'Tech Company KPIs Display', + json('[ + {"component":"columns"}, + { + "title":"Monthly Active Users", + "value":"10k", + "value_color":"blue", + "size": 4, + "description":"Total active users this month, showcasing user engagement.", + "item": [ + {"icon": "target", "description":"Target: 12,000"} + ], + "link":"#", + "button_text":"User Activity Overview", + "button_color":"info" + }, + { + "title":"Revenue", + "value":"$49k", + "value_color":"blue", + "size": 4, + "description":"Total revenue generated this month, indicating financial performance.", + "item": [ + {"icon":"trending-down", "color": "red", "description":"down from $51k last month" } + ], + "link":"#", + "button_text":"Financial Dashboard", + "button_color":"info" + }, + { + "title":"Customer Satisfaction", + "value":"94%", + "value_color":"blue", + "size": 4, + "description":"Percentage of satisfied customers, reflecting service quality.", + "item": [ + {"icon":"trending-up", "color": "green", "description":"+ 2% this month" } + ], + "link":"#", + "button_text": "Open Google Ratings", + "button_color":"info" + } + ]')); diff --git a/examples/official-site/sqlpage/migrations/52_foldable.sql b/examples/official-site/sqlpage/migrations/52_foldable.sql new file mode 100644 index 0000000..d70201d --- /dev/null +++ b/examples/official-site/sqlpage/migrations/52_foldable.sql @@ -0,0 +1,27 @@ +INSERT INTO component(name, icon, description, introduced_in_version) VALUES + ('foldable', 'chevrons-down', 'A foldable list of elements which can be expanded individually.', '0.29.0'); + +INSERT INTO parameter(component, name, description, type, top_level, optional) SELECT 'foldable', * FROM (VALUES + ('id', 'ID attribute added to the container in HTML. Used for targeting through CSS or for scrolling via links. When set at the top level, applies to the entire foldable component.', 'TEXT', TRUE, TRUE), + ('class', 'CSS class(es) to add to the foldable container. When set at the top level, applies to the entire foldable component.', 'TEXT', TRUE, TRUE), + ('id', 'ID attribute added to individual foldable items. Used for targeting through CSS or for scrolling via links.', 'TEXT', FALSE, TRUE), + ('class', 'CSS class(es) to add to individual foldable items.', 'TEXT', FALSE, TRUE), + ('title', 'Title of the foldable item, displayed on the button.', 'TEXT', FALSE, TRUE), + ('description', 'Plain text description of the item, displayed when expanded.', 'TEXT', FALSE, TRUE), + ('description_md', 'Markdown description of the item, displayed when expanded.', 'TEXT', FALSE, TRUE), + ('expanded', 'If set to TRUE, the foldable item starts in an expanded state. Defaults FALSE', 'BOOLEAN', FALSE, TRUE) +) x; + +INSERT INTO example(component, description, properties) VALUES + ('foldable', 'A single foldable paragraph of text', json('[ + {"component":"foldable"}, + {"title":"The foldable component", "description": "This is a simple foldable component. It can be used to show and hide content. It is a list of items, each with a title and a description. The description is displayed when the item is expanded."}, + ]')); + +INSERT INTO example(component, description, properties) VALUES + ('foldable', 'A SQLPage-themed foldable list with Markdown', json('[ + {"component":"foldable"}, + {"title":"Quick Prototyping", "description_md": "Build a functional web app prototype in minutes using just SQL queries:\n\n- Rapid development\n- Ideal for MVPs\n- Great for internal tools\n\nLearn more about [quick prototyping](/your-first-sql-website/).", "expanded": true}, + {"title":"Data Visualization", "description_md": "Quickly transform your database into useful insights:\n\n1. **Charts**: Line, bar, pie\n2. **KPIs**: Appealing visualizations of key metrics\n3. **Maps**: Geospatial data\n\nAs simple as:\n\n```sql\nSELECT ''chart'' as component;\nSELECT date as x, revenue as y FROM sales;\n```"}, + {"title":"Don''t stare, interact!", "description_md": "SQLPage is not just a passive *Business Intelligence* tool. With SQLPage, you can act upon user input:\n\n- *User input collection*: Building a form is just as easy as building a chart.\n- *Data validation*: Write your own validation rules in SQL.\n- *Database updates*: `INSERT` and `UPDATE` are first-class citizens.\n- *File uploads*: Upload `CSV` and other files, store and display them the way you want.\n\n> Let users interact with your data, not just look at it!"} + ]')); \ No newline at end of file diff --git a/examples/official-site/sqlpage/migrations/53_blog_pgconf2024.sql b/examples/official-site/sqlpage/migrations/53_blog_pgconf2024.sql new file mode 100644 index 0000000..1f859c2 --- /dev/null +++ b/examples/official-site/sqlpage/migrations/53_blog_pgconf2024.sql @@ -0,0 +1,28 @@ + +INSERT INTO blog_posts (title, description, icon, created_at, content) +VALUES + ( + 'Come and see us in Athens !', + 'PGConf 2024 is coming to Athens, Greece. And we will be there to talk about SQLPage & Archeology !', + 'microphone-2', + '2024-10-15', + ' +# PostgreSQL & SQLPage at PGConf 2024 + +We have been invited to give a talk at PGConf 2024 in Athens, Greece. + +Last year, we gave a general introduction to SQLPage at PGConf.eu 2023 in Prague. + +This year, we will focus on a more specific use case: +using SQLPage to build a power-tool for archaeologists to explore and understand archaeological sites, +radically changing the way archaeologists work, and allowing them to +drastically reduce the time spent on data entry and management. + +There will be two presenters: + - **Ophir Lojkine**, the creator and maintainer of SQLPage, will present the project and its capabilities. + - **Thomas Guillemard**, an archaeologist from France''s National Institute for Preventive Archaeological Research, will present the project''s use case and its benefits. + +Come and see us in Athens ! + +https://www.postgresql.eu/events/pgconfeu2024/schedule/session/5707-unearthing-the-past-with-postgresql-how-open-source-is-revolutionizing-digital-archaeology/ +'); diff --git a/examples/official-site/sqlpage/migrations/54_blog_bompard.sql b/examples/official-site/sqlpage/migrations/54_blog_bompard.sql new file mode 100644 index 0000000..4fc4457 --- /dev/null +++ b/examples/official-site/sqlpage/migrations/54_blog_bompard.sql @@ -0,0 +1,141 @@ + +INSERT INTO blog_posts (title, description, icon, created_at, content) +VALUES + ( + 'How I built a 360° performance monitoring tool with SQL Queries', + 'Alexis built a performance monitoring tool with SQLPage for a 100M€/year company', + 'shirt', + '2025-01-25', + ' +# How I Built And Deployed An Exhaustive Performance Monitoring Tool For a 100M€/year Company Using SQL Queries Only + +### What is SQLPage ? + +> [SQLPage](http://sql-page.com) allows anyone with SQL skills to build and deploy digital tools (websites, data applications, dashboards, user forms…) using only **SQL queries**. Official website: [https://sql-page.com/](https://sql-page.com/) + +SQLPage eliminates the need to learn server languages, HTML, CSS, JavaScript, or front-end frameworks, and instead uses SQL to generate modern UI layouts populated with database query results. You get native SQL interactions with the database, without all the other layers that typically get in the way. + +The execution of the project is straightforward: simply run a single executable without any installation dependencies. Everything from authentication to security, and even HTTPS termination is automated. The code required to complete most real-world development tasks is minimal and seamless. + +Finally, it’s open source with an MIT license. + +### Why SQLPage became a game-changer for me, as a Head of Data + +As a Head of Data at a mid-size company, I understand the challenge of juggling multiple tools — often expensive and proprietary — alongside a variety of dashboards. Building an **all-in-one**, **user-friendly**, **mobile-compatible** platform for data monitoring and visualization that serves everyone, from C-level executives to store managers, is no small feat. + +The struggle intensifies when teams are small and lack coding skills or experience with diverse tech stacks. A typical data flow in a digital-native company involves several teams, specialized skills, and costly tools: + +![](https://cdn-images-1.medium.com/max/800/1*1IoXc8-07rqXO3yvKC13nQ.png) + +*Typical Data Flow of digital native companies. + +SQLPage changes this by allowing data professionals to use the same language — SQL — across the entire process, from building data pipelines to creating fully functional digital tools. Data analysts, scientists, business analysts, DBAs, and IT teams already have the expertise to craft their own custom data applications from the ground up. + +### Building an all-in one monitoring tool using SQL-queries only +[![youtube](https://github.com/user-attachments/assets/1afe36d7-9deb-40fc-a174-7a869348500b)](https://www.youtube.com/embed/R-5Pej8Sw18?si=qgxacwip2Mm-0wC7) + +*Excerpt from a series of videos explaining how to build and deploy your first digital tool with SQLPage* ([https://www.youtube.com/@SQLPage](https://www.youtube.com/@SQLPage)). + + +I am using SQLPage to build a 360° Performance tool for my company, integrating data from multiple sources — Revenue, traffic, marketing investments, live performance monitoring, financial targets, images of top sold products, Google Analytics for the online traffic… — . + +#### With SQLPage, I can: + +- **Centralize** all company data in one tool for visualizations, year-over-year comparisons, financial targets, and more. +- **Provide tailored insights**: A store owner can instantly access last year’s performance and top-selling products, while the e-commerce director can track conversion rate history. SQLPage’s pre-built components offer limitless possibilities for displaying results. +- **Perform CRUD operations**: Unlike traditional BI tools, SQLPage not only displays data but also allows users to interact with it — inputting data, such as comments or updates, directly through the interface. This capability to both display and collect data is a significant enhancement over traditional BI tools, which typically do not support data input. +- **Ensure a single source of truth**: By connecting directly to the database, SQLPage avoids discrepancies between dashboards, ensuring all teams work with consistent and accurate data. + +Here are some pages I built using only SQL queries, allowing everyone in the company to instantly access any level of information, from the fiscal year 2024 revenue trends to the top-selling products in Marseille in October 2022. + +![](https://cdn-images-1.medium.com/max/800/1*MkORbAC7oGEG-8I1mthu6A.png) + +*Performance of different channels vs last year and best sellers. + +![](https://cdn-images-1.medium.com/max/800/1*_3-g1om_p9ghXhdcw0zHmw.png) + +*Examples of views built with SQLPage to provide a 360° tool for the company. + +### How Does It Work ? + +The process in SQLPage follows a simple pattern: + +> 1) Select a component + +> 2) Write a query to populate the selected component with data + +You can find the full list of components: [https://sql-page.com/documentation.sql](https://sql-page.com/documentation.sql) + +Here’s an example of a parameterized SQL query that uses the “chart” component, along with the query to feed data into it: + + + +```sql +-- Chart Component +SELECT ''chart'' AS component, + CONCAT(''Daily Revenue from '', $start_date_comparison, '' to '', $end_date_comparison) AS title, + ''area'' AS type, + ''indigo'' AS color, + 5 AS marker, + 0 AS ymin; + +-- Chart Data Query +SELECT DATE(business_date) AS x, + ROUND(SUM(value), 2) AS y +FROM data_example +WHERE DATE(business_date) BETWEEN $start_date_comparison AND $end_date_comparison + AND variable_name = ''CA'' +GROUP BY DATE(business_date) +ORDER BY x ASC; + +-- NB: The variables $start_date_comparison and $end_date_comparison are +-- defined dynamically in the SQL script +``` +And the result: + +![Example of a SQL-generated page using the “graph” and the “table” components](https://cdn-images-1.medium.com/max/800/1*mtgJNP7DSOmMnq0iu6dDcg.png) + +*Example of a SQL-generated page using the components “graphs” and “tables”.* + +That’s it! Each component comes with customizable parameters, allowing you to tailor the display. As shown in the screenshot, links are clickable, enabling users to add data, such as leaving a comment for a specific date. + +The ability to perform CRUD operations and interact directly with databases is a game changer compared to traditional BI tools. You can try it yourself by clicking “add” in the column “COMMENT THIS YEAR” [https://demo-test.datapage.app/lets_see_some_graphs.sql](https://demo-test.datapage.app/lets_see_some_graphs.sql) + +### What About GenAI ? + +I couldn’t write an article about data in 2024 without mentioning GenAI. The great news is that SQLPage, relying solely on SQL queries, is naturally GenAI-friendly. In fact, I rarely write SQL queries myself anymore — I let GenAI handle that. My workflow in SQLPage now becomes: + +> 1) Select a component + +> 2) Ask a GenAI tool to write the query I need + +![](https://cdn-images-1.medium.com/max/800/1*mg45EO7XCVPNiuIQ_5Xg0Q.png) + +*Example of Generated SQL to display a specific format of numbers.* + +### How to Host Your SQLPage Application + +Once my app was ready, I could have chosen to host it myself on any server for a few euros a month, but I opted for SQLPage’s official hosting service, DataPage ([https://datapage.app/](https://beta.datapage.app/)), which is fully managed and very convenient. My app was hosted at _domainname.datapage.app._ The service includes a Postgres database, allowing you to either store your data on the server or connect directly to your existing database (Microsoft SQL Server, SQLite, Postgres, MySQL, etc). + +### What Difficulties Can Be Encountered With SQLPage + +While SQLPage simplifies the process of building digital tools, it does come with some challenges. + +As applications grow in complexity, so do the SQL queries required to power them, which can result in long and intricate scripts. Additionally, to fully leverage SQLPage, you need to understand how its components work, especially if user input is involved. Developers should be comfortable with creating tables in a database, writing `INSERT` queries, and managing data effectively. Without a solid grasp of these fundamentals, building more advanced apps can become a bit overwhelming. + +### Conclusion + +With SQLPage, any company with a database and one employee who knows how to query it has the tools and workforce to build and deploy virtually any digital tool. + +In this article, I focused on creating an enhanced Business Intelligence tool, but SQLPage’s versatility goes far beyond that. It is being used to build a planning tool for lumberjacks in Finland, a monitoring app for a South African transport and logistics company, by archaeologists to input excavation data in the field… + +What all these projects have in common is that they were built by a single person, using nothing but SQL queries. If you’re ready to streamline your processes and build powerful tools with ease, SQLPage is worth exploring further. + +### Useful links + +- 🏡Official website [https://sql-page.com](https://sql-page.com/) +- 🔰Quick start (written by [Nick Antonaccio](https://medium.com/u/b6a791990395)): [https://learnsqlpage.com/sqlpage_quickstart.html](https://learnsqlpage.com/sqlpage_quickstart.html) +- 📹Youtube tutorial videos on SQLPage channel: [https://www.youtube.com/@SQLPage/playlists](https://www.youtube.com/@SQLPage/playlists) +- 🤓github: [https://github.com/sqlpage/SQLPage/](https://github.com/sqlpage/SQLPage/) +- ☁️Host your applications: [https://datapage.app](https://datapage.app) +'); diff --git a/examples/official-site/sqlpage/migrations/55_request_body.sql b/examples/official-site/sqlpage/migrations/55_request_body.sql new file mode 100644 index 0000000..d8dd1c9 --- /dev/null +++ b/examples/official-site/sqlpage/migrations/55_request_body.sql @@ -0,0 +1,135 @@ +INSERT INTO sqlpage_functions ( + "name", + "introduced_in_version", + "icon", + "description_md" + ) +VALUES ( + 'request_body', + '0.33.0', + 'http-post', + 'Returns the raw request body as a string. + +A client (like a web browser, mobile app, or another server) can send information to your server in the request body. +This function allows you to read that information in your SQL code, +in order to create or update a resource in your database for instance. + +The request body is commonly used when building **REST APIs** (machines-to-machines interfaces) +that receive data from the client. + +This is especially useful in: +- `POST` and `PUT` requests for creating or updating resources in your database +- Any API endpoint that needs to receive complex data + +### Example: Building a REST API + +Here''s an example of building an API endpoint that receives a json object, +and inserts it into a database. + +#### `api/create_user.sql` +```sql +-- Get the raw JSON body +set user_data = sqlpage.request_body(); + +-- Insert the user into database +with parsed_data as ( + select + json_extract($user_data, ''$.name'') as name, + json_extract($user_data, ''$.email'') as email +) +insert into users (name, email) +select name, email from parsed_data; + +-- Return success response +select ''json'' as component, + json_object( + ''status'', ''success'', + ''message'', ''User created successfully'' + ) as contents; +``` + +### Testing the API + +You can test this API using curl: +```bash +curl -X POST http://localhost:8080/api/create_user \ + -H "Content-Type: application/json" \ + -d ''{"name": "John", "email": "john@example.com"}'' +``` + +## Special cases + +### NULL + +This function returns NULL if: + - There is no request body + - The request content type is `application/x-www-form-urlencoded` or `multipart/form-data` + (in these cases, use [`sqlpage.variables(''post'')`](?function=variables) instead) + +### Binary data + +If the request body is not valid text encoded in UTF-8, +invalid characters are replaced with the Unicode replacement character `�` (U+FFFD). + +If you need to handle binary data, +use [`sqlpage.request_body_base64()`](?function=request_body_base64) instead. +' + ); + +INSERT INTO sqlpage_functions ( + "name", + "introduced_in_version", + "icon", + "description_md" + ) +VALUES ( + 'request_body_base64', + '0.33.0', + 'photo-up', + 'Returns the raw request body encoded in base64. This is useful when receiving binary data or when you need to handle non-text content in your API endpoints. + +### What is Base64? + +Base64 is a way to encode binary data (like images or files) into text that can be safely stored and transmitted. This function automatically converts the incoming request body into this format. + +### Example: Handling Binary Data in an API + +This example shows how to receive and process an image uploaded directly in the request body: + +```sql +-- Assuming this is api/upload_image.sql +-- Client would send a POST request with the raw image data + +-- Get the base64-encoded image data +set image_data = sqlpage.request_body_base64(); + +-- Store the image data in the database +insert into images (data, uploaded_at) +values ($image_data, current_timestamp); + +-- Return success response +select ''json'' as component, + json_object( + ''status'', ''success'', + ''message'', ''Image uploaded successfully'' + ) as contents; +``` + +You can test this API using curl: +```bash +curl -X POST http://localhost:8080/api/upload_image.sql \ + -H "Content-Type: application/octet-stream" \ + --data-binary "@/path/to/image.jpg" +``` + +This is particularly useful when: +- Working with binary data (images, files, etc.) +- The request body contains non-UTF8 characters +- You need to pass the raw body to another system that expects base64 + +> Note: Like [`sqlpage.request_body()`](?function=request_body), this function returns NULL if: +> - There is no request body +> - The request content type is `application/x-www-form-urlencoded` or `multipart/form-data` +> (in these cases, use [`sqlpage.variables(''post'')`](?function=variables) instead) +' + ); \ No newline at end of file diff --git a/examples/official-site/sqlpage/migrations/56_headers.sql b/examples/official-site/sqlpage/migrations/56_headers.sql new file mode 100644 index 0000000..b88aaf4 --- /dev/null +++ b/examples/official-site/sqlpage/migrations/56_headers.sql @@ -0,0 +1,39 @@ +INSERT INTO sqlpage_functions ( + "name", + "introduced_in_version", + "icon", + "description_md" + ) +VALUES ( + 'headers', + '0.33.0', + 'circle-dotted-letter-h', + 'Returns all HTTP request headers as a JSON object. + +### Example + +The following displays all HTTP request headers in a list, +using SQLite''s `json_each()` function. + +```sql +select ''list'' as component; + +select key as title, value as description +from json_each(sqlpage.headers()); -- json_each() is SQLite only +``` + +If not on SQLite, use your [database''s JSON function](/blog.sql?post=JSON%20in%20SQL%3A%20A%20Comprehensive%20Guide). + +### Details + +The function returns a JSON object where: +- Keys are lowercase header names +- Values are the corresponding header values +- If no headers are present, returns an empty JSON object `{}` + +This is useful when you need to: +- Debug HTTP requests +- Access multiple headers at once + +If you only need access to a single known header, use [`sqlpage.header(name)`](?function=header) instead. +'); \ No newline at end of file diff --git a/examples/official-site/sqlpage/migrations/57_client_ip.sql b/examples/official-site/sqlpage/migrations/57_client_ip.sql new file mode 100644 index 0000000..168451f --- /dev/null +++ b/examples/official-site/sqlpage/migrations/57_client_ip.sql @@ -0,0 +1,46 @@ +INSERT INTO + sqlpage_functions ( + "name", + "introduced_in_version", + "icon", + "description_md" + ) +VALUES + ( + 'client_ip', + '0.33.0', + 'network', + 'Returns the IP address of the client making the HTTP request. + +### Example + +```sql +insert into connection_log (client_ip) values (sqlpage.client_ip()); +``` + +### Details + +The function returns: +- The IP address of the client as a string +- `null` if the client IP cannot be determined (e.g., when serving through a Unix socket) + +### ⚠️ Important Notes for Production Use + +When [running behind a reverse proxy](/your-first-sql-website/nginx.sql) (e.g., Nginx, Apache, Cloudflare): +- This function will return the IP address of the reverse proxy, not the actual client +- To get the real client IP, use [`sqlpage.header`](?function=header): `sqlpage.header(''x-forwarded-for'')` or `sqlpage.header(''x-real-ip'')` + - The exact header name depends on your reverse proxy configuration + +Example with reverse proxy: +```sql +-- Choose the appropriate header based on your setup +select coalesce( + sqlpage.header(''x-forwarded-for''), + sqlpage.header(''x-real-ip''), + sqlpage.client_ip() +) as real_client_ip; +``` + +For security-critical applications, ensure your reverse proxy is properly configured to set and validate these headers. +' + ); \ No newline at end of file diff --git a/examples/official-site/sqlpage/migrations/58_fetch_with_meta.sql b/examples/official-site/sqlpage/migrations/58_fetch_with_meta.sql new file mode 100644 index 0000000..296071d --- /dev/null +++ b/examples/official-site/sqlpage/migrations/58_fetch_with_meta.sql @@ -0,0 +1,86 @@ +INSERT INTO sqlpage_functions ( + "name", + "introduced_in_version", + "icon", + "description_md" + ) +VALUES ( + 'fetch_with_meta', + '0.34.0', + 'transfer-vertical', + 'Sends an HTTP request and returns detailed metadata about the response, including status code, headers, and body. + +This function is similar to [`fetch`](?function=fetch), but returns a JSON object containing detailed information about the response. +The returned object has the following structure: +```json +{ + "status": 200, + "headers": { + "content-type": "text/html", + "content-length": "1234" + }, + "body": "a string, or a json object, depending on the content type", + "error": "error message if any" +} +``` + +If the request fails or encounters an error (e.g., network issues, invalid UTF-8 response), instead of throwing an error, +the function returns a JSON object with an "error" field containing the error message. + +### Example: Basic Usage + +```sql +-- Make a request and get detailed response information +set response = sqlpage.fetch_with_meta(''https://pokeapi.co/api/v2/pokemon/ditto''); + +-- redirect the user to an error page if the request failed +select ''redirect'' as component, ''error.sql'' as url +where + json_extract($response, ''$.error'') is not null + or json_extract($response, ''$.status'') != 200; + +-- Extract data from the response json body +select ''card'' as component; +select + json_extract($response, ''$.body.name'') as title, + json_extract($response, ''$.body.abilities[0].ability.name'') as description +from $response; +``` + +### Example: Advanced Request with Authentication + +```sql +set request = json_object( + ''method'', ''POST'', + ''url'', ''https://sqlpage.free.beeceptor.com'', + ''headers'', json_object( + ''Content-Type'', ''application/json'', + ''Authorization'', ''Bearer '' || sqlpage.environment_variable(''API_TOKEN'') + ), + ''body'', json_object( + ''key'', ''value'' + ) +); +set response = sqlpage.fetch_with_meta($request); + +-- Check response content type +select ''debug'' as component, $response as response; +``` + +The function accepts the same parameters as the [`fetch` function](?function=fetch).' + ); + +INSERT INTO sqlpage_function_parameters ( + "function", + "index", + "name", + "description_md", + "type" + ) +VALUES ( + 'fetch_with_meta', + 1, + 'url', + 'Either a string containing an URL to request, or a json object in the standard format of the request interface of the web fetch API.', + 'TEXT' + ); \ No newline at end of file diff --git a/examples/official-site/sqlpage/migrations/59_unsafe_contents_md.sql b/examples/official-site/sqlpage/migrations/59_unsafe_contents_md.sql new file mode 100644 index 0000000..5f8f2c9 --- /dev/null +++ b/examples/official-site/sqlpage/migrations/59_unsafe_contents_md.sql @@ -0,0 +1,4 @@ +INSERT INTO parameter(component, name, description, type, top_level, optional) SELECT 'text', * FROM (VALUES +('unsafe_contents_md','Markdown format with html blocks. Use this only with trusted content. See the html-blocks section of the Commonmark spec for additional info.', 'TEXT', TRUE, TRUE), +('unsafe_contents_md','Markdown format with html blocks. Use this only with trusted content. See the html-blocks section of the Commonmark spec for additional info.', 'TEXT', FALSE, TRUE) +); diff --git a/examples/official-site/sqlpage/migrations/60_empty_state.sql b/examples/official-site/sqlpage/migrations/60_empty_state.sql new file mode 100644 index 0000000..4d897d4 --- /dev/null +++ b/examples/official-site/sqlpage/migrations/60_empty_state.sql @@ -0,0 +1,52 @@ +INSERT INTO component(name, icon, description, introduced_in_version) VALUES + ('empty_state', 'info-circle', 'Displays a large placeholder message to communicate a single information to the user and invite them to take action. + +Typically includes a title, an optional icon/image, descriptive text (rich text formatting and images supported via Markdown), and a call-to-action button. + +Ideal for first-use screens, empty data sets, "no results" pages, or error messages.', '0.35.0'); + +INSERT INTO parameter(component, name, description, type, top_level, optional) SELECT 'empty_state', * FROM (VALUES + ('title','Description of the empty state.','TEXT',TRUE,FALSE), + ('header','Text displayed on the top of the empty state.','TEXT',TRUE,TRUE), + ('icon','Name of an icon to be displayed on the top of the empty state.','ICON',TRUE,TRUE), + ('image','The URL (absolute or relative) of an image to display at the top of the empty state.','URL',TRUE,TRUE), + ('description','A short text displayed below the title.','TEXT',TRUE,TRUE), + ('link_text','The text displayed on the button.','TEXT',TRUE,FALSE), + ('link_icon','Name of an icon to be displayed on the left side of the button.','ICON',TRUE,FALSE), + ('link','The URL to which the button should navigate when clicked.','URL',TRUE,FALSE), + ('class','Class attribute added to the container in HTML. It can be used to apply custom styling to this item through css.','TEXT',TRUE,TRUE), + ('id','ID attribute added to the container in HTML. It can be used to target this item through css or for scrolling to this item through links (use "#id" in link url).','TEXT',TRUE,TRUE) +) x; + +INSERT INTO example(component, description, properties) VALUES + ('empty_state', ' +This example shows how to create a 404-style "Not Found" empty state with + - a prominent header displaying "404", + - a helpful description suggesting to adjust search parameters, and + - a "Search again" button with a search icon that links back to the search page. +', + json('[{ + "component": "empty_state", + "title": "No results found", + "header": "404", + "description": "Try adjusting your search or filter to find what you''re looking for.", + "link_text": "Search again", + "link_icon": "search", + "link": "#not-found", + "id": "not-found" + }]')), + ('empty_state', ' +It''s possible to use an icon or an image to illustrate the problem. +', + json('[{ + "component": "empty_state", + "title": "A critical problem has occurred", + "icon": "mood-wrrr", + "description_md": "SQLPage can do a lot of things, but this is not one of them. + +Please restart your browser and **cross your fingers**.", + "link_text": "Close and restart", + "link_icon": "rotate-clockwise", + "link": "#" + }]')); + diff --git a/examples/official-site/sqlpage/migrations/61_oidc_functions.sql b/examples/official-site/sqlpage/migrations/61_oidc_functions.sql new file mode 100644 index 0000000..71d1d84 --- /dev/null +++ b/examples/official-site/sqlpage/migrations/61_oidc_functions.sql @@ -0,0 +1,294 @@ +INSERT INTO + sqlpage_functions ( + "name", + "introduced_in_version", + "icon", + "description_md" + ) +VALUES + ( + 'user_info_token', + '0.35.0', + 'key', + '# Accessing information about the current user, when logged in with SSO + +This function can be used only when you have [configured Single Sign-On with an OIDC provider](/sso). + +## The ID Token + +When a user logs in through OIDC, your application receives an [identity token](https://openid.net/specs/openid-connect-core-1_0.html#IDToken) from the identity provider. +This token contains information about the user, such as their name and email address. +The `sqlpage.user_info_token()` function lets you access the entire contents of the ID token, as a JSON object. +You can then use [your database''s JSON functions](/blog.sql?post=JSON+in+SQL%3A+A+Comprehensive+Guide) to process that JSON. + +If you need to access a specific claim, it is easier and more performant to use the +[`sqlpage.user_info()`](?function=user_info) function instead. + +### Example: Displaying User Information + +```sql +select ''list'' as component; +select key as title, value as description +from json_each(sqlpage.user_info_token()); +``` + +This sqlite-specific example will show all the information available about the current user, such as: +- `sub`: A unique identifier for the user +- `name`: The user''s full name +- `email`: The user''s email address +- `picture`: A URL to the user''s profile picture + +### Security Notes + +- The ID token is automatically verified by SQLPage to ensure it hasn''t been tampered with. +- The token is only available to authenticated users: if no user is logged in or sso is not configured, this function returns NULL +- If some information is not available in the token, you have to configure it on your OIDC provider, SQLPage can''t do anything about it. +- The token is stored in a signed http-only cookie named `sqlpage_auth`. You can use [the cookie component](/component.sql?component=cookie) to delete it, and the user will be redirected to the login page on the next page load. +' + ); + +INSERT INTO + sqlpage_functions ( + "name", + "introduced_in_version", + "icon", + "description_md" + ) +VALUES + ( + 'user_info', + '0.34.0', + 'user', + '# Accessing Specific User Information + +The `sqlpage.user_info` function is a convenient way to access specific pieces of information about the currently logged-in user. +When you [configure Single Sign-On](/sso), your OIDC provider will issue an [ID token](https://openid.net/specs/openid-connect-core-1_0.html#IDToken) for the user, +which contains *claims*, with information about the user. + +Calling `sqlpage.user_info(claim_name)` lets you access these claims directly from SQL. + +## How to Use + +The function takes one parameter: the name of the *claim* (the piece of information you want to retrieve). + +For example, to display a personalized welcome message, with the user''s name, you can use: + +```sql +select ''text'' as component; +select ''Welcome, '' || sqlpage.user_info(''name'') || ''!'' as title; +``` + +## Available Information + +The exact information available depends on your identity provider (the service you chose to authenticate with), +its configuration, and the scopes you requested. +Use [`sqlpage.user_info_token()`](?function=user_info_token) to see all the information available in the ID token of the current user. + +Here are some commonly available fields: + +### Basic Information +- `name`: The user''s full name (usually first and last name separated by a space) +- `email`: The user''s email address (*warning*: there is no guarantee that the user currently controls this email address. Use the `sub` claim for database references instead.) +- `picture`: URL to the user''s profile picture + +### User Identifiers +- `sub`: A unique identifier for the user (use this to uniquely identify the user in your database) +- `preferred_username`: The username the user prefers to use + +### Name Components +- `given_name`: The user''s first name +- `family_name`: The user''s last name + +## Examples + +### Personalized Welcome Message +```sql +select ''text'' as component, + ''Welcome back, **'' || sqlpage.user_info(''given_name'') || ''**!'' as contents_md; +``` + +### User Profile Card +```sql +select ''card'' as component; +select + sqlpage.user_info(''name'') as title, + sqlpage.user_info(''email'') as description, + sqlpage.user_info(''picture'') as image; +``` + +### Conditional Content Based on custom claims + +Some identity providers let you add custom claims to the ID token. +This lets you customize the behavior of your application based on arbitrary user attributes, +such as the user''s role. + +```sql +-- show everything to admins, only public items to others +select ''list'' as component; +select title from my_items + where is_public or sqlpage.user_info(''role'') = ''admin'' +``` + +## Security Best Practices + +> ⚠️ **Important**: Always use the `sub` claim to identify users in your database, not their email address. +> The `sub` claim is guaranteed to be unique and stable for each user, while email addresses can change. +> In most providers, receiving an id token with a given email does not guarantee that the user currently controls that email. + +```sql +-- Store the user''s ID in your database +insert into user_preferences (user_id, theme) +values (sqlpage.user_info(''sub''), ''dark''); +``` + +## Troubleshooting + +If you''re not getting the information you expect: + +1. Check that OIDC is properly configured in your `sqlpage.json` +2. Verify that you requested the right scopes in your OIDC configuration +3. Try using `sqlpage.user_info_token()` to see all available information +4. Check your OIDC provider''s documentation for the exact claim names they use + +Remember: If the user is not logged in or the requested information is not available, this function returns NULL. +' + ); + +INSERT INTO + sqlpage_function_parameters ( + "function", + "index", + "name", + "description_md", + "type" + ) +VALUES + ( + 'user_info', + 1, + 'claim', + 'The name of the user information to retrieve. Common values include ''name'', ''email'', ''picture'', ''sub'', ''preferred_username'', ''given_name'', and ''family_name''. The exact values available depend on your OIDC provider and configuration.', + 'TEXT' + ); + +INSERT INTO + sqlpage_functions ( + "name", + "introduced_in_version", + "icon", + "description_md" + ) +VALUES + ( + 'oidc_logout_url', + '0.41.0', + 'logout', + '# Secure OIDC Logout + +The `sqlpage.oidc_logout_url` function generates a secure logout URL for users authenticated via [OIDC Single Sign-On](/sso). + +When a user visits this URL, SQLPage will: +1. Remove the authentication cookie +2. Redirect the user to the OIDC provider''s logout endpoint (if available) +3. Finally redirect back to the specified `redirect_uri` + +## Security Features + +This function provides protection against **Cross-Site Request Forgery (CSRF)** attacks: +- The generated URL contains a cryptographically signed token +- The token includes a timestamp and expires after 10 minutes +- The token is signed using your OIDC client secret +- Only relative URLs (starting with `/`) are allowed as redirect targets + +This means that malicious websites cannot trick your users into logging out by simply including an image or link to your logout URL. + +## How to Use + +```sql +select ''button'' as component; +select + ''Logout'' as title, + sqlpage.oidc_logout_url(''/'') as link, + ''logout'' as icon, + ''red'' as outline; +``` + +This creates a logout button that, when clicked: +1. Logs the user out of your SQLPage application +2. Logs the user out of the OIDC provider (if the provider supports [RP-Initiated Logout](https://openid.net/specs/openid-connect-rpinitiated-1_0.html)) +3. Redirects the user back to your homepage (`/`) + +## Examples + +### Logout Button in Navigation + +```sql +select ''shell'' as component, + ''My App'' as title, + json_array( + json_object( + ''title'', ''Logout'', + ''link'', sqlpage.oidc_logout_url(''/''), + ''icon'', ''logout'' + ) + ) as menu_item; +``` + +### Logout with Return to Current Page + +```sql +select ''button'' as component; +select + ''Sign Out'' as title, + sqlpage.oidc_logout_url(sqlpage.path()) as link; +``` + +### Conditional Logout Link + +```sql +select ''button'' as component +where sqlpage.user_info(''sub'') is not null; +select + ''Logout '' || sqlpage.user_info(''name'') as title, + sqlpage.oidc_logout_url(''/'') as link +where sqlpage.user_info(''sub'') is not null; +``` + +## Requirements + +- OIDC must be [configured](/sso) in your `sqlpage.json` +- If OIDC is not configured, this function returns NULL +- The `redirect_uri` must be a relative path starting with `/` + +## Provider Support + +The logout behavior depends on your OIDC provider: + +| Provider | Full Logout Support | +|----------|-------------------| +| Keycloak | ✅ Yes | +| Auth0 | ✅ Yes | +| Google | ❌ No (local logout only) | +| Azure AD | ✅ Yes | +| Okta | ✅ Yes | + +When the provider doesn''t support RP-Initiated Logout, SQLPage will still remove the local authentication cookie and redirect to your specified URI. +' + ); + +INSERT INTO + sqlpage_function_parameters ( + "function", + "index", + "name", + "description_md", + "type" + ) +VALUES + ( + 'oidc_logout_url', + 1, + 'redirect_uri', + 'The relative URL path where the user should be redirected after logout. Must start with `/`. Defaults to `/` if not provided.', + 'TEXT' + ); \ No newline at end of file diff --git a/examples/official-site/sqlpage/migrations/62_example_cards.sql b/examples/official-site/sqlpage/migrations/62_example_cards.sql new file mode 100644 index 0000000..04a7c90 --- /dev/null +++ b/examples/official-site/sqlpage/migrations/62_example_cards.sql @@ -0,0 +1,104 @@ +create table + example_cards as +select + 'Advanced Authentication' as title, + 'user-authentication' as folder, -- Has to exactly match the folder name in the /examples/ directory + 'postgres' as db_engine, + 'Build a secure user authentication system with login, signup, and in-database session management.' as description +union all +select + 'Authenticated CRUD', + 'CRUD - Authentication', + 'sqlite', + 'Complete Create-Read-Update-Delete operations with user authentication.' +union all +select + 'Image Gallery', + 'image gallery with user uploads', + 'sqlite', + 'Create an image gallery with user uploads and session management.' +union all +select + 'Developer UI', + 'SQLPage developer user interface', + 'postgres', + 'A web-based interface for managing SQLPage files and database tables.' +union all +select + 'Corporate Game', + 'corporate-conundrum', + 'sqlite', + 'An interactive multiplayer board game with real-time updates.' +union all +select + 'Roundest Pokemon', + 'roundest_pokemon_rating', + 'sqlite', + 'Demo app with a distinct non-default design, using custom HTML templates for everything.' +union all +select + 'Todo Application', + 'todo application (PostgreSQL)', + 'postgres', + 'A full-featured todo list application with PostgreSQL backend.' +union all +select + 'MySQL & JSON', + 'mysql json handling', + 'mysql', + 'Learn advanced JSON manipulation in MySQL to build advanced SQLPage applications.' +union all +select + 'Apache Web Server', + 'web servers - apache', + 'mysql', + 'Use an existing Apache httpd Web Server to expose your SQLPage application.' +union all +select + 'Sending Emails', + 'sending emails', + 'sqlite', + 'Use the fetch function to send emails (or interact with any other HTTP API).' +union all +select + 'Simple Website', + 'simple-website-example', + 'sqlite', + 'Basic website example with navigation and data management.' +union all +select + 'Geographic App', + 'PostGIS - using sqlpage with geographic data', + 'postgres', + 'Use SQLPage to create and manage geodata.' +union all +select + 'Multi-step form', + 'forms-with-multiple-steps', + 'sqlite', + 'Guide to the implementation of forms that spread over multiple pages.' +union all +select + 'Custom HTML & JS', + 'custom form component', + 'mysql', + 'Building a custom form component with a dynamic widget using HTML and javascript.' +union all +select + 'Splitwise Clone', + 'splitwise', + 'sqlite', + 'An expense tracker app to split expenses with your friends, with nice debt charts.' +union all +select + 'Advanced Forms with MS SQL Server', + 'microsoft sql server advanced forms', + 'sql server', + 'Forms with multi-value dropdowns, using SQL Server and its JSON functions.' +union all +select + 'Rich Text Editor', + 'rich-text-editor', + 'sqlite', + 'A rich text editor with bold, italic, lists, images, and more. It posts its contents as Markdown.' +; diff --git a/examples/official-site/sqlpage/migrations/63_modal.sql b/examples/official-site/sqlpage/migrations/63_modal.sql new file mode 100644 index 0000000..dfc78c0 --- /dev/null +++ b/examples/official-site/sqlpage/migrations/63_modal.sql @@ -0,0 +1,84 @@ +INSERT INTO component(name, icon, description, introduced_in_version) VALUES + ('modal', 'app-window', ' +Defines the a temporary popup box displayed on top of a webpage’s content. +Useful for displaying additional information, help, or collect data from users. + +Modals are closed by default, and can be opened by clicking on a button or link targeting their ID.', '0.36.0'); + +INSERT INTO parameter(component, name, description, type, top_level, optional) SELECT 'modal', * FROM (VALUES + ('title','Description of the modal box.','TEXT',TRUE,FALSE), + ('close','The text to display in the Close button.','TEXT',TRUE,TRUE), + ('contents','A paragraph of text to display, without any formatting, without having to make additional queries.','TEXT',FALSE,TRUE), + ('contents_md','Rich text in the markdown format. Among others, this allows you to write bold text using **bold**, italics using *italics*, and links using [text](https://example.com).','TEXT',FALSE,TRUE), + ('scrollable','Create a scrollable modal that allows scroll the modal body.','BOOLEAN',TRUE,TRUE), + ('class','Class attribute added to the container in HTML. It can be used to apply custom styling to this item through css.','TEXT',TRUE,TRUE), + ('id','ID attribute added to the container in HTML. It can be used to target this item through css or for displaying this item.','TEXT',TRUE,FALSE), + ('large','Indicates that the modal box has an increased width.','BOOLEAN',TRUE,TRUE), + ('small','Indicates that the modal box has a reduced width.','BOOLEAN',TRUE,TRUE), + ('embed','Embed remote content in an iframe.','TEXT',TRUE,TRUE), + ('embed_mode','Use "iframe" to display embedded content within an iframe.','TEXT',TRUE,TRUE), + ('height','Height of the embedded content.','INTEGER',TRUE,TRUE), + ('allow','For embedded content, this attribute specifies the features or permissions that can be used.','TEXT',TRUE,TRUE), + ('sandbox','For embedded content, this attribute specifies the security restrictions on the loaded content.','TEXT',TRUE,TRUE), + ('style','Applies CSS styles to the embedded content.','TEXT',TRUE,TRUE) +) x; + +INSERT INTO example(component, description, properties) VALUES + ('modal', + 'This example shows how to create a modal box that displays a paragraph of text. The modal window is opened with the help of a button.', + json('[ + {"component": "modal","id": "my_modal","title": "A modal box","close": "Close"}, + {"contents":"I''m a modal window, and I allow you to display additional information or help for the user."}, + {"component": "button"}, + {"title":"Open a simple modal","link":"#my_modal"} + ]') + ), + ('modal', + 'Example of modal form content', + json('[ + { + "component":"modal", + "id":"my_embed_form_modal", + "title":"Embeded form content", + "large":true, + "embed":"/examples/form.sql?_sqlpage_embed" + }, + {"component": "button"}, + {"title":"Open a modal with a form","link":"#my_embed_form_modal"} + ]') + ), + ('modal', + 'A popup modal that contains a chart generated by a separate SQL file. The modal is triggered by links inside a datagrid.', + json('[ + { + "component":"modal", + "id":"my_embed_chart_modal", + "title":"Embeded chart content", + "close":"Close", + "embed":"/examples/chart.sql?_sqlpage_embed" + }, + {"component": "datagrid"}, + {"title":"Chart", "color":"blue", "description":"Revenue", "link":"#my_embed_chart_modal"}, + {"title":"Form", "color":"green", "description":"Fill info", "link":"#my_embed_form_modal"}, + ]') + ), + ('modal', + 'Example of modal video content', + json('[ + { + "component":"modal", + "id":"my_embed_video_modal", + "title":"Embeded video content", + "close":"Close", + "embed":"https://www.youtube.com/embed/mXdgmSdaXkg", + "allow":"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share", + "embed_mode":"iframe", + "height":"350" + }, + {"component": "text", "contents_md": "Open a [modal with a video](#my_embed_video_modal)"} + ]') + ); + +INSERT INTO parameter(component, name, description, type, top_level, optional) SELECT 'button', * FROM (VALUES + ('modal','Display the modal window corresponding to the specified ID.','TEXT',FALSE,TRUE) +) x; diff --git a/examples/official-site/sqlpage/migrations/64_blog_routing.sql b/examples/official-site/sqlpage/migrations/64_blog_routing.sql new file mode 100644 index 0000000..44f920b --- /dev/null +++ b/examples/official-site/sqlpage/migrations/64_blog_routing.sql @@ -0,0 +1,63 @@ + +INSERT INTO blog_posts (title, description, icon, created_at, content) +VALUES + ( + 'File-based routing in SQLPage', + 'Understanding how SQLPage maps URLs to files and handles errors', + 'route', + '2025-07-28', + ' +SQLPage uses a simple file-based routing system that maps URLs directly to SQL files in your project directory. +No complex configuration is needed. Just create files and they become accessible endpoints. + +This guide explains how SQLPage resolves URLs, handles different file types, and manages 404 errors so you can structure your application effectively. + +## How SQLPage Routes Requests + +### 1. Site Prefix Handling + +If you''ve configured a [`site_prefix`](/your-first-sql-website/nginx) in your settings, +SQLPage will redirect all requests that do not start with the prefix to `/`. + +### 2. Path Resolution Priority + +**Directory requests (paths ending with `/`)**: SQLPage looks for an `index.sql` file in that directory and executes it if found. + +**Direct SQL file requests (`.sql` extension)**: SQLPage executes the requested SQL file if it exists. + +**Static asset requests (other extensions)**: SQLPage serves files like CSS, JavaScript, images, or any other static content directly. + +**Clean URL requests (no extension)**: SQLPage first tries to find a matching `.sql` file. If that doesn''t exist but there''s an `index.sql` file in a directory with the same name, it redirects to the directory path with a trailing slash. + +### Error Handling + +When, after applying each of the rules above in order, SQLPage can''t find a requested file, +it walks up your directory structure looking for [custom `404.sql` files](/your-first-sql-website/custom_urls). + +## Dynamic Routing with SQLPage + +SQLPage''s file-based routing becomes powerful when combined with strategic use of 404.sql files to handle dynamic URLs. Here''s how to build APIs and pages with dynamic parameters: + +### Product Catalog with Dynamic IDs + +**Goal**: Handle URLs like `/products/123`, `/products/abc`, `/products/new-laptop` + +**Setup**: +```text +products/ +├── index.sql # Lists all products (/products/) +├── 404.sql # Handles /products/ +└── categories.sql # Product categories (/products/categories) +``` + +**How it works**: +- `/products/` → Executes `products/index.sql` (product listing) +- `/products/123` → No `123.sql` file exists, so executes `products/404.sql` +- `/products/laptop` → No `laptop.sql` file exists, so executes `products/404.sql` + +**In `products/404.sql`**: +```sql +set product_id = substr(sqlpage.path(), 1+length(''/products/'')); +``` + ' + ); diff --git a/examples/official-site/sqlpage/migrations/65_download.sql b/examples/official-site/sqlpage/migrations/65_download.sql new file mode 100644 index 0000000..61c005a --- /dev/null +++ b/examples/official-site/sqlpage/migrations/65_download.sql @@ -0,0 +1,138 @@ +-- Insert the download component into the component table +INSERT INTO + component (name, description, icon, introduced_in_version) +VALUES + ( + 'download', + ' +The *download* component lets a page immediately return a file to the visitor. + +Instead of showing a web page, it sends the file''s bytes as the whole response, +so it should be used **at the very top of your SQL page** (before the shell or any other page contents). +It is an error to use this component after another component that would display content. + +How it works in simple terms: +- You provide the file content using a [data URL](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs). +A data URL is just a text string that contains both the file type and the actual data. +- Optionally, you provide a "filename" so the browser shows a proper Save As name. +If you do not provide a filename, many browsers will try to display the file inline (for example images or JSON), depending on the content type. +- You link to the page that uses the download component from another page, using the [button](/components?component=button) component for example. + +What is a data URL? +- It looks like this: `data:[content-type][;base64],DATA` +- Examples: + - Plain text (URL-encoded): `data:text/plain,Hello%20world` + - JSON (URL-encoded): `data:application/json,%7B%22message%22%3A%22Hi%22%7D` + - Binary data (Base64): `data:application/octet-stream;base64,SGVsbG8h` + +Tips: +- Use URL encoding when you have textual data. You can use [`sqlpage.url_encode(source_text)`](/functions?function=url_encode) to encode the data. +- Use Base64 when you have binary data (images, PDFs, or content that may include special characters). +- Use [`sqlpage.read_file_as_data_url(file_path)`](/functions?function=read_file_as_data_url) to read a file from the server and return it as a data URL. + +> Keep in mind that large files are better served from disk or object storage. Data URLs are best for small to medium files. +There is a big performance penalty for loading large files as data URLs, so it is not recommended. +', + 'download', + '0.37.0' + ); + +-- Insert the parameters for the download component into the parameter table +INSERT INTO + parameter ( + component, + name, + description, + type, + top_level, + optional + ) +VALUES + ( + 'download', + 'data_url', + 'The file content to send, written as a data URL (for example: data:text/plain,Hello%20world or data:application/octet-stream;base64,SGVsbG8h). The part before the comma declares the content type and whether the data is base64-encoded. The part after the comma is the actual data.', + 'TEXT', + TRUE, + FALSE + ), + ( + 'download', + 'filename', + 'The suggested name of the file to save (for example: report.csv). When set, the browser will download the file as an attachment with this name. When omitted, many browsers may try to display the file inline depending on its content type.', + 'TEXT', + TRUE, + TRUE + ); + +-- Insert usage examples of the download component into the example table +INSERT INTO + example (component, description) +VALUES + ( + 'download', + ' +## Simple plain text file +Download a small text file. The content is URL-encoded (spaces become %20). + +```sql +select + ''download'' as component, + ''data:text/plain,Hello%20SQLPage%20world!'' as data_url, + ''hello.txt'' as filename; +``` +' + ), + ( + 'download', + ' +## Download a PDF file from the server + +Download a PDF file with the proper content type so PDF readers recognize it. +Uses [`sqlpage.read_file_as_data_url(file_path)`](/functions?function=read_file_as_data_url) to read the file from the server. + +```sql +select + ''download'' as component, + ''report.pdf'' as filename, + sqlpage.read_file_as_data_url(''report.pdf'') as data_url; +``` +' + ), + ( + 'download', + ' +## Serve an image stored as a BLOB in the database + +### Automatically detect the mime type + +If you have a table with a column `content` that contains a BLOB +(depending on the database, the type may be named `BYTEA`, `BLOB`, `VARBINARY`, or `IMAGE`), +you can just return its contents directly, and SQLPage will automatically detect the mime type, +and convert it to a data URL. + +```sql +select + ''download'' as component, + content as data_url +from document +where id = $doc_id; +``` + +### Customize the mime type + +In PostgreSQL, you can use the [encode(bytes, format)](https://www.postgresql.org/docs/current/functions-binarystring.html#FUNCTION-ENCODE) function to encode the file content as Base64, +and manually create your own data URL. + +```sql +select + ''download'' as component, + ''data:'' || doc.mime_type || '';base64,'' || encode(doc.content::bytea, ''base64'') as data_url +from document as doc +where doc.id = $doc_id; +``` + + - In Microsoft SQL Server, you can use the [BASE64_ENCODE(bytes)](https://learn.microsoft.com/en-us/sql/t-sql/functions/base64-encode-transact-sql) function to encode the file content as Base64. + - In MySQL and MariaDB, you can use the [TO_BASE64(str)](https://mariadb.com/docs/server/reference/sql-functions/string-functions/to_base64) function. +' + ); \ No newline at end of file diff --git a/examples/official-site/sqlpage/migrations/66_log_component.sql b/examples/official-site/sqlpage/migrations/66_log_component.sql new file mode 100644 index 0000000..7109ad2 --- /dev/null +++ b/examples/official-site/sqlpage/migrations/66_log_component.sql @@ -0,0 +1,61 @@ +INSERT INTO component(name, icon, introduced_in_version, description) VALUES +('log', 'logs', '0.37.1', 'A component that writes messages to the server logs. +When a page runs, it prints your message to the terminal/console (standard error). +Use it to track what happens and troubleshoot issues. + +### Where do the messages appear? + +- Running from a terminal (Linux, macOS, or Windows PowerShell/Command Prompt): they show up in the window. +- Docker: run `docker logs `. +- Linux service (systemd): run `journalctl -u sqlpage`. +- This component''s output is written to [standard error (stderr)](https://en.wikipedia.org/wiki/Standard_streams#Standard_error_(stderr)). SQLPage request access logs are separate and are written to standard output (stdout). +'); + +INSERT INTO parameter(component, name, description, type, top_level, optional) SELECT 'log', * FROM (VALUES + -- top level + ('message', 'The text to write to the server logs. It is printed when the page runs.', 'TEXT', TRUE, FALSE), + ('level', 'How important the message is. One of ''trace'', ''debug'', ''info'' (default), ''warn'', ''error''. Not case-sensitive. Controls the level shown in the logs.', 'TEXT', TRUE, TRUE) +) x; + +INSERT INTO example(component, description) VALUES +('log', ' +### Record a simple message + +This writes "Hello, World!" to the server logs. + +```sql +SELECT ''log'' as component, ''Hello, World!'' as message; +``` + +Example output: + +```text +[2025-09-13T22:30:14.722Z INFO sqlpage::log from "x.sql" statement 1] Hello, World! +``` + +### Set the importance (level) + +Choose how important the message is. + +```sql +SELECT ''log'' as component, ''error'' as level, ''This is an error message'' as message; +``` + +Example output: + +```text +[2025-09-13T22:30:14.722Z ERROR sqlpage::log from "x.sql" statement 2] This is an error message +``` + +### Log dynamic information + +Include variables like a username. + +```sql +set username = ''user'' + +select ''log'' as component, + ''403 - failed for '' || coalesce($username, ''None'') as message, + ''error'' as level; +``` +') diff --git a/examples/official-site/sqlpage/migrations/67_hmac_function.sql b/examples/official-site/sqlpage/migrations/67_hmac_function.sql new file mode 100644 index 0000000..e667f03 --- /dev/null +++ b/examples/official-site/sqlpage/migrations/67_hmac_function.sql @@ -0,0 +1,137 @@ +-- HMAC function documentation and examples +INSERT INTO + sqlpage_functions ( + "name", + "introduced_in_version", + "icon", + "description_md" + ) +VALUES + ( + 'hmac', + '0.38.0', + 'shield-lock', + 'Creates a unique "signature" for some data using a secret key. +This signature proves that the data hasn''t been tampered with and comes from someone who knows the secret. + +### What is HMAC used for? + +[**HMAC**](https://en.wikipedia.org/wiki/HMAC) (Hash-based Message Authentication Code) is commonly used to: + - **Verify webhooks**: Use HMAC to ensure only a given external service can call a given endpoint in your application. +The service signs their request with a secret key, and you verify the signature before processing the data they sent you. +Used for instance by [Stripe](https://docs.stripe.com/webhooks?verify=verify-manually), and [Shopify](https://shopify.dev/docs/apps/build/webhooks/subscribe/https#step-2-validate-the-origin-of-your-webhook-to-ensure-its-coming-from-shopify). + - **Secure API requests**: Prove that an API request comes from an authorized source + - **Generate secure tokens**: Create temporary access codes for downloads or password resets + - **Protect data**: Ensure data hasn''t been modified during transmission + +### How to use it + +The `sqlpage.hmac` function takes three inputs: +1. **Your data** - The text you want to sign (like a message or request body) +2. **Your secret key** - A password only you know (keep this safe!) +3. **Algorithm** (optional) - The hash algorithm and output format: + - `sha256` (default) - SHA-256 with hexadecimal output + - `sha256-base64` - SHA-256 with base64 output + - `sha512` - SHA-512 with hexadecimal output + - `sha512-base64` - SHA-512 with base64 output + +It returns a signature string. If someone changes even one letter in your data, the signature will be completely different. + +### Example: Verify a Webhooks signature + +When Shopify sends you a webhook (like when someone places an order), it includes a signature. Here''s how to verify it''s really from Shopify. +This supposes you store the secret key in an [environment variable](https://en.wikipedia.org/wiki/Environment_variable) named `WEBHOOK_SECRET`. + +```sql +SET body = sqlpage.request_body(); +SET secret = sqlpage.environment_variable(''WEBHOOK_SECRET''); +SET expected_signature = sqlpage.hmac($body, $secret, ''sha256''); +SET actual_signature = sqlpage.header(''X-Webhook-Signature''); + +-- redirect to an error page and stop execution if the signature does not match +SELECT + ''redirect'' as component, + ''/error.sql?err=bad_webhook_signature'' as link +WHERE $actual_signature != $expected_signature OR $actual_signature IS NULL; + +-- If we reach here, the signature is valid - process the order +INSERT INTO orders (order_data) VALUES ($body); + +SELECT ''json'' as component, ''jsonlines'' as type; +SELECT ''success'' as status; +``` + +### Example: Time-limited links + +You can create links that will be valid only for a limited time by including a signature in them. +Let''s say we have a `download.sql` page we want to link to, +but we don''t want it to be accessible to anyone who can find the link. +Sign `file_id|expires_at` with a secret. Accept only if not expired and the signature matches. + +#### Generate a signed link + +```sql +SET expires_at = datetime(''now'', ''+1 hour''); +SET token = sqlpage.hmac( + $file_id || ''|'' || $expires_at, + sqlpage.environment_variable(''DOWNLOAD_SECRET''), + ''sha256'' +); +SELECT ''/download.sql?file_id='' || $file_id || ''&expires_at='' || $expires_at || ''&token='' || $token AS link; +``` + +#### Verify the signed link + +```sql +SET expected = sqlpage.hmac( + $file_id || ''|'' || $expires_at, + sqlpage.environment_variable(''DOWNLOAD_SECRET''), + ''sha256'' +); +SELECT ''redirect'' AS component, ''/error.sql?err=expired'' AS link +WHERE $expected != $token OR $token IS NULL OR $expires_at < datetime(''now''); + +-- serve the file +``` + +### Important Security Notes + + - **Keep your secret key safe**: If your secret leaks, anyone can forge signatures and access protected pages + - **The signature is case-sensitive**: Even a single wrong letter means the signature won''t match + - **NULL handling**: Always use `IS DISTINCT FROM`, not `=` to check for hmac matches. + - `SELECT ''redirect'' as component WHERE sqlpage.hmac(...) != $signature` will not redirect if `$signature` is NULL (the signature is absent). + - `SELECT ''redirect'' as component WHERE sqlpage.hmac(...) IS DISTINCT FROM $signature` checks for both NULL and non-NULL values (but is not available in all SQL dialects). + - `SELECT ''redirect'' as component WHERE sqlpage.hmac(...) != $signature OR $signature IS NULL` is the most portable solution. +' + ); + +INSERT INTO + sqlpage_function_parameters ( + "function", + "index", + "name", + "description_md", + "type" + ) +VALUES + ( + 'hmac', + 1, + 'data', + 'The input data to compute the HMAC for. Can be any text string. Cannot be NULL.', + 'TEXT' + ), + ( + 'hmac', + 2, + 'key', + 'The secret key used to compute the HMAC. Should be kept confidential. Cannot be NULL.', + 'TEXT' + ), + ( + 'hmac', + 3, + 'algorithm', + 'The hash algorithm and output format. Optional, defaults to `sha256` (hex output). Supported values: `sha256`, `sha256-base64`, `sha512`, `sha512-base64`. Defaults to `sha256`.', + 'TEXT' + ); \ No newline at end of file diff --git a/examples/official-site/sqlpage/migrations/68_login.sql b/examples/official-site/sqlpage/migrations/68_login.sql new file mode 100644 index 0000000..a545105 --- /dev/null +++ b/examples/official-site/sqlpage/migrations/68_login.sql @@ -0,0 +1,74 @@ +INSERT INTO component(name, icon, description, introduced_in_version) VALUES + ('login', 'password-user', ' +The login component is an authentication form with numerous customization options. +It offers the main functionalities for this type of form. +The user can enter their username and password. +There are many optional attributes such as the use of icons on input fields, the insertion of a link to a page to reset the password, an option for the application to maintain the user''s identity via a cookie. +It is also possible to set the title of the form, display the company logo, or customize the appearance of the form submission button. + +This component should be used in conjunction with other components such as [authentication](component.sql?component=authentication) and [cookie](component.sql?component=cookie). +It does not implement any logic and simply collects the username and password to pass them to the code responsible for authentication. + +A few things to know : +- The form uses the POST method to transmit information to the destination page, +- The user''s username and password are entered into fields with the names `username` and `password`, +- To obtain the values of username and password, you must use the variables `:username` and `:password`, +- When you set the `remember_me_text` property, the variable `:remember` becomes available after form submission to check if the user checked the "remember me" checkbox. +', '0.39.0'); + +INSERT INTO parameter(component, name, description, type, top_level, optional) SELECT 'login', * FROM (VALUES + ('title','Title of the authentication form.','TEXT',TRUE,TRUE), + ('enctype','Form data encoding.','TEXT',TRUE,TRUE), + ('action','An optional link to a target page that will handle the results of the form. ','TEXT',TRUE,TRUE), + ('error_message','An error message to display above the form, typically shown after a failed login attempt.','TEXT',TRUE,TRUE), + ('error_message_md','A markdown error message to display above the form, typically shown after a failed login attempt.','TEXT',TRUE,TRUE), + ('username','Label and placeholder for the user account identifier text field.','TEXT',TRUE,FALSE), + ('password','Label and placeholder for the password field.','TEXT',TRUE,FALSE), + ('username_icon','Icon to display on the left side of the input field, on the same line.','ICON',TRUE,TRUE), + ('password_icon','Icon to display on the left side of the input field, on the same line.','ICON',TRUE,TRUE), + ('image','The URL of an centered image displayed before the title.','URL',TRUE,TRUE), + ('forgot_password_text','A text for the link allowing the user to reset their password. If the text is empty, the link is not displayed.','TEXT',TRUE,TRUE), + ('forgot_password_link','The link to the page allowing the user to reset their password.','TEXT',TRUE,TRUE), + ('remember_me_text','A text for the option allowing the user to request the preservation of their identity. If the text is empty, the option is not displayed.','TEXT',TRUE,TRUE), + ('footer','A text placed at the bottom of the authentication form. If both footer and footer_md are specified, footer takes precedence.','TEXT',TRUE,TRUE), + ('footer_md','A markdown text placed at the bottom of the authentication form. Useful for creating links to other pages (creating a new account, contacting technical support, etc.).','TEXT',TRUE,TRUE), + ('validate','The text to display in the button at the bottom of the form that submits the values.','TEXT',TRUE,TRUE), + ('validate_color','The color of the button at the bottom of the form that submits the values. Omit this property to use the default color.','COLOR',TRUE,TRUE), + ('validate_shape','The shape of the validation button.','TEXT',TRUE,TRUE), + ('validate_outline','A color to outline the validation button.','COLOR',TRUE,TRUE), + ('validate_size','The size of the validation button.','TEXT',TRUE,TRUE) +) x; + +-- Insert example(s) for the component +INSERT INTO example(component, description, properties) +VALUES ( + 'login', + 'Using the main options of the login component + +When the user clicks the "Sign in" button, the form is submitted to the `/examples/show_variables.sql` page. +There, you will have access to the variables: + - `:username`: the username entered by the user + - `:password`: the password entered by the user + - `:remember`: the string "on" if the checkbox was checked, or NULL if it was not checked +', + JSON( + '[ + { + "component": "login", + "action": "/examples/show_variables", + "image": "../assets/icon.webp", + "title": "Please login to your account", + "username": "Username", + "password": "Password", + "username_icon": "user", + "password_icon": "lock", + "forgot_password_text": "Forgot your password?", + "forgot_password_link": "reset_password.sql", + "remember_me_text": "Remember me", + "footer_md": "Don''t have an account? [Register here](register.sql)", + "validate": "Sign in" + } + ]' + ) + ), + ('login', 'Most basic login form', JSON('[{"component": "login"}]')); diff --git a/examples/official-site/sqlpage/migrations/69_blog_performance_guide.sql b/examples/official-site/sqlpage/migrations/69_blog_performance_guide.sql new file mode 100644 index 0000000..6dcdf11 --- /dev/null +++ b/examples/official-site/sqlpage/migrations/69_blog_performance_guide.sql @@ -0,0 +1,284 @@ + +INSERT INTO blog_posts (title, description, icon, created_at, content) +VALUES + ( + 'Performance Guide', + 'Concrete advice on how to make your SQLPage webapp fast', + 'bolt', + '2025-10-31', + ' +# Performance Guide + +SQLPage is [optimized](/performance) +to allow you to create web pages that feel snappy. +This guide contains advice on how to ensure your users never wait +behind a blank screen waiting for your pages to load. + +A lot of the advice here is not specific to SQLPage, but applies +to making SQL queries fast in general. +If you are already comfortable with SQL performance optimization, feel free to jump right to +the second part of the quide: *SQLPage-specific advice*. + +## Make your queries fast + +The best way to ensure your SQLPage webapp is fast is to ensure your +database is well managed and your SQL queries are well written. +We''ll go over the most common database performance pitfalls so that you know how to avoid them. + +### Choose the right database schema + +#### Normalize (but not too much) + +Your database schema should be [normalized](https://en.wikipedia.org/wiki/Database_normalization): +one piece of information should be stored in only one place in the database. +This is a good practice that will not only make your queries faster, +but also make it impossible to store incoherent data. +You should use meaningful natural [primary keys](https://en.wikipedia.org/wiki/Primary_key) for your tables +and resort to surrogate keys (such as auto-incremented integer ids) only when the data is not naturally keyed. +Relationships between tables should be explicitly represented by [foreign keys](https://en.wikipedia.org/wiki/Foreign_key). + +```sql +-- Products table, naturally keyed by catalog_number +CREATE TABLE product ( + catalog_number VARCHAR(20) PRIMARY KEY, + name TEXT NOT NULL, + price DECIMAL(10,2) NOT NULL +); + +-- Sales table: natural key = (sale_date, store_id, transaction_number) +-- composite primary key used since no single natural attribute alone uniquely identifies a sale +CREATE TABLE sale ( + sale_date DATE NOT NULL, + store_id VARCHAR(10) NOT NULL, + transaction_number INT NOT NULL, + product_catalog_number VARCHAR(20) NOT NULL, + quantity INT NOT NULL CHECK (quantity > 0), + PRIMARY KEY (sale_date, store_id, transaction_number), + FOREIGN KEY (product_catalog_number) REFERENCES product(catalog_number), + FOREIGN KEY (store_id) REFERENCES store(store_id) +); +``` + +Always use foreign keys instead of trying to store redundant data such as store names in the sales table. + +This way, when you need to display the list of stores in your application, you don''t have to +run a slow `select distinct store from sales`, that would have to go through your millions of sales +(*even if you have an index on the store column*), you just query the tiny `stores` table directly. + +You also need to use the right [data types](https://en.wikipedia.org/wiki/Data_type) for your columns, +otherwise you will waste a lot of space and time converting data at query time. +See [postgreSQL data types](https://www.postgresql.org/docs/current/datatype.html), +[MySQL data types](https://dev.mysql.com/doc/refman/8.0/en/data-types.html), +[Microsoft SQL Server data types](https://learn.microsoft.com/en-us/sql/t-sql/data-types/data-types-transact-sql?view=sql-server-ver16), +[SQLite data types](https://www.sqlite.org/datatype3.html). + +[Denormalization](https://en.wikipedia.org/wiki/Denormalization) can be introduced +only after you have already normalized your data, and is often not required at all. + +### Use views + +Querying normalized views can be cumbersome. +`select store_name, sum(paid_eur) from sale group by store_name` +is more readable than + +```sql +select store.name, sum(sale.paid_eur) +from sales + inner join stores on sale.store_id = store.store_id +group by store_name +``` + +To work around that, you can create views that contain +useful table joins so that you do not have to duplicate them in all your queries: + +```sql +create view enriched_sales as +select sales.sales_eur, sales.client_id, store.store_name +from sales +inner join store +``` + +#### Materialized views + +Some analytical queries just have to compute aggregated statistics over large quantities of data. +For instance, you might want to compute the total sales per store, or the total sales per product. +These queries are slow to compute when there are many rows, and you might not want to run them on every request. +You can use [materialized views](https://en.wikipedia.org/wiki/Materialized_view) to cache the results of these queries. +Materialized views are views that are stored as regular tables in the database. + +Depending on the database, you might have to refresh the materialized view manually. +You can either refresh the view manually from inside your sql pages when you detect they are outdated, +or write an external script to refresh the view periodically. + +```sql +create materialized view total_sales_per_store as +select store_name, sum(sales_eur) as total_sales +from sales +group by store_name; +``` + +### Use database indices + +When a query on a large table uses non-primary column in a `WHERE`, `GROUP BY`, `ORDER BY`, or `JOIN`, +you should create an [index](https://en.wikipedia.org/wiki/Database_index) on that column. +When multiple columns are used in the query, you should create a composite index on those columns. +When creating a composite index, the order of the columns is important. +The most frequently used columns should be first. + +```sql +create index idx_sales_store_date on sale (store_id, sale_date); -- useful for queries that filter by "store" or by "store and date" +create index idx_sales_product_date on sale (product_id, sale_date); +create index idx_sales_store_product_date on sale (store_id, product_id, sale_date); +``` + +Indexes are updated automatically when the table is modified. +They slow down the insertion and deletion of rows in the table, +but speed up the retrieval of rows in queries that use the indexed columns. + +### Query performance debugging + +When a query is slow, you can use the `EXPLAIN` keyword to see how the database will execute the query. +Just add `EXPLAIN` before the query you want to analyze. + +On PostgreSQL, you can use a tool like [explain.dalibo.com](https://explain.dalibo.com/) to visualize the query plan. + +What to look for: + - Are indexes used? You should see references to the indices you created. + - Are full table scans used? Large tables should never be scanned. + - Are expensive operations used? Such as sorting, hashing, bitmap index scans, etc. + - Are operations happening in the order you expected them to? Filtering large tables should come first. + +### Vacuum your database regularly + +On PostgreSQL, you can use the [`VACUUM`](https://www.postgresql.org/docs/current/sql-vacuum.html) command to garbage-collect and analyze a database. + +On MySQL, you can use the [`OPTIMIZE TABLE`](https://dev.mysql.com/doc/refman/8.0/en/optimize-table.html) command to reorganize it on disk and make it faster. +On Microsoft SQL Server, you can use the [`DBCC DBREINDEX`](https://learn.microsoft.com/en-us/sql/t-sql/database-console-commands/dbcc-dbreindex-transact-sql?view=sql-server-ver17) command to rebuild the indexes. +On SQLite, you can use the [`VACUUM`](https://www.sqlite.org/lang_vacuum.html) command to garbage-collect and analyze the database. + +### Use the right database engine + +If the amount of data you are working with is very large, does not change frequently, and you need to run complex queries on it, +you could use a specialized analytical database such as [ClickHouse](https://clickhouse.com/) or [DuckDB](https://duckdb.org/). +Such databases can be used with SQLPage by using their [ODBC](https://en.wikipedia.org/wiki/Open_Database_Connectivity) drivers. + +### Database-specific performance recommendations + + - [PostgreSQL "Performance Tips"](https://www.postgresql.org/docs/current/performance-tips.html) + - [MySQL optimization guide](https://dev.mysql.com/doc/refman/8.0/en/optimization.html) + - [Microsoft SQL Server "Monitor and Tune for Performance"](https://learn.microsoft.com/en-us/sql/relational-databases/performance/monitor-and-tune-for-performance?view=sql-server-ver17) + - [SQLite query optimizer overview](https://www.sqlite.org/optoverview.html) + +## SQLPage-specific advice + +The best way to make your SQLPage webapp fast is to make your queries fast. +Sometimes, you just don''t have control over the database, and have to run slow queries. +This section will help you minimize the impact to your users. + +### Order matters + +SQLPage executes the queries in your `.sql` files in order. +It does not start executing a query before the previous one has returned all its results. +So, if you have to execute a slow query, put it as far down in the page as possible. + +#### No heavy computation before the shell + +Every user-facing page in a SQLPage site has a [shell](/components?component=shell). + +The first queries in any sql file (all the ones that come before the [shell](/components?component=shell)) +are executed before any data has been sent to the user''s browser. +During that time, the user will see a blank screen. +So, ensure your shell comes as early as possible, and does not require any heavy computation. +If you can make your shell entirely static (independent of the database), do so, +and it will be rendered before SQLPage even finishes acquiring a database connection. + +#### Set variables just above their first usage + +For the reasons explained above, you should avoid defining all variables at the top of your sql file. +Instead, define them just above their first usage. + +### Avoid recomputing the same data multiple times + +Often, a single page will require the same pieces of data in multiple places. +In this case, avoid recomputing it on every use inside the page. + +#### Reusing a single database record + +When that data is small, store it in a sqlpage variable as JSON and then +extract the data you need using [json operations](/blog.sql?post=JSON%20in%20SQL%3A%20A%20Comprehensive%20Guide). + +```sql +set product = ( + select json_object(''name'', name, ''price'', price) -- in postgres, you can simply use row_to_json(product) + from products where id = $product_id +); + +select ''alert'' as component, ''Product'' as title, $product->>''name'' as description; +``` + +#### Reusing a large query result set + +You may have a page that lets the user filter a large dataset by many different criteria, +and then displays multiple charts and tables based on the filtered data. + +In this case, store the filtered data in a temporary table and then reuse it in multiple places. + +```sql +drop table if exists filtered_products; +create temporary table filtered_products as +select * from products where + ($category is null or category = $category) and + ($manufacturer is null or manufacturer = $manufacturer); + +select ''alert'' as component, count(*) || '' products'' as title +from filtered_products; + +select ''list'' as component; +select name as title from filtered_products; +``` + +### Reduce the number of queries + +Each query you execute has an overhead of at least the time it takes to send a packet back and forth +between SQLPage and the database. +When it''s possible, combine multiple queries into a single one, possibly using +[`UNION ALL`](https://en.wikipedia.org/wiki/Set_operations_(SQL)#UNION_operator). + +```sql +select ''big_number'' as component; + +with stats as ( + select count(*) as total, avg(price) as average_price from filtered_products +) +select ''count'' as title, stats.total as value from stats +union all +select ''average price'' as title, stats.average_price as value from stats; +``` + +### Lazy loading + +Use the [card](/component?component=card) and [modal](/component?component=modal) components +with the `embed` attribute to load data lazily. +Lazy loaded content is not sent to the user''s browser when the page initially loads, +so it does not block the initial rendering of the page and provides a better experience for +data that might be slow to load. + +### Database connections + +SQLPage uses connection pooling: it keeps multiple database connections opened, +and reuses them for consecutive requests. When it does not receive requests for a long time, +it closes idle connection. When it receives many requests, it opens new connection, +but never more than the value specified by `max_database_pool_connections` in its +[configuration](https://github.com/sqlpage/SQLPage/blob/main/configuration.md). +You can increase the value of that parameter if your website has many concurrent users and your +database is configured to allow opening many simultaneous connections. + +### SQLPage performance debugging + +When `environment` is set to `development` in its [configuration](https://github.com/sqlpage/SQLPage/blob/main/configuration.md), +SQLPage will include precise measurement of the time it spends in each of the steps it has to go through before starting to send data +back to the user''s browser. You can visualize that performance data in your browser''s network inspector. + +You can set the `RUST_LOG` environment variable to `sqlpage=debug` to make SQLPage +print detailed messages associated with precise timing for everything it does. +'); diff --git a/examples/official-site/sqlpage/migrations/70_pagination.sql b/examples/official-site/sqlpage/migrations/70_pagination.sql new file mode 100644 index 0000000..5bb50d0 --- /dev/null +++ b/examples/official-site/sqlpage/migrations/70_pagination.sql @@ -0,0 +1,252 @@ +INSERT INTO component(name, icon, description, introduced_in_version) VALUES + ('pagination', 'sailboat-2', ' +Navigation links to go to the first, previous, next, or last page of a dataset. +Useful when data is divided into pages, each containing a fixed number of rows. + +This component only handles the display of pagination. +**Your sql queries are responsible for filtering data** based on the page number passed as a URL parameter. + +This component is typically used in conjunction with a [table](?component=table), +[list](?component=list), or [card](?component=card) component. + +The pagination component displays navigation buttons (first, previous, next, last) customizable with text or icons. + +For large numbers of pages, an offset can limit the visible page links. + +A minimal example of a SQL query that uses the pagination would be: +```sql +select ''table'' as component; +select * from my_table limit 100 offset $offset; + +select ''pagination'' as component; +with recursive pages as ( + select 0 as offset + union all + select offset + 100 from pages + where offset + 100 < (select count(*) from my_table) +) +select + (offset/100+1) as contents, + sqlpage.link(sqlpage.path(), json_object(''offset'', offset)) as link, + offset = coalesce(cast($offset as integer), 0) as active +from pages; +``` + +For more advanced usage, the [pagination guide](blog.sql?post=How+to+use+the+pagination+component) provides a complete tutorial. +', '0.40.0'); + +INSERT INTO parameter(component, name, description, type, top_level, optional) SELECT 'pagination', * FROM (VALUES + -- Top-level parameters + ('first_link','A target URL to which the user should be directed to get to the first page. If none, the link is not displayed.','URL',TRUE,TRUE), + ('previous_link','A target URL to which the user should be directed to get to the previous page. If none, the link is not displayed.','URL',TRUE,TRUE), + ('next_link','A target URL to which the user should be directed to get to the next page. If none, the link is not displayed.','URL',TRUE,TRUE), + ('last_link','A target URL to which the user should be directed to get to the last page. If none, the link is not displayed.','URL',TRUE,TRUE), + ('first_title','The text displayed on the button to go to the first page.','TEXT',TRUE,TRUE), + ('previous_title','The text displayed on the button to go to the previous page.','TEXT',TRUE,TRUE), + ('next_title','The text displayed on the button to go to the next page.','TEXT',TRUE,TRUE), + ('last_title','The text displayed on the button to go to the last page.','TEXT',TRUE,TRUE), + ('first_disabled','disables the button to go to the first page.','BOOLEAN',TRUE,TRUE), + ('previous_disabled','disables the button to go to the previous page.','BOOLEAN',TRUE,TRUE), + ('next_disabled','Disables the button to go to the next page.','BOOLEAN',TRUE,TRUE), + ('last_disabled','disables the button to go to the last page.','BOOLEAN',TRUE,TRUE), + ('outline','Whether to use outline version of the pagination.','BOOLEAN',TRUE,TRUE), + ('circle','Whether to use circle version of the pagination.','BOOLEAN',TRUE,TRUE), + -- Item-level parameters (for each page) + ('contents','Page number.','INTEGER',FALSE,FALSE), + ('link','A target URL to which the user should be redirected to view the requested page of data.','URL',FALSE,TRUE), + ('offset','Whether to use offset to show only a few pages at a time. Usefull if the count of pages is too large. Defaults to false','BOOLEAN',FALSE,TRUE), + ('active','Whether the link is active or not. Defaults to false.','BOOLEAN',FALSE,TRUE) +) x; + + +-- Insert example(s) for the component +INSERT INTO example(component, description, properties) +VALUES ( + 'pagination', + 'This is an extremely simple example of a pagination component that displays only the page numbers, with the first page being the current page.', + JSON( + '[ + { + "component": "pagination" + }, + { + "contents": 1, + "link": "?component=pagination&page=1", + "active": true + }, + { + "contents": 2, + "link": "?component=pagination&page=2" + }, + { + "contents": 3, + "link": "?component=pagination&page=3" + } + ]' + ) + ), + ( + 'pagination', + 'The ouline style adds a rectangular border to each navigation link.', + JSON( + '[ + { + "component": "pagination", + "outline": true + }, + { + "contents": 1, + "link": "?component=pagination&page=1", + "active": true + }, + { + "contents": 2, + "link": "?component=pagination&page=2" + }, + { + "contents": 3, + "link": "?component=pagination&page=3" + } + ]' + ) + ), + ( + 'pagination', + 'The circle style adds a circular border to each navigation link.', + JSON( + '[ + { + "component": "pagination", + "circle": true + }, + { + "contents": 1, + "link": "?component=pagination&page=1", + "active": true + }, + { + "contents": 2, + "link": "?component=pagination&page=2" + }, + { + "contents": 3, + "link": "?component=pagination&page=3" + } + ]' + ) + ), + ( + 'pagination', + 'The following example implements navigation links that can be enabled or disabled as needed. Since a navigation link does not appear if no link is assigned to it, you must always assign a link to display it as disabled.', + JSON( + '[ + { + "component": "pagination", + "first_link": "?component=pagination", + "first_disabled": true, + "previous_link": "?component=pagination", + "previous_disabled": true, + "next_link": "#?page=2", + "last_link": "#?page=3" + + }, + { + "contents": 1, + "link": "?component=pagination&page=1", + "active": true + }, + { + "contents": 2, + "link": "?component=pagination&page=2" + }, + { + "contents": 3, + "link": "?component=pagination&page=3" + } + ]' + ) + ), + ( + 'pagination', + 'Instead of using icons, you can apply text to the navigation links.', + JSON( + '[ + { + "component": "pagination", + "first_title": "First", + "last_title": "Last", + "previous_title": "Previous", + "next_title": "Next", + "first_link": "?component=pagination", + "first_disabled": true, + "previous_link": "?component=pagination", + "previous_disabled": true, + "next_link": "#?page=2", + "last_link": "#?page=3" + + }, + { + "contents": 1, + "link": "?component=pagination&page=1", + "active": true + }, + { + "contents": 2, + "link": "?component=pagination&page=2" + }, + { + "contents": 3, + "link": "?component=pagination&page=3" + } + ]' + ) + ), + ( + 'pagination', + 'If you have a large number of pages to display, you can use an offset to represent a group of pages.', + JSON( + '[ + { + "component": "pagination", + "first_link": "#?page=1", + "previous_link": "#?page=3", + "next_link": "#?page=4", + "last_link": "#?page=99" + + }, + { + "contents": 1, + "link": "?component=pagination&page=1" + }, + { + "contents": 2, + "link": "?component=pagination&page=2" + }, + { + "contents": 3, + "link": "?component=pagination&page=3" + }, + { + "contents": 4, + "link": "?component=pagination&page=4", + "active": true + }, + { + "contents": 5, + "link": "?component=pagination&page=5" + }, + { + "contents": 6, + "link": "?component=pagination&page=6" + }, + { + "offset": true + }, + { + "contents": 99, + "link": "?component=pagination&page=99" + }, + ]' + ) + ); + \ No newline at end of file diff --git a/examples/official-site/sqlpage/migrations/71_blog_pagination.sql b/examples/official-site/sqlpage/migrations/71_blog_pagination.sql new file mode 100644 index 0000000..859b9e8 --- /dev/null +++ b/examples/official-site/sqlpage/migrations/71_blog_pagination.sql @@ -0,0 +1,163 @@ + +INSERT INTO blog_posts (title, description, icon, created_at, content) +VALUES + ( + 'How to use the pagination component', + 'A tutorial for using the pagination component', + 'sailboat-2', + '2025-11-10', + ' +# How to use the pagination component + +To display a large number of records from a database, it is often practical to split these data into pages. The user can thus navigate from one page to another, as well as directly to the first or last page. With SQLPage, it is possible to perform these operations using the pagination component. + +This component offers many options, and I recommend consulting its documentation before proceeding with the rest of this tutorial. + +Of course, this component only handles its display and does not implement any logic for data processing or state changes. In this tutorial, we will implement a complete example of using the pagination component with a SQLite database, but the code should work without modification (or with very little modification) with any relational database management system (RDBMS). + +> This article serves as a tutorial on the pagination component, rather than an advanced guide on paginated data retrieval from a database. The document employs a straightforward approach using the LIMIT and OFFSET instructions. This approach is interesting only for datasets that are big enough not to be realistically loadable on a single webpage, yet small enough for being queryable with OFFSET...LIMIT. + +## Initialization + +We first need to define two constants that indicate the maximum number of rows per page and the maximum number of pages that the component should display. + +``` +SET MAX_RECORD_PER_PAGE = 10; +SET MAX_PAGES = 10; +``` + +Now, we need to know the number of rows present in the table to be displayed. We can then calculate the number of pages required. + +``` +SET records_count = (SELECT COUNT(*) FROM album); +SET pages_count = (CAST($records_count AS INTEGER) / CAST($MAX_RECORD_PER_PAGE AS INTEGER)); +``` + +It is possible that the number of rows in the table is greater than the estimated number of pages multiplied by the number of rows per page. In this case, it is necessary to add an additional page. + +``` +SET pages_count = ( + CASE + WHEN MOD(CAST($records_count AS INTEGER),CAST($MAX_RECORD_PER_PAGE AS INTEGER)) = 0 THEN $pages_count + ELSE (CAST($pages_count AS INTEGER) + 1) + END +); +``` + +We will need to transmit the page number to be displayed in the URL using the `page` parameter. We do the same for the number of the first page (`idx_page`) appearing at the left end of the pagination component. + +![Meaning of URL parameters](blog/pagination.png) + + +If the page number or index is not present in the URL, the value of 1 is applied by default. + +``` +SET page = COALESCE($page,1); +SET idx_page = COALESCE($idx_page,1); +``` + +## Read the data + +We can now read and display the data based on the active page. To do this, we simply use a table component. + +``` +SELECT + ''table'' as component +SELECT + user_id AS id, + last_name AS "Last name", + first_name AS "First name" +FROM + users +LIMIT CAST($MAX_RECORD_PER_PAGE AS INTEGER) +OFFSET (CAST($page AS INTEGER) - 1) * CAST($MAX_RECORD_PER_PAGE AS INTEGER); +``` + +The SQL LIMIT clause allows us to not read more rows than the maximum allowed for a page. With the SQL OFFSET clause, we specify from which row the data is selected. + +On each HTML page load, the table content will be updated based on the `page` and `idx_page` variables, whose values will be extracted from the URL + +## Set up the pagination component + +Now, we need to set up the parameters that will be included in the URL for the buttons to navigate to the previous or next page. + +If the user wants to view the previous page and the current page is not the first one, the value of the `page` variable is decremented. The same applies to `idx_page`, which is decremented if its value does not correspond to the first page. + +``` +SET previous_parameters = ( + CASE + WHEN CAST($page AS INTEGER) > 1 THEN + json_object( + ''page'', (CAST($page AS INTEGER) - 1), + ''idx_page'', (CASE + WHEN CAST($idx_page AS INTEGER) > 1 THEN (CAST($idx_page AS INTEGER) - 1) + ELSE $idx_page + END) + ) + ELSE json_object() END +); +``` + +The logic is quite similar for the URL to view the next page. First, it is necessary to verify that the user is not already on the last page. Then, the `page` variable can be incremented and the `idx_page` variable updated. + +``` +SET next_parameters = ( + CASE + WHEN CAST($page AS INTEGER) < CAST($pages_count AS INTEGER) THEN + json_object( + ''page'', (CAST($page AS INTEGER) + 1), + ''idx_page'', (CASE + WHEN CAST($idx_page AS INTEGER) < (CAST($pages_count AS INTEGER) - CAST($MAX_PAGES AS INTEGER) + 1) THEN (CAST($idx_page AS INTEGER) + 1) + ELSE $idx_page + END) + ) + ELSE json_object() END +); +``` + +We can now add the pagination component, which is placed below the table displaying the data. All the logic for managing the buttons is entirely handled in SQL: +- the buttons to access the first or last page, +- the buttons to view the previous or next page, +- the enabling or disabling of these buttons based on the context. + +``` +SELECT + ''pagination'' AS component, + (CAST($page AS INTEGER) = 1) AS first_disabled, + (CAST($page AS INTEGER) = 1) AS previous_disabled, + (CAST($page AS INTEGER) = CAST($pages_count AS INTEGER)) AS next_disabled, + (CAST($page AS INTEGER) = CAST($pages_count AS INTEGER)) AS last_disabled, + sqlpage.link(sqlpage.path(), json_object(''page'', 1, ''idx_page'', 1)) as first_link, + sqlpage.link(sqlpage.path(), $previous_parameters) AS previous_link, + sqlpage.link(sqlpage.path(), $next_parameters) AS next_link, + sqlpage.link( + sqlpage.path(), + json_object(''page'', $pages_count, ''idx_page'', ( + CASE + WHEN (CAST($pages_count AS INTEGER) <= CAST($MAX_PAGES AS INTEGER)) THEN 1 + ELSE (CAST($pages_count AS INTEGER) - CAST($MAX_PAGES AS INTEGER) + 1) + END) + ) + ) AS last_link, + TRUE AS outline; +``` + +The final step is to generate the page numbers based on the number of pages and the index of the first page displayed to the left of the component. To do this, we use a recursive CTE query. + +``` +WITH RECURSIVE page_numbers AS ( + SELECT $idx_page AS number + UNION ALL + SELECT number + 1 + FROM page_numbers + LIMIT CAST($MAX_PAGES AS INTEGER) +) +SELECT + number AS contents, + sqlpage.link(sqlpage.path(), json_object(''page'', number, ''idx_page'', $idx_page)) as link, + (number = CAST($page AS INTEGER)) AS active +FROM page_numbers; +``` + +If the added page matches the content of the `page` variable, the `active` option is set to `TRUE` so that the user knows it is the current page. +'); \ No newline at end of file diff --git a/examples/official-site/sqlpage/migrations/72_set_variable.sql b/examples/official-site/sqlpage/migrations/72_set_variable.sql new file mode 100644 index 0000000..1a21365 --- /dev/null +++ b/examples/official-site/sqlpage/migrations/72_set_variable.sql @@ -0,0 +1,60 @@ +INSERT INTO + sqlpage_functions ( + "name", + "introduced_in_version", + "icon", + "description_md" + ) +VALUES + ( + 'set_variable', + '0.40.0', + 'variable', + 'Returns a URL that is the same as the current page''s URL, but with a variable set to a new value. + +This function is useful when you want to create a link that changes a parameter on the current page, while preserving other parameters. + +It is equivalent to `sqlpage.link(sqlpage.path(), json_patch(sqlpage.variables(''get''), json_object(name, value)))`. + +### Example + +Let''s say you have a list of products, and you want to filter them by category. You can use `sqlpage.set_variable` to create links that change the category filter, without losing other potential filters (like a search query or a sort order). + +```sql +select ''button'' as component, ''sm'' as size, ''center'' as justify; +select + category as title, + sqlpage.set_variable(''category'', category) as link, + case when $category = category then ''primary'' else ''secondary'' end as color +from categories; +``` + +### Parameters + - `name` (TEXT): The name of the variable to set. + - `value` (TEXT): The value to set the variable to. If `NULL` is passed, the variable is removed from the URL. +' + ); + +INSERT INTO + sqlpage_function_parameters ( + "function", + "index", + "name", + "description_md", + "type" + ) +VALUES + ( + 'set_variable', + 1, + 'name', + 'The name of the variable to set.', + 'TEXT' + ), + ( + 'set_variable', + 2, + 'value', + 'The value to set the variable to.', + 'TEXT' + ); diff --git a/examples/official-site/sqlpage/migrations/73_blog_tracing.sql b/examples/official-site/sqlpage/migrations/73_blog_tracing.sql new file mode 100644 index 0000000..fda9d9e --- /dev/null +++ b/examples/official-site/sqlpage/migrations/73_blog_tracing.sql @@ -0,0 +1,175 @@ +INSERT INTO blog_posts (title, description, icon, created_at, content) +VALUES + ( + 'Tracing SQLPage with OpenTelemetry and Grafana', + 'How to inspect requests, SQL queries, and database wait time with distributed tracing', + 'route-2', + '2026-03-09', + ' +# Tracing SQLPage with OpenTelemetry and Grafana + +When a page is slow, a log line telling you that the request took 1.8 seconds is only the start of the investigation. What you usually want to know next is where that time went: + +- Did the request wait for a database connection? +- Which SQL file was executed? +- Which query took the longest? +- Did the delay start in SQLPage, in the reverse proxy, or in the database? + +SQLPage now supports [OpenTelemetry](https://opentelemetry.io/), the standard way to emit distributed traces. Combined with Grafana, Tempo, Loki, and an OpenTelemetry collector, this gives you a detailed timeline of each request and lets you jump directly from logs to traces. + +If you want a ready-to-run demo, see the [OpenTelemetry + Grafana example](https://github.com/sqlpage/SQLPage/tree/main/examples/telemetry), which this article is based on. + +## What tracing gives you + +With tracing enabled, one HTTP request becomes a tree of timed operations called spans. In a typical SQLPage app, you will see something like: + +```text +[nginx] GET /todos + └─ [sqlpage] GET /todos + └─ [sqlpage] SQL website/todos.sql + ├─ db.pool.acquire + └─ db.query +``` + +This is immediately useful for: + +- Debugging slow pages by seeing exactly which query consumed the time +- Detecting connection pool pressure by measuring time spent in `db.pool.acquire` +- Following one request end-to-end from the reverse proxy to SQLPage to PostgreSQL + +Tracing is especially helpful in SQLPage because one request often maps cleanly to one SQL file. That makes traces easy to interpret even when you are not used to application performance tooling. + +## The easiest way to try it + +The simplest way to explore tracing is to run +[the example shipped with SQLPage](https://github.com/sqlpage/SQLPage/tree/main/examples/telemetry). +After downloading or cloning the example, run this from the example''s folder: + +```bash +docker compose up --build +``` + +That stack starts: + +- nginx as the reverse proxy +- SQLPage +- PostgreSQL +- an OpenTelemetry collector +- Grafana Tempo for traces +- Grafana Loki for logs +- Promtail for log shipping +- Grafana for visualization + +Then: + +1. Open [http://localhost](http://localhost) and use the sample todo application. +2. Open [http://localhost:3000](http://localhost:3000) to access Grafana. +3. Inspect recent traces and logs from the default dashboard. +4. Open a trace to see the full span waterfall for a single request. + +This setup is useful both as a demo and as a reference architecture for production deployments. + +## Enabling tracing in SQLPage + +Tracing is built into SQLPage. There is no plugin to install and no SQLPage-specific tracing configuration file to write. + +You only need to set standard OpenTelemetry environment variables before starting SQLPage: + +```bash +export OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4318" +export OTEL_SERVICE_NAME="sqlpage" +sqlpage +``` + +The `OTEL_EXPORTER_OTLP_ENDPOINT` variable tells SQLPage where to send traces. +The `OTEL_SERVICE_NAME` variable controls how the service appears in your tracing backend. + +If `OTEL_EXPORTER_OTLP_ENDPOINT` is not set, SQLPage falls back to normal logging and tracing stays disabled. + +## What you will see in the trace + +The most useful spans emitted by SQLPage are: + +- The HTTP request span, with attributes such as method, path, status code, and user agent +- The SQL file execution span, showing which `.sql` file handled the request +- The `db.pool.acquire` span, showing time spent waiting for a database connection +- The `db.query` span, containing the SQL statement and database system + +In practice, that means you can answer questions like: + +- Is the page slow because the SQL itself is slow? +- Is the request queued because the connection pool is exhausted? +- Is the delay happening before SQLPage even receives the request? + +This is much more actionable than a single request duration number. + +## Logs and traces together + +Tracing is even more useful when logs and traces are connected. + +In the example stack, SQLPage writes request access logs to stdout and diagnostic logs to stderr. The OpenTelemetry Collector forwards both streams to Loki, and Grafana lets you move from a log line to the matching trace using the trace id. This makes it possible to start from an error log and immediately inspect the full request timeline. + +That workflow is often the difference between guessing and knowing. + +## PostgreSQL correlation + +SQLPage also propagates trace context to PostgreSQL through the connection `application_name`. +This makes it possible to correlate live PostgreSQL activity or database logs with the trace that triggered it. + +For example, inspecting `pg_stat_activity` can show which trace is attached to a running query: + +```sql +SELECT application_name, query, state +FROM pg_stat_activity; +``` + +If you also include `%a` in PostgreSQL''s `log_line_prefix`, your database logs can contain the same trace context. + +## A practical debugging example + +Suppose users report that a page occasionally becomes slow under load. + +With tracing enabled, you might see that: + +- the HTTP span is long +- the SQL file execution span is also long +- the `db.query` span is short +- but `db.pool.acquire` takes several hundred milliseconds + +That immediately tells you the database query itself is not the problem. The real issue is contention on the connection pool. You can then increase `max_database_pool_connections`, reduce concurrent load, or review long-running requests that keep connections busy. + +Without tracing, this kind of diagnosis usually requires guesswork. + +## Deployment options + +The example uses Grafana Tempo and Loki, but SQLPage is not tied to a single backend. Because it emits standard OTLP traces, you can also send data to: + +- Jaeger +- Grafana Cloud +- Datadog +- Honeycomb +- New Relic +- Axiom + +In small setups, SQLPage can often send traces directly to the backend. In larger deployments, placing an OpenTelemetry collector in the middle is usually better because it centralizes routing, batching, and authentication. + +## When to enable tracing + +Tracing is particularly valuable when: + +- you are running SQLPage behind a reverse proxy +- several SQL files participate in user-facing workflows +- you want to understand production latency, not just local development behavior +- you need a shared debugging tool for developers and operators + +If your application is already important enough to monitor, it is important enough to trace. + +## Conclusion + +SQLPage already makes the application logic easy to inspect because it lives in SQL files. Tracing extends that visibility to runtime behavior. + +By enabling OpenTelemetry and connecting SQLPage to Grafana, you can see not just that a request was slow, but why it was slow, where the time was spent, and which query or resource caused the delay. + +For a complete working setup, start with the [OpenTelemetry + Grafana example](https://github.com/sqlpage/SQLPage/tree/main/examples/telemetry) and adapt it to your own deployment. +' + ); diff --git a/examples/official-site/sqlpage/migrations/74_regex_match.sql b/examples/official-site/sqlpage/migrations/74_regex_match.sql new file mode 100644 index 0000000..8e05404 --- /dev/null +++ b/examples/official-site/sqlpage/migrations/74_regex_match.sql @@ -0,0 +1,103 @@ +INSERT INTO + sqlpage_functions ( + "name", + "introduced_in_version", + "icon", + "description_md" + ) +VALUES + ( + 'regex_match', + '0.43.0', + 'regex', + 'Matches a text value against a regular expression and returns the capture groups as a JSON object. + +If the text matches the pattern, the result contains one entry for each capture group that matched: +- key `0` contains the full match +- named groups like `(?...)` use their name as the JSON key +- unnamed groups like `( ... )` use their numeric index as a string + +If the text does not match, this function returns `NULL`. + +### Example: custom routing from `404.sql` + +This function is especially useful in a custom [`404.sql` page](/your-first-sql-website/custom_urls.sql), +where you want to turn a dynamic URL into variables your SQL can use. + +For example, suppose you want `/categories/{category}/post/{id}` URLs such as `/categories/sql/post/42`, +but there is no physical `categories/sql/post/42.sql` file on disk. +You can put a `categories/404.sql` file in your project and extract the dynamic parts from the URL: + +#### `categories/404.sql` +```sql +set route = sqlpage.regex_match( + ''/categories/(?\w+)/post/(?\d+)'', + sqlpage.path() +); + +select ''redirect'' as component, ''/404'' as link +where $route is null; + +select ''text'' as component; +select + ''Category: '' || ($route->>''category'') || '' | Post id: '' || ($route->>''id'') as contents; +``` + +If the current path is `/categories/sql/post/42`, `sqlpage.regex_match()` returns: + +```json +{"0":"/categories/sql/post/42","category":"sql","id":"42"} +``` + +You can then use those extracted values to query your database: + +```sql +select title, body +from posts +where category = $route->>''category'' + and id = cast($route->>''id'' as integer); +``` + +### Details + +- Quick regex reminder: + - `\w+` matches one or more "word" characters + - `\d+` matches one or more digits + - `(?...)` creates a named capture group +- Some databases, such as MySQL and MariaDB, treat backslashes specially inside SQL strings. + In those databases, you may need to write `\\w` and `\\d`, or use portable character classes such as `[A-Za-z0-9_]` and `[0-9]` instead. +- In SQLite, PostgreSQL, and some other databases, you can read fields from the returned JSON with `->` and `->>` +- On databases that do not support that syntax, use their JSON extraction function instead, such as `json_extract($route, ''$.category'')` +- For the full regular expression syntax supported by SQLPage, see the Rust `regex` crate documentation: + [regex syntax reference](https://docs.rs/regex/latest/regex/#syntax) +- If the input text is `NULL`, the function returns `NULL` +- If an optional capture group does not match, that key is omitted from the JSON object +- If the regular expression is invalid, SQLPage returns an error + +The returned JSON can then be processed with your database''s JSON functions. +' + ); + +INSERT INTO + sqlpage_function_parameters ( + "function", + "index", + "name", + "description_md", + "type" + ) +VALUES + ( + 'regex_match', + 1, + 'pattern', + 'The regular expression pattern to match against the input text. Named capture groups such as `(?...)` are supported.', + 'TEXT' + ), + ( + 'regex_match', + 2, + 'text', + 'The text to match against the regular expression. Returns `NULL` when this argument is `NULL`.', + 'TEXT' + ); diff --git a/examples/official-site/sqlpage/migrations/999_fts_search_index.sql b/examples/official-site/sqlpage/migrations/999_fts_search_index.sql new file mode 100644 index 0000000..225ff42 --- /dev/null +++ b/examples/official-site/sqlpage/migrations/999_fts_search_index.sql @@ -0,0 +1,32 @@ +CREATE VIRTUAL TABLE documentation_fts USING fts5( + component_name, + component_description, + parameter_name, + parameter_description, + blog_title, + blog_description, + function_name, + function_description, + function_parameter_name, + function_parameter_description, + component_example_description, + component_example_json +); + +INSERT INTO documentation_fts(component_name, component_description) +SELECT name, description FROM component; + +INSERT INTO documentation_fts(component_name, parameter_name, parameter_description) +SELECT component, name, description FROM parameter; + +INSERT INTO documentation_fts(blog_title, blog_description) +SELECT title, description FROM blog_posts; + +INSERT INTO documentation_fts(function_name, function_description) +SELECT name, description_md FROM sqlpage_functions; + +INSERT INTO documentation_fts(function_name, function_parameter_name, function_parameter_description) +SELECT function, name, description_md FROM sqlpage_function_parameters; + +INSERT INTO documentation_fts(component_name, component_example_description, component_example_json) +SELECT component, description, properties FROM example; \ No newline at end of file diff --git a/examples/official-site/sqlpage/migrations/99_shared_id_class_attributes.sql b/examples/official-site/sqlpage/migrations/99_shared_id_class_attributes.sql new file mode 100644 index 0000000..c4f779b --- /dev/null +++ b/examples/official-site/sqlpage/migrations/99_shared_id_class_attributes.sql @@ -0,0 +1,59 @@ +INSERT INTO parameter(component, top_level, name, description, type, optional) +SELECT *, 'id', 'id attribute added to the container in HTML. It can be used to target this item through css or for scrolling to this item through links (use "#id" in link url).', 'TEXT', TRUE +FROM (VALUES + ('alert', TRUE), + ('breadcrumb', TRUE), + ('chart', TRUE), + ('code', TRUE), + ('csv', TRUE), + ('datagrid', TRUE), + ('datagrid', FALSE), + ('hero', TRUE), + ('list', TRUE), + ('list', FALSE), + ('map', TRUE), + ('tab', FALSE), + ('table', TRUE), + ('timeline', TRUE), + ('timeline', FALSE), + ('title', TRUE), + ('tracking', TRUE), + ('text', TRUE), + ('carousel', TRUE), + ('login', TRUE), + ('pagination', TRUE) +); + +INSERT INTO parameter(component, top_level, name, description, type, optional) +SELECT *, 'id', 'id attribute injected as an anchor in HTML. It can be used for scrolling to this item through links (use "#id" in link url). Added in v0.18.0.', 'TEXT', TRUE +FROM (VALUES + ('steps', TRUE) +); + +INSERT INTO parameter(component, top_level, name, description, type, optional) +SELECT *, 'class', 'class attribute added to the container in HTML. It can be used to apply custom styling to this item through css. Added in v0.18.0.', 'TEXT', TRUE +FROM (VALUES + ('alert', TRUE), + ('breadcrumb', TRUE), + ('button', TRUE), + ('card', FALSE), + ('chart', TRUE), + ('code', TRUE), + ('csv', TRUE), + ('datagrid', TRUE), + ('divider', TRUE), + ('form', TRUE), + ('list', TRUE), + ('list', FALSE), + ('map', TRUE), + ('tab', FALSE), + ('table', TRUE), + ('timeline', TRUE), + ('timeline', FALSE), + ('title', TRUE), + ('tracking', TRUE), + ('carousel', TRUE), + ('login', TRUE), + ('pagination', TRUE) +); + diff --git a/examples/official-site/sqlpage/sqlpage.yaml b/examples/official-site/sqlpage/sqlpage.yaml new file mode 100644 index 0000000..8d0cfbe --- /dev/null +++ b/examples/official-site/sqlpage/sqlpage.yaml @@ -0,0 +1,7 @@ +# The documentation site is fully static, so we don't need to persist any data. +database_url: "sqlite::memory:?cache=shared" + +# We have a file upload example, and would like to limit the size of the uploaded files +max_uploaded_file_size: 256000 + +database_connection_acquire_timeout_seconds: 30 diff --git a/examples/official-site/sqlpage/templates/color_swatch.handlebars b/examples/official-site/sqlpage/templates/color_swatch.handlebars new file mode 100644 index 0000000..e27fe78 --- /dev/null +++ b/examples/official-site/sqlpage/templates/color_swatch.handlebars @@ -0,0 +1,20 @@ +
+ {{#each_row}} +
+
+
+
+

{{name}}

+ {{#if hex}} +
{{hex}}
+ {{/if}} + {{#if css_var}} +
{{css_var}}
+ {{/if}} + {{#if description}} +

{{description}}

+ {{/if}} +
+
+ {{/each_row}} +
diff --git a/examples/official-site/sqlpage/templates/shell-home.handlebars b/examples/official-site/sqlpage/templates/shell-home.handlebars new file mode 100644 index 0000000..8fff0ff --- /dev/null +++ b/examples/official-site/sqlpage/templates/shell-home.handlebars @@ -0,0 +1,1517 @@ + + + + + + + SQLPage - SQL websites + + + + + + + + + + + + + + +
+ + +
+

SQLPage + Website + App + Tools + Forms + Maps + Plots + APIs +

+

Instant web interfaces for your database. Free and open-source.

+ Get Started +
+
+ + + + + + +
+
+
+

Build sophisticated tools, easily

+

Turn simple SQL queries into beautiful, dynamic web applications.

+
+
+
+
📈
+

Complete

+

Create navigatable interfaces with + maps, + charts, + tables, + forms, + grids, + dashboards, and more. + Batteries included. +

+
+
+
+

Fast

+

Build fast applications, quickly. You spend one afternoon building your first app. Then it + loads instantly for everyone forever. +

+
+
+
🎯
+

Easy

+

You can teach yourself enough SQL to query and edit a database through SQLPage in a + weekend. Focus on your data, we'll handle optimizations and security.

+
+
+
+
+ +
+
+
+

More scalable than a spreadsheet

+

SQL queries sort, filter, and aggregate millions of rows in milliseconds. No more slow spreadsheet + formulas or memory limitations. Your app remains smooth and responsive + even + as your data grows.

+
+
+ +
+
+
+ +
+
+
+

More dynamic than a dashboard

+

Create multi-page interactive websites with drill-down capabilities. Navigate from summaries to + detailed records. +

+ You should be able to comment, edit, and dive into your data instead of just looking at aggregated + statistics. +

+ Build apps, not dashboards. +

+
+ User creation form, illustrating the ability to create, edit, and delete individual data points, to go beyond simple static dashboards. +
+
+
+ +
+
+
+
Database Compatibility
+

Works with your database

+

SQLPage connects to the database engine you already rely on today. + Keep your data in place and surface it through a + friendly interface that stays in sync. + If you don't have a DB yet, SQLPage comes with a built-in query engine. +

+
+
SQLite built-in
+
MySQL & MariaDB
+
PostgreSQL family
+
Microsoft SQL Server
+
ODBC bridge
+
+
+
+
+
+ Native connectors +
+
SQLite
+
MySQL
+
PostgreSQL
+
Microsoft SQL Server
+
+
+

Wherever your data lives

+

Through ODBC you can plug SQLPage into any warehouses and enterprise engines.

+
+ + + + + + + + +
+
+ No data copy + Streams query results +
+
+
+
+
+ +
+
+
+

The power of the web, without the complexity

+

Do not code, query. Write SQL, get a web app. +

Traditional web programming languages are powerful, but complex. + Using smart opinionated defaults, SQLPage requires 10x less code. +

+ For advanced customization, you can still optionally use HTML/CSS/JS, and + integrate with external programs and APIs.

+
+ Simple Web Development. Just SQL +
+
+ + + + + \ No newline at end of file diff --git a/examples/official-site/sqlpage/templates/typography_sample.handlebars b/examples/official-site/sqlpage/templates/typography_sample.handlebars new file mode 100644 index 0000000..647bcb0 --- /dev/null +++ b/examples/official-site/sqlpage/templates/typography_sample.handlebars @@ -0,0 +1,18 @@ +
+{{#each_row}} +
+ {{#if title}} +
{{title}}
+ {{/if}} +
+ {{sample_text}} +
+ {{#if description}} +
{{description}}
+ {{/if}} + {{#if usage}} +
Use for: {{usage}}
+ {{/if}} +
+{{/each_row}} +
diff --git a/examples/official-site/sqlpage_cover_image.webp b/examples/official-site/sqlpage_cover_image.webp new file mode 100644 index 0000000..5efac87 Binary files /dev/null and b/examples/official-site/sqlpage_cover_image.webp differ diff --git a/examples/official-site/sqlpage_illustration_alien.webp b/examples/official-site/sqlpage_illustration_alien.webp new file mode 100644 index 0000000..7f26458 Binary files /dev/null and b/examples/official-site/sqlpage_illustration_alien.webp differ diff --git a/examples/official-site/sqlpage_illustration_components.webp b/examples/official-site/sqlpage_illustration_components.webp new file mode 100644 index 0000000..4988623 Binary files /dev/null and b/examples/official-site/sqlpage_illustration_components.webp differ diff --git a/examples/official-site/sqlpage_introduction_video.webm b/examples/official-site/sqlpage_introduction_video.webm new file mode 100644 index 0000000..98078f3 Binary files /dev/null and b/examples/official-site/sqlpage_introduction_video.webm differ diff --git a/examples/official-site/sqlpage_social_preview.webp b/examples/official-site/sqlpage_social_preview.webp new file mode 100644 index 0000000..3b46c9d Binary files /dev/null and b/examples/official-site/sqlpage_social_preview.webp differ diff --git a/examples/official-site/sso/index.sql b/examples/official-site/sso/index.sql new file mode 100644 index 0000000..c352d9e --- /dev/null +++ b/examples/official-site/sso/index.sql @@ -0,0 +1,7 @@ +select 'http_header' as component, + 'public, max-age=600, stale-while-revalidate=3600, stale-if-error=86400' as "Cache-Control", + '; rel="canonical"' as "Link"; + +select 'dynamic' as component, properties FROM example WHERE component = 'shell' LIMIT 1; + +select 'text' as component, sqlpage.read_file_as_text('sso/single_sign_on.md') as contents_md, true as article; \ No newline at end of file diff --git a/examples/official-site/sso/single_sign_on.md b/examples/official-site/sso/single_sign_on.md new file mode 100644 index 0000000..d10e6b6 --- /dev/null +++ b/examples/official-site/sso/single_sign_on.md @@ -0,0 +1,161 @@ +# Setting Up Single Sign-On in SQLPage + +When you want to add user authentication to your SQLPage application, you have two main options: + +1. The [authentication component](/component.sql?component=authentication): + A simple username/password system, that you have to manage yourself. +2. **OpenID Connect (OIDC)**: + A single sign-on system that lets users log in with their existing accounts (like Google, Microsoft, or your organization's own identity provider). + +This guide will help you set up single sign-on using OpenID connect with SQLPage quickly. + +## Essential Terms + +- **OIDC** ([OpenID Connect](https://openid.net/developers/how-connect-works/)): The protocol that enables secure login with existing accounts. While it adds some complexity, it's an industry standard that ensures your users' data stays safe. +- **Issuer** (or identity provider): The service that verifies your users' identity (like Google or Microsoft) +- **Identity Token**: A secure message from the issuer containing user information. It is stored as a cookie on the user's computer, and sent with every request after login. SQLPage will redirect all requests that do not contain a valid token to the identity provider's login page. +- **Claim**: A piece of information contained in the token about the user (like their name or email) + +## Quick Setup Guide + +### Choose an OIDC Provider + +Here are the setup guides for +[Google](https://developers.google.com/identity/openid-connect/openid-connect), +[Microsoft Entra ID](https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-register-app), +and [Keycloak](https://www.keycloak.org/getting-started/getting-started-docker) (self-hosted). + +### Register Your Application + +1. Go to your chosen provider's developer console +2. Create a new application +3. Set the redirect URI to `http://localhost:8080/sqlpage/oidc_callback`. (We will change that later when you deploy your site to a hosting provider such as [datapage](https://beta.datapage.app/)). +4. Note down the client ID and client secret + +### Configure SQLPage + +Create or edit `sqlpage/sqlpage.json` to add the following configuration keys: + +```json +{ + "oidc_issuer_url": "https://accounts.google.com", + "oidc_client_id": "your-client-id", + "oidc_client_secret": "your-client-secret", + "host": "localhost:8080" +} +``` + +#### Provider-specific settings +- Google: `https://accounts.google.com` +- Microsoft: `https://login.microsoftonline.com/{tenant}/v2.0`. [Find your value of `{tenant}`](https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-create-new-tenant). +- GitHub: `https://github.com` +- Keycloak: Use [your realm's base url](https://www.keycloak.org/securing-apps/oidc-layers), ending in `/auth/realms/{realm}`. +- For other OIDC providers, you can usually find the issuer URL by + looking for a "discovery document" or "well-known configuration" at an URL that ends with the suffix `/.well-known/openid-configuration`. + Strip the suffix and use it as the `oidc_issuer_url`. + +### Restart SQLPage + +When you restart your SQLPage instance, it should automatically contact +the identity provider, find its login URL, and the public keys that will be used to check the validity of its identity tokens. + +By default, all pages on your website will now require users to log in. + +## Access User Information in Your SQL + +Once you have successfully configured SSO, you can access information +about the authenticated user who is visiting the current page using the following functions: +- [`sqlpage.user_info`](/functions.sql?function=user_info) to access a particular claim about the user such as `name` or `email`, +- [`sqlpage.user_info_token`](/functions.sql?function=user_info_token) to access the entire identity token as json. + +Access user data in your SQL files: + +```sql +select 'text' as component, ' + +Welcome, ' || sqlpage.user_info('name') || '! + +You have visited this site ' || + (select count(*) from page_visits where user=sqlpage.user_info('sub')) || +' times before. +' as contents_md; + +insert into page_visits + (path, user) +values + (sqlpage.path(), sqlpage.user_info('sub')); +``` + +## Restricting authentication to a specific set of pages + +Sometimes, you don't want to protect your entire website with a login, but only a specific section. +You can achieve this by adding the `oidc_protected_paths` option to your `sqlpage.json` file. + +This option takes a list of URL prefixes. If a user requests a page whose address starts with one of these prefixes, they will be required to log in. + +**Example:** Protect only pages in the `/admin` folder. + +```json +{ + "oidc_issuer_url": "https://accounts.google.com", + "oidc_client_id": "your-client-id", + "oidc_client_secret": "your-client-secret", + "host": "localhost:8080", + "oidc_protected_paths": ["/admin"] +} +``` + +In this example, a user visiting `/admin/dashboard.sql` will be prompted to log in, while a user visiting `/index.sql` will not. + +### Creating a public login page + +A common pattern is to have a public home page with a "Login" button that redirects users to a protected area. + +With the configuration above, you can create a public page `login.sql` that is not in a protected path. This page can contain a simple link to a protected resource, for instance `/admin/index.sql`: + +```sql +select 'list' as component, 'actions' as title; +select 'Login' as title, '/admin' as link, 'login' as icon; +``` + +When a non-authenticated user clicks this "Login" link, SQLPage will automatically redirect them to your identity provider's login page. After they successfully authenticate, they will be sent back to the page they originally requested (`/admin/index.sql`). + +## Going to Production + +When deploying to production: + +1. Update the redirect URI in your OIDC provider's settings to: + ``` + https://your-domain.com/sqlpage/oidc_callback + ``` + +2. Update your `sqlpage.json`: + ```json + { + "oidc_issuer_url": "https://accounts.google.com", + "oidc_client_id": "your-client-id", + "oidc_client_secret": "your-client-secret", + "host": "your-domain.com" + } + ``` + +3. If you're using HTTPS (recommended), make sure your `host` setting matches your domain name exactly. + +## Troubleshooting + +### Version Requirements +- OIDC support requires SQLPage **version 0.35 or higher**. Check your version in the logs. + +### Common Configuration Issues +- **Redirect URI Mismatch**: The redirect URI in your OIDC provider settings must exactly match `https://your-domain.com/sqlpage/oidc_callback` (or `http://localhost:8080/sqlpage/oidc_callback` for local development) +- **Invalid Client Credentials**: Double-check your client ID and secret are copied correctly from your OIDC provider +- **Host Configuration**: The `host` setting in `sqlpage.json` must match your application's domain name exactly +- **HTTPS Requirements**: Most OIDC providers require HTTPS in production. Ensure your site is served over HTTPS. +- **Provider Discovery**: If SQLPage fails to discover your provider's configuration, verify the `oidc_issuer_url` is correct and accessible by loading `{oidc_issuer_url}/.well-known/openid-configuration` in your browser. + +### Debugging Tips +- Check SQLPage's logs for detailed error messages. You can enable verbose logging with the `RUST_LOG=trace` environment variable. +- Verify your OIDC provider's logs for authentication attempts +- In production, confirm your domain name matches exactly in both the OIDC provider settings and `sqlpage.json` +- If [using a reverse proxy](/your-first-sql-website/nginx.sql), ensure it's properly configured to handle the OIDC callback path. +- If you have checked everything and you think the bug comes from SQLPage itself, [open an issue on our bug tracker](https://github.com/sqlpage/SQLPage/issues). \ No newline at end of file diff --git a/examples/official-site/style_pricing.css b/examples/official-site/style_pricing.css new file mode 100644 index 0000000..9bb0b1c --- /dev/null +++ b/examples/official-site/style_pricing.css @@ -0,0 +1,17 @@ +/* Ensure all unordered and ordered lists are left-aligned */ +ul, +ol { + text-align: left; /* Aligns bullet points to the left */ + margin-left: 20px; /* Adds indentation for better readability */ + padding-left: 20px; /* Adds space between bullet and text */ +} + +/* Optional: Style for the individual list items */ +li { + margin-bottom: 10px; /* Adds space between list items */ +} + +/* Optional: Ensure the body text is also left-aligned */ +body { + text-align: left; /* Makes sure the overall page is left-aligned */ +} diff --git a/examples/official-site/test.hurl b/examples/official-site/test.hurl new file mode 100644 index 0000000..451aa1b --- /dev/null +++ b/examples/official-site/test.hurl @@ -0,0 +1,72 @@ +GET http://localhost:8080/ +HTTP 200 +[Asserts] +body contains "SQLPage" +body contains "Documentation" +body contains "Get Started" +body not contains "An error occurred" + +GET http://localhost:8080/documentation.sql +HTTP 200 +[Asserts] +body contains "SQLPage v" +body contains "components" +body contains "shell" +body not contains "An error occurred" + +GET http://localhost:8080/component.sql?component=form +HTTP 200 +[Asserts] +body contains "The form component" +body contains "Building forms in SQL" +body not contains "An error occurred" + +GET http://localhost:8080/functions.sql?function=url_encode +HTTP 200 +[Asserts] +body contains "sqlpage.url_encode" +body contains "Parameters" +body not contains "An error occurred" + +GET http://localhost:8080/search.sql?search=form +HTTP 302 +[Asserts] +header "Location" == "/component.sql?component=form" + +GET http://localhost:8080/not-a-real-doc-page +HTTP 404 +[Asserts] +body contains "Not Found" +body not contains "An error occurred" + +GET http://localhost:8080/examples/authentication/ +HTTP 302 +[Asserts] +header "Location" == "login.sql" + +GET http://localhost:8080/examples/authentication/login.sql +HTTP 200 +[Asserts] +body contains "Demo Login Form" +body contains "admin" +body not contains "An error occurred" + +POST http://localhost:8080/examples/authentication/create_session_token.sql +[FormParams] +username: admin +password: admin +HTTP 302 +[Asserts] +header "Location" == "/examples/authentication" + +GET http://localhost:8080/examples/authentication/ +HTTP 200 +[Asserts] +body contains "You are logged in as admin" +body contains "Log out" +body not contains "An error occurred" + +GET http://localhost:8080/examples/authentication/logout.sql +HTTP 302 +[Asserts] +header "Location" == "login.sql" diff --git a/examples/official-site/visual-identity.sql b/examples/official-site/visual-identity.sql new file mode 100644 index 0000000..5427d72 --- /dev/null +++ b/examples/official-site/visual-identity.sql @@ -0,0 +1,266 @@ +select 'http_header' as component, + 'public, max-age=600, stale-while-revalidate=3600, stale-if-error=86400' as "Cache-Control"; + +select 'dynamic' as component, json_patch(json_extract(properties, '$[0]'), json_object( + 'title', 'Visual Identity - SQLPage', + 'css', '/assets/highlightjs-and-tabler-theme.css', + 'theme', 'dark' +)) as properties +FROM example WHERE component = 'shell' LIMIT 1; + +select 'text' as component, 'Visual Identity Guide' as title, ' +This guide defines the visual identity of SQLPage for consistent brand representation. +' as contents_md; + +select 'text' as component, 'Personality' as title, ' +**Playful yet professional**: Approachable, innovative, confident, energetic, reliable, creative. +' as contents_md; + +select 'text' as component, 'Logo' as title, ' +Primary logo: `/assets/icon.webp` + +**Usage**: +- Minimum size: 48px height +- Clear space: 50% of logo height +- Do not distort, rotate, or modify +- Works on dark and light backgrounds +' as contents_md; + +select 'html' as component, ' +
+ SQLPage Logo +
+' as html; + +select 'button' as component; +select + 'Download Logo' as title, + '/assets/icon.webp' as link, + 'icon.webp' as download, + 'blue' as color, + 'download' as icon; + +select 'text' as component, 'Colors' as title, ' +Color palette extracted directly from the logo and design system. +' as contents_md; + +select 'color_swatch' as component; +select + 'Primary Cyan' as name, + '#37E5EF' as hex, + 'Main logo color - bright cyan' as description; +select + 'Teal Accent' as name, + '#2A9FAF' as hex, + 'Secondary teal from logo' as description; +select + 'Dark Navy' as name, + '#090D19' as hex, + 'Logo background - dark navy' as description; +select + 'Medium Blue' as name, + '#27314C' as hex, + 'Medium blue from logo' as description; +select + 'Blue Gray' as name, + '#304960' as hex, + 'Blue-gray from logo' as description; +select + 'Neutral Gray' as name, + '#4B4E5C' as hex, + 'Neutral gray from logo' as description; +select + 'Light Gray' as name, + '#9FA4AE' as hex, + 'Light gray from logo' as description; +select + 'Primary Background' as name, + '#0a0f1a' as hex, + 'Dark theme foundation' as description; +select + 'Primary Text' as name, + '#f7f7f7' as hex, + 'Main text color' as description; +select + 'White' as name, + '#ffffff' as hex, + 'Headings and emphasis' as description; + +select 'text' as component, 'Gradient' as title, ' +Primary gradient flows from primary cyan (#37E5EF) to teal accent (#2A9FAF). + +Use for buttons, highlights, and important elements. +' as contents_md; + +select 'text' as component, 'Typography' as title, ' +**Primary Font**: Inter + +Use Inter for all digital materials, websites, and presentations. Inter is a modern, highly legible sans-serif typeface designed specifically for user interfaces. + +**Font Source**: [Google Fonts - Inter](https://fonts.google.com/specimen/Inter) + +**Fallback Font Stack**: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif + +If Inter is not available, use the fallback stack in order. +' as contents_md; + +select 'typography_sample' as component; +select + 'Page Title' as title, + 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif' as font_family, + '64px' as font_size, + '800' as font_weight, + '1.1' as line_height, + '#ffffff' as text_color, + '-1px' as letter_spacing, + 'SQLPage Visual Identity' as sample_text, + 'Hero sections, main page titles, presentation title slides' as usage, + 'Bold, impactful text for maximum visual hierarchy' as description; + +select 'typography_sample' as component; +select + 'Section Heading' as title, + 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif' as font_family, + '56px' as font_size, + '700' as font_weight, + '1.2' as line_height, + '#ffffff' as text_color, + 'normal' as letter_spacing, + 'Section Title' as sample_text, + 'Major section breaks, chapter headings, presentation section slides' as usage, + 'Strong but slightly less prominent than page titles' as description; + +select 'typography_sample' as component; +select + 'Subsection Heading' as title, + 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif' as font_family, + '40px' as font_size, + '600' as font_weight, + '1.3' as line_height, + '#ffffff' as text_color, + 'normal' as letter_spacing, + 'Subsection Heading' as sample_text, + 'Card titles, subsection headers, content slide titles' as usage, + 'Clear hierarchy for organizing content' as description; + +select 'typography_sample' as component; +select + 'Body Text' as title, + 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif' as font_family, + '16px' as font_size, + '400' as font_weight, + '1.6' as line_height, + '#f7f7f7' as text_color, + 'normal' as letter_spacing, + 'This is body text used for paragraphs, descriptions, and main content. It should be comfortable to read with adequate spacing between lines.' as sample_text, + 'Paragraphs, descriptions, main content, presentation body text' as usage, + 'Standard reading size with comfortable line spacing' as description; + +select 'typography_sample' as component; +select + 'Small Text' as title, + 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif' as font_family, + '14px' as font_size, + '400' as font_weight, + '1.5' as line_height, + '#999999' as text_color, + 'normal' as letter_spacing, + 'Small text for captions and secondary information' as sample_text, + 'Captions, metadata, footnotes, fine print' as usage, + 'Supporting information that should not compete with main content' as description; + +select 'text' as component, 'Spacing' as title, ' +**Base unit**: 8 pixels + +**Spacing scale**: +- Extra small: 8 pixels +- Small: 16 pixels +- Medium: 24 pixels +- Large: 32 pixels +- Extra large: 48 pixels +- XXL: 64 pixels + +**Container**: Maximum width 1000 pixels, padding 40 pixels +' as contents_md; + +select 'text' as component, 'Dark Environments' as title, ' +For digital displays, presentations, and screens. +' as contents_md; + +select 'text' as component, 'Background Colors' as title, ' +- **Primary background**: #0a0f1a (deep navy blue) +- **Secondary background**: #0f1426 (slightly lighter navy) +- Use gradients with primary cyan (#37E5EF) and teal (#2A9FAF) for visual interest +' as contents_md; + +select 'text' as component, 'Text Colors' as title, ' +- **Primary text**: #f7f7f7 (almost white) - for main content +- **Secondary text**: #999999 (medium gray) - for supporting information +- **Headings**: #ffffff (pure white) - for maximum emphasis +- **Links**: #7db3e8 (bright blue) - for interactive elements +' as contents_md; + +select 'text' as component, 'Contrast Guidelines' as title, ' +- All text must meet WCAG AA contrast requirements (minimum 4.5:1 for normal text, 3:1 for large text) +- Primary text (#f7f7f7) on primary background (#0a0f1a) meets accessibility standards +- Use white (#ffffff) only for headings and emphasis +- Test all color combinations before finalizing designs +' as contents_md; + +select 'text' as component, 'Light Environments' as title, ' +For print materials, light-themed websites, and bright displays. +' as contents_md; + +select 'text' as component, 'Background Colors' as title, ' +- **Primary background**: #ffffff (white) or #f8f9fa (off-white) +- **Secondary background**: #f1f3f5 (light gray) +- Use subtle gradients or solid light colors +- Avoid pure white backgrounds in print to reduce glare +' as contents_md; + +select 'text' as component, 'Text Colors' as title, ' +- **Primary text**: #1a1a1a (near black) or #212529 (dark gray) - for main content +- **Secondary text**: #6c757d (medium gray) - for supporting information +- **Headings**: #000000 (black) or #0a0f1a (dark navy) - for emphasis +- **Links**: #2A9FAF (teal) or #37E5EF (cyan) - maintain brand colors +' as contents_md; + +select 'text' as component, 'Logo Usage in Light Environments' as title, ' +- Logo works on both light and dark backgrounds +- On light backgrounds, ensure sufficient contrast +- Consider using a darker version or adding a subtle shadow if needed +- Test logo visibility on various light backgrounds +' as contents_md; + +select 'text' as component, 'Print Guidelines' as title, ' +- Use CMYK color mode for print materials +- Convert hex colors to CMYK equivalents +- Primary cyan (#37E5EF) prints as: C: 76%, M: 0%, Y: 0%, K: 6% +- Teal accent (#2A9FAF) prints as: C: 76%, M: 9%, Y: 0%, K: 31% +- Test print samples to ensure color accuracy +- Use off-white paper (#f8f9fa equivalent) to reduce eye strain +- Minimum font size for print: 10 points (13 pixels) +- Ensure all text meets print contrast requirements +' as contents_md; + +select 'text' as component, 'Presentations' as title, ' +**Background**: Dark theme #0a0f1a with gradient overlays + +**Typography**: +- Title slide: Large bold text with gradient effect +- Body: Minimum readable size for your audience +- Code: Monospace font, minimum readable size + +**Logo**: +- Title slide: Large, centered +- Content slides: Small, bottom-right corner + +**Colors**: Use brand cyan/teal gradients (#37E5EF to #2A9FAF) for highlights. Maintain high contrast for readability. +' as contents_md; + +select 'text' as component, 'Resources' as title, ' +- Logo: `/assets/icon.webp` +- CSS Theme: `/assets/highlightjs-and-tabler-theme.css` +- [Components Documentation](/component.sql) +- [GitHub Discussions](https://github.com/sqlpage/SQLPage/discussions) +' as contents_md; diff --git a/examples/official-site/your-first-sql-website/custom_urls.sql b/examples/official-site/your-first-sql-website/custom_urls.sql new file mode 100644 index 0000000..53e2dab --- /dev/null +++ b/examples/official-site/your-first-sql-website/custom_urls.sql @@ -0,0 +1,56 @@ +select 'http_header' as component, + 'public, max-age=300, stale-while-revalidate=3600, stale-if-error=86400' as "Cache-Control"; + +select 'dynamic' as component, properties FROM example WHERE component = 'shell' LIMIT 1; + +select 'hero' as component, + 'Custom URLs' as title, + 'SQLPage lets you customize responses to URLs that don''t match any file, using `404.sql`.' as description_md, + 'not_found.jpg' as image; + +select 'text' as component, ' +# Handling custom URLs + +By default, SQLPage serves the file that matches the URL requested by the client. +If your users enter `https://example.com/about`, SQLPage will serve the file `about/index.sql` in your project. +If you create a file named `about.sql`, SQLPage will serve it when the user requests either `https://example.com/about.sql` or `https://example.com/about` (since v0.33, the `.sql` suffix is optional). + +But what if you want to handle URLs that don''t match any file in your project ? +For example, what if you have a blog, and you want nice urls like `example.com/blog/my-trip-to-rome`, +but you don''t want to create a file for each blog post ? +By default, SQLPage would return a sad 404 error if you don''t have a file named `blog/my-trip-to-rome/index.sql` +in your project''s root directory. + +But you can customize this behavior by creating a file named `404.sql` in your project. + +## The 404.sql file + +When SQLPage doesn''t find a file that matches the URL requested by the client, it will serve the file `404.sql` if it exists. + +Since v0.28, when SQLPage receives a request for a URL like `https://example.com/a/b/c`, it will look for the file `a/b/c/index.sql` in your project, +and if it doesn''t find it, it will then search for, in order: +- `/a/b/404.sql` +- `/a/404.sql` +- `/404.sql` + +## Basic routing example + +So, you have a `blog_posts` table in your database, with columns `name`, and `content`. +You want to serve the content of the blog post with id `:id` when the user requests `example.com/blog/:id`. +You can do this by creating a `404.sql` file in the `blog` directory of your project: + +```sql +-- blog/404.sql + +-- Get the id from the URL +set name = substr(sqlpage.path(), 1+length(''/blog/'')); + +-- Get the blog post from the database +select ''text'' as component, + content as contents_md +from blog_posts +where name = $name; +``` + +Now, when a user requests `example.com/blog/my-trip-to-rome`, SQLPage will serve the content of the blog post with name `my-trip-to-rome` from the `blog_posts` table. +' as contents_md; \ No newline at end of file diff --git a/examples/official-site/your-first-sql-website/documentation.sql b/examples/official-site/your-first-sql-website/documentation.sql new file mode 100644 index 0000000..04038b2 --- /dev/null +++ b/examples/official-site/your-first-sql-website/documentation.sql @@ -0,0 +1 @@ +select 'redirect' as component, '../documentation.sql' as link; \ No newline at end of file diff --git a/examples/official-site/your-first-sql-website/final-result.png b/examples/official-site/your-first-sql-website/final-result.png new file mode 100644 index 0000000..64c65f1 Binary files /dev/null and b/examples/official-site/your-first-sql-website/final-result.png differ diff --git a/examples/official-site/your-first-sql-website/first-sql-website-launch.png b/examples/official-site/your-first-sql-website/first-sql-website-launch.png new file mode 100644 index 0000000..1727c59 Binary files /dev/null and b/examples/official-site/your-first-sql-website/first-sql-website-launch.png differ diff --git a/examples/official-site/your-first-sql-website/full-website.png b/examples/official-site/your-first-sql-website/full-website.png new file mode 100644 index 0000000..976bb36 Binary files /dev/null and b/examples/official-site/your-first-sql-website/full-website.png differ diff --git a/examples/official-site/your-first-sql-website/get_started.webp b/examples/official-site/your-first-sql-website/get_started.webp new file mode 100644 index 0000000..9ee1dce Binary files /dev/null and b/examples/official-site/your-first-sql-website/get_started.webp differ diff --git a/examples/official-site/your-first-sql-website/get_started_linux.webp b/examples/official-site/your-first-sql-website/get_started_linux.webp new file mode 100644 index 0000000..1e9ce0e Binary files /dev/null and b/examples/official-site/your-first-sql-website/get_started_linux.webp differ diff --git a/examples/official-site/your-first-sql-website/get_started_macos.webp b/examples/official-site/your-first-sql-website/get_started_macos.webp new file mode 100644 index 0000000..91feae4 Binary files /dev/null and b/examples/official-site/your-first-sql-website/get_started_macos.webp differ diff --git a/examples/official-site/your-first-sql-website/get_started_windows.webp b/examples/official-site/your-first-sql-website/get_started_windows.webp new file mode 100644 index 0000000..175b4a4 Binary files /dev/null and b/examples/official-site/your-first-sql-website/get_started_windows.webp differ diff --git a/examples/official-site/your-first-sql-website/hello-world.png b/examples/official-site/your-first-sql-website/hello-world.png new file mode 100644 index 0000000..f587b26 Binary files /dev/null and b/examples/official-site/your-first-sql-website/hello-world.png differ diff --git a/examples/official-site/your-first-sql-website/hosted.sql b/examples/official-site/your-first-sql-website/hosted.sql new file mode 100644 index 0000000..e7ff736 --- /dev/null +++ b/examples/official-site/your-first-sql-website/hosted.sql @@ -0,0 +1,28 @@ +select 'shell' as component, + 'SQLPage: get started!' as title, + 'database' as icon, + '/' as link, + 'en-US' as lang, + 'Hosted SQLPage: set-up a SQLPage website in three clicks.' as description, + 'documentation' as menu_item, + 'Poppins' as font; + +SELECT 'hero' as component, + 'Hosted SQLPage' as title, + 'Work In Progress: We are working on a cloud version of SQLPage + that will enable you to effortlessly set up your website online without the need to download any software or configure your own server.' as description, + 'https://upload.wikimedia.org/wikipedia/commons/thumb/9/94/Baustelle_H%C3%B6lzla_6066312.jpg/1024px-Baustelle_H%C3%B6lzla_6066312.jpg' as image, + 'https://forms.gle/z1qmuCwdNT5Am7gp6' as link, + 'Get notified when we are ready' as link_text; + +SELECT 'text' as component, + 'Try SQLPage online today' as title, + ' +If you want to fiddle around with SQLPage without installing anything on your computer, you can still try it out online today. + +Repl.it is an online development environment that allows you to run SQLPage in your browser. + +Try [the SQLPage repl](https://replit.com/@pimaj62145/SQLPage). +Click *Use template* to create your own editable copy of the demo website, then click *Run* to see the result. +On the left side you can edit the SQLPage code, on the right side you can see the result. +' as contents_md; \ No newline at end of file diff --git a/examples/official-site/your-first-sql-website/index.sql b/examples/official-site/your-first-sql-website/index.sql new file mode 100644 index 0000000..fe380cd --- /dev/null +++ b/examples/official-site/your-first-sql-website/index.sql @@ -0,0 +1,87 @@ +select 'http_header' as component, + 'public, max-age=300, stale-while-revalidate=3600, stale-if-error=86400' as "Cache-Control", + '; rel="canonical"' as "Link"; + +set os = COALESCE($os, case + when sqlpage.header('user-agent') like '%windows%' then 'windows' + when sqlpage.header('user-agent') like '%x11; linux%' then 'linux' + when sqlpage.header('user-agent') like '%x11; ubuntu; linux%' then 'linux' + when sqlpage.header('user-agent') like '%x11; debian; linux%' then 'linux' + when sqlpage.header('user-agent') like '%macintosh%' then 'macos' + else 'any' +end); + +-- Fetch the page title and header from the database +select 'dynamic' as component, json_patch(json_extract(properties, '$[0]'), json_object( + 'title', 'SQL to Website - Tutorial', + 'description', 'Convert your SQL database into a website in minutes. In this 5-minute guide, we will create a simple website from scratch, and learn the basics of SQLPage.' +)) as properties +FROM example WHERE component = 'shell' LIMIT 1; + +SET req = '{ + "url": "https://api.github.com/repos/sqlpage/SQLPage/releases/latest", + "timeout_ms": 200 +}'; +SET api_results = sqlpage.fetch_with_meta($req); +SET sqlpage_version = COALESCE(json_extract($api_results, '$.body.tag_name'), ''); + +SELECT 'hero' as component, + 'Your first SQL Website' as title, + '[SQLPage](/) is a free tool for building data-driven apps quickly. + +Let''s create a simple website with a database from scratch, to learn SQLPage basics.' as description_md, + case $os + when 'linux' then 'get_started_linux.webp' + when 'macos' then 'get_started_macos.webp' + when 'windows' then 'get_started_windows.webp' + else 'get_started.webp' + end as image, + CASE $os + WHEN 'macos' THEN '#download' + WHEN 'windows' THEN 'https://github.com/sqlpage/SQLPage/releases/latest/download/sqlpage-windows.zip' + WHEN 'linux' THEN 'https://github.com/sqlpage/SQLPage/releases/latest/download/sqlpage-linux.tgz' + ELSE 'https://github.com/sqlpage/SQLPage/releases' + END AS link, + CASE $os + WHEN 'macos' THEN CONCAT('Install SQLPage ', $sqlpage_version, ' using Homebrew') + WHEN 'windows' THEN CONCAT('Download SQLPage ', $sqlpage_version, ' for Windows') + WHEN 'linux' THEN CONCAT('Download SQLPage ', $sqlpage_version, ' for Linux') + ELSE CONCAT('Download SQLPage ', $sqlpage_version) + END AS link_text; + +SELECT 'alert' as component, + 'Afraid of the setup ? Do it the easy way !' as title, + 'mood-happy' as icon, + 'teal' as color, + 'You don’t want to install anything on your computer ? + You can use a preconfigured SQLPage hosted on our servers, and get your app online in minutes, without **ever having to configure a server** yourself.' as description_md, + 'https://datapage.app' AS link, + 'Host your app on our servers' as link_text; +select 'https://editor.datapage.app' as link, 'Try SQLPage from your browser' as title, 'teal' as color; + +SELECT 'alert' as component, + 'Do you prefer videos ?' as title, + 'brand-youtube' as icon, + 'purple' as color, + 'We made videos to introduce you to SQLPage. You can watch them on YouTube. The videos cover everything from the underlying technology to the philosophy behind SQLPage to the actual steps to create your first website.' as description_md, + 'https://www.youtube.com/watch?v=9NJgH_-zXjY' AS link, + 'Watch the introduction video' as link_text; +select 'https://www.youtube.com/watch?v=6D5D10v18b0&list=PLTue_qIAHxActQnLn_tHWZUNXziZTeraB' as link, 'Tutorial video series' as title; + +select 'text' as component, + sqlpage.read_file_as_text( + printf('your-first-sql-website/tutorial-install-%s.md', + case + when $os = 'windows' then 'windows' + when $os = 'macos' then 'macos' + else 'any' + end + ) + ) as contents_md, + true as article, + 'download' as id; + +select 'text' as component, + sqlpage.read_file_as_text('your-first-sql-website/tutorial.md') as contents_md, + true as article, + 'tutorial' as id; diff --git a/examples/official-site/your-first-sql-website/migrations.md b/examples/official-site/your-first-sql-website/migrations.md new file mode 100644 index 0000000..7d1822c --- /dev/null +++ b/examples/official-site/your-first-sql-website/migrations.md @@ -0,0 +1,146 @@ +# Understanding SQL Migrations: Your Database, Layer by Layer + +Maintaining a structured and evolving database is crucial for web app development. Rarely do we get a schema 100% correct on day one. New insights about the shape of the application are discovered over time, or business needs themselves evolve. In the world of databases, we can evolve schemas using migration files. These files are just more SQL that append or amend layers of development. Think of this process like sedimentary rock layers. Each migration adds a layer, and together, these layers create a complete, functional structure along with a visible trail of historical changes. + +## What Makes up a SQL Migration File? + +SQL migrations are incremental changes to a database. These changes can include creating tables, adding columns, modifying data types, or even inserting or updating records. Each migration is a distinct script that applies a specific change. + +### Use Caution! + +Since migration files change the database, they can have unintended consequences if not thought through carefully. For instance, you may accidentally delete a column that is still being used by your application or remove records that are still needed. + +> ⚠️ Be thoughtful, double-check your work, and **always back up your data before running a migration**. + +### Order Matters + +**It's important that migrations are distinct ordered files** as SQLPage uses the sequence of migration files to build the database over time: `0001_initial_setup.sql`, `0002_my_first_change.sql`, `0003_my_next_change`, etc. + +### No take-backs! + +**Do not make changes to an existing migration file** in production. If a previously implemented migration file is altered, it will confuse SQLPage and cause a crash. + +*If you are in early stages of development and are okay with losing data*, you can delete the database and start over with an altered migration file. However, in a production environment, especially once persisted data is involved, this is not an option. + +It's like trying to go back in time and change a previous sedimentary layer. That's not how rocks work, and that's not how migrations work. + +Append or amend; do not try to change the past. + +## Examples + +Let's start off easy with a simple database to store user information: `first_name`, `last_name`, `email`, `phone`, and `password_hash`. Our first migration actually creates the `users` table with these columns. That is, we migrate from *nothing* to *having a users table*. + +**`sqlpage/migrations/001_create_users.sql`**: +```sql +create table users ( + id integer primary key autoincrement, + first_name not null, + last_name not null, + email not null unique, + phone, + rewards_level, + password_hash not null +); +``` + +In the terminal, we can see the new schema: + +```console +sqlite> .schema +CREATE TABLE users ( + id integer primary key autoincrement, + first_name not null, + last_name not null, + email not null unique, + phone, + rewards_level, + password_hash not null +); +``` + +### A Simple Change + +Later, we discover we need a `middle_name` column, so we create a new migration file to add this column to the `users` table. Remember, we must ensure the order is written into the filename so SQLPage can apply them in the correct order when building the database. + +**`sqlpage/migrations/002_add_middle_name.sql`**: +```sql +alter table users add column middle_name; +``` + +In the terminal: + +```console +sqlite> .schema +CREATE TABLE users ( + id integer primary key autoincrement, + first_name not null, + last_name not null, + email not null unique, + phone, + rewards_level integer, + password_hash not null, + middle_name +); +``` + +### A More Complex Change + +But notice here, SQLite has appended the column to the very end. What if we really need that `middle_name` column to be next to the other name columns? Further, what if we realize `rewards_level` should really be an integer and only one between 1 and 20? + +We can create a new migration file to make these changes, albeit a bit more complicated. + +Because we'll be altering column types and modifying column order, we'll need to *create a temporary table* to hold the data while we drop the original table and recreate it with the new schema. + +**`sqlpage/migrations/003_alter_users.sql`**: +```sql +create table users_temp ( + id integer primary key autoincrement, + first_name not null, + last_name not null, + middle_name, + email not null unique, + phone, + rewards_level integer check(rewards_level between 1 and 20), + password_hash not null +); + +insert into users_temp select id, first_name, last_name, middle_name, email, phone, rewards_level, password_hash from users; + +drop table users; -- backups are important! + +alter table users_temp rename to users; +``` + +In the terminal: + +```console +sqlite> .schema +CREATE TABLE users ( + id integer primary key autoincrement, + first_name not null, + last_name not null, + middle_name, + email not null unique, + phone, + rewards_level integer check(rewards_level between 1 and 20), + password_hash not null +); +``` + +## Conclusion + +SQL migration is our tool for evolving databases over time. By creating distinct, ordered migration files, we can incrementally build and modify our databases without losing data or breaking our application functionality. Just **remember to always back up data before running a migration**, and always be thoughtful about changes. + +Since SQLPage runs migrations forward in time, we won't dive into the complexities of rolling back migrations here. Just remember, we can't change the past, only build upon it. + +[Rollbacks](https://en.wikipedia.org/wiki/Rollback_(data_management)) are an intriguing topic that you may run into in other frameworks. + +## Further Study + +To learn more on the migrations topic, consider the Wikipedia article on [Schema Migration](https://en.wikipedia.org/wiki/Schema_migration). **Note**: database engines are different, so be sure to review the documentation for your specific database engine and what types of SQL statements are permitted. For SQLite, the [official documentation](https://www.sqlite.org/lang_altertable.html) is a good place to start. + +Best migrations on your evolving database journey! 👋 + +--- + +Article written by [Matthew Larkin](https://github.com/matthewlarkin) for [SQLPage](https://sql-page.com/). \ No newline at end of file diff --git a/examples/official-site/your-first-sql-website/migrations.sql b/examples/official-site/your-first-sql-website/migrations.sql new file mode 100644 index 0000000..61f0761 --- /dev/null +++ b/examples/official-site/your-first-sql-website/migrations.sql @@ -0,0 +1,14 @@ +select 'http_header' as component, + 'public, max-age=300, stale-while-revalidate=3600, stale-if-error=86400' as "Cache-Control", + '; rel="canonical"' as "Link"; + +select 'dynamic' as component, json_patch(json_extract(properties, '$[0]'), json_object( + 'title', 'SQLPage migrations', + 'description', 'Manage your database schema with SQLPage using migrations.' +)) as properties +FROM example WHERE component = 'shell' LIMIT 1; + +-- Article by Matthew Larkin +select 'text' as component, + sqlpage.read_file_as_text('your-first-sql-website/migrations.md') as contents_md, + true as article; diff --git a/examples/official-site/your-first-sql-website/nginx.md b/examples/official-site/your-first-sql-website/nginx.md new file mode 100644 index 0000000..9e15613 --- /dev/null +++ b/examples/official-site/your-first-sql-website/nginx.md @@ -0,0 +1,353 @@ +# **Hosting SQLPage Behind a Reverse Proxy** + +Hosting SQLPage behind a reverse proxy can help with security, scalability, and flexibility. +In this guide, we will guide you step-by-step on how to host SQLPage behind a reverse proxy using +[NGINX](https://www.nginx.com/). + +## Why host SQLPage behind a Reverse Proxy ? + +Here are some reasons why you might want to host SQLPage behind a reverse proxy: + + - **customize your application's URLs**, removing `.sql` extensions and changing URL parameters + - **protect against attacks** such as denial-of-service (DoS) by rate limiting incoming requests + - **improve performance** by caching responses and serving static files without involving SQLPage + - **enable HTTPS** on the front-end, even when SQLPage is running on HTTP + - **host multiple applications** or multiple instances of SQLPage on the same server + +## Prerequisites + +Before you begin, you will need the following: + + - A server running SQLPage. In this guide, we will assume SQLPage is running on `localhost:8080` + - Nginx installed on your server. On Ubuntu, you can install NGINX using `sudo apt install nginx` + - A domain name pointing to your server (optional) + - An SSL certificate obtained from Certbot (optional) + +## Configuring the Reverse Proxy + +NGINX uses a hierarchical configuration structure. The global configuration file (`/etc/nginx/nginx.conf`) contains settings that apply to the entire server, such as logging, caching, and rate limiting.Site-specific configuration files, stored in `/etc/nginx/sites-available/`, contain directives for individual websites or applications. These site-specific configurations are activated by creating symbolic links in the `/etc/nginx/sites-enabled/` directory. This setup allows for clean and organized management of multiple sites on a single server. + +To host SQLPage behind a reverse proxy, you will need to create a new configuration file in the `/etc/nginx/sites-available/` directory, and then create a symbolic link to it in the `/etc/nginx/sites-enabled/` directory. + +Create a file named `sqlpage` in the `/etc/nginx/sites-available/` directory: +```bash +sudo nano /etc/nginx/sites-available/sqlpage +``` + +Add the following configuration to the file: + +```nginx +server { + listen 80; + server_name example.com; + + location / { + proxy_pass http://localhost:8080; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } +} +``` + +Save the file and create a symbolic link to it in the `/etc/nginx/sites-enabled/` directory: +```bash +sudo ln -s /etc/nginx/sites-available/sqlpage /etc/nginx/sites-enabled/sqlpage +``` + +Test the configuration and reload NGINX: +```bash +sudo nginx -t +sudo systemctl reload nginx +``` + +Your SQLPage instance is now hosted behind a reverse proxy using NGINX. You can access it by visiting `http://example.com`. + + +### Streaming-friendly proxy settings + +SQLPage streams HTML by default so the browser can render results while the database is still sending rows. +If you have slow SQL queries (you shouldn't), you can add the following directive to your location block: + +```nginx +proxy_buffering off; +``` + +That will allow users to start seeing the top of your pages faster, +but will increase the load on your SQLPage server, and reduce the amount of users you can serve concurrently. + +Refer to the official documentation for [proxy buffering](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffering), [gzip](https://nginx.org/en/docs/http/ngx_http_gzip_module.html), and [chunked transfer](https://nginx.org/en/docs/http/ngx_http_core_module.html#chunked_transfer_encoding) when tuning these values. + +When SQLPage sits behind a reverse proxy, set `compress_responses` to `false` [in `sqlpage.json`](https://github.com/sqlpage/SQLPage/blob/main/configuration.md) so that NGINX compresses once at the edge. + +### URL Rewriting + +URL rewriting is a powerful feature that allows you to manipulate URLs to make them more readable, search-engine-friendly, and easy to maintain. +In this section, we will cover how to use URL rewriting with SQLPage. + +Note that for basic URL rewriting, you can use a simple [`404.sql`](/your-first-sql-website/custom_urls.sql) file to handle custom URLs. +However, for more complex rewriting rules, you can use NGINX's `rewrite` directive. + +#### Example: Rewriting `/products/$id` to `/products.sql?id=$id` + +Let's say you want your users to access product details using URLs like `/products/123` instead of `/products.sql?id=123`. This can be achieved using the `rewrite` directive in NGINX. + +Here is an example configuration: + +```nginx +server { + listen 80; + server_name example.com; + + location / { + proxy_pass http://localhost:8080; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } + + location /products/ { + rewrite ^/products/([^/]+)$ /products.sql?id=$1 last; + } +} +``` + +This configuration uses the `rewrite` directive to rewrite URLs of the form `/products/$id` to `/products.sql?id=$id`. The `^/products/([^/]+)$` pattern matches URLs that start with `/products/` and captures the dynamic parameter `$id` using parentheses. The `last` flag indicates that this rewrite rule should be the last one to be applied; if the pattern matches, the rewritten URL is passed to the next location block, +in this case, the proxy_pass directive. + +**How it Works** + +When a request is made to `/products/123`, the rewrite rule is triggered, and the URL is rewritten to `/products.sql?id=123`. The `proxy_pass` directive then forwards the rewritten URL to the SQLPage instance, which processes the request and returns the response. + +#### Example: Removing `.sql` Extension from URLs + +Let's say you want to remove the `.sql` extension from all URLs to make them cleaner and more user-friendly. This can be achieved using the `rewrite` directive in NGINX. + +```nginx +location / { + + # When a request doesn't end with a '/' and doesn't have an extension, add '.sql' at the end + rewrite ^/((.*/)?[^/.]+)$ /$1.sql last; + + proxy_pass http://localhost:8080; +} +``` + +### Hosting Multiple Applications + +You may want to use the same web server to host SQLPage together with +another application such as a blog, a different website, or another instance of SQLPage. +In this section, we will cover how to host multiple applications behind a reverse proxy using NGINX. + +#### Example: Hosting Two Applications with Different domain names + +Let's say you want to host two separate instances of SQLPage on the same server, each accessible via a different domain name: `app1.example.com` and `app2.example.com`. This can be achieved by creating two separate configuration files in the `/etc/nginx/sites-available/` directory and then creating symbolic links to them in the `/etc/nginx/sites-enabled/` directory. + +Create `/etc/nginx/sites-available/app1`, and `/etc/nginx/sites-available/app2` configuration files, +and add the following configuration to each file, replacing `localhost:8080` and `app1.example.com` with the appropriate values: + +```nginx +server { + listen 80; + server_name app1.example.com; + + location / { + proxy_pass http://localhost:8080; + } +} +``` +Then create symbolic links to the configuration files in the `/etc/nginx/sites-enabled/` directory. + +#### Hosting on a Subpath + +You may have multiple applications to host, but a single domain name to use. In this case, you can host each application on a different subpath of the domain name, for example, `example.com/app1` and `example.com/app2`. + +To host SQLPage on a subpath, you can use a single NGINX configuration file with a `location` block that specifies the subpath: + +```nginx +server { + listen 80; + server_name example.com; + + location /sqlpage { + proxy_pass http://localhost:8080; + } +} +``` +This configuration sets up a reverse proxy that forwards incoming requests from `example.com/sqlpage` to `localhost:8080`, where SQLPage is running. + +And in the SQLPage configuration file, at `./sqlpage/sqlpage.json`, +you can specify the base URL as `/sqlpage`: + +```json +{ + "site_prefix": "/sqlpage" +} +``` + +### IP Rate Limiting + +To enable IP rate limiting for your SQLPage instance, you can use the +[`limit_req` module in NGINX](http://nginx.org/en/docs/http/ngx_http_limit_req_module.html). + +Define a global rate limiting zone in `/etc/nginx/nginx.conf`: + +```nginx +http { + ... + limit_req_zone $binary_remote_addr zone=myzone:10m rate=10r/m; +} +``` + +Then use it in your site's configuration in `/etc/nginx/sites-available/sqlpage`: + +```nginx +server { + ... + + location / { + limit_req zone=myzone; + proxy_pass http://localhost:8080; + ... + } +} +``` +This configuration sets up a reverse proxy that forwards incoming requests from `example.com` to `localhost:8080`, where SQLPage is running, and enables IP rate limiting to prevent abuse. + + +### Static File Serving + +The [`try_files`](https://nginx.org/en/docs/http/ngx_http_core_module.html#try_files) directive in Nginx specifies the files to attempt to serve before falling back to a specified URI or passing the request to a proxy server. It's typically used within a location block to define the behavior when a request matches that location. + +```nginx +server { + listen 80; + server_name example.com; + + location ~ \.sql$ { + include sqlpage_proxy.conf; + } + + location / { + try_files $uri @reverse_proxy; + } + + location @reverse_proxy { + include sqlpage_proxy.conf; + } +} +``` + +And in `/etc/nginx/sqlpage_proxy.conf`: + +```nginx +proxy_pass http://localhost:8080; +proxy_http_version 1.1; +proxy_set_header Upgrade $http_upgrade; +proxy_set_header Connection 'upgrade'; +proxy_set_header Host $host; +proxy_cache_bypass $http_upgrade; +proxy_buffering on; +proxy_set_header X-Real-IP $remote_addr; +proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +proxy_set_header X-Forwarded-Proto $scheme; +proxy_set_header X-Forwarded-Host $host; +``` + +### Caching and Buffering + +To enable caching and buffering for your SQLPage instance, you can use the +[`proxy_cache`](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_cache) +and [`proxy_buffering`](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffering) +directives in NGINX. + +Declare the cache in `/etc/nginx/nginx.conf` : + +```nginx +http { + ... + proxy_cache mycache; + # Cache settings: 1 hour for 200 and 302 responses, 1 minute for 404 responses + proxy_cache_valid 200 302 1h; + proxy_cache_valid 404 1m; +} +``` + +and then in your sqlpage nginx configuration file `/etc/nginx/sites-available/sqlpage` : + +```nginx +server { + listen 80; + server_name example.com; + + location / { + proxy_pass http://sqlpage; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + proxy_cache mycache; + # Buffering: when a client is slow to read the response, quickly read the response from SQLPage and store it in a buffer, then send it to the slow client, while SQLPage can continue processing other requests + proxy_buffering on; + proxy_buffer_size 128k; + proxy_buffers 4 256k; + } +} +``` + +### **HTTPS and Certbot** + +To let nginx handle HTTPS instead of SQLPage, you can obtain an SSL certificate from Certbot and configure nginx to use it. + +Install certbot using the following command: +```bash +sudo snap install --classic certbot +``` + +Obtain an SSL certificate using the following command: +```bash +sudo certbot --nginx -d example.com +``` + +### Binding to a UNIX socket + +Binding SQLPage to a Unix socket can reduce latency and enhance security by bypassing the network stack and restricting access to the socket file. Unix sockets are suitable for communication within the same host, offering lower overhead compared to TCP/IP. + +#### SQLPage Configuration + +Edit `./sqlpage/sqlpage.json`. Remove the `listen_on` and `port` configuration entries if they are present. + +```json +{ + "unix_socket": "/var/run/sqlpage.sock" +} +``` + +#### NGINX Configuration + +In `/etc/nginx/sites-available/sqlpage`: + +```nginx +server { + listen 80; + server_name example.com; + + location / { + proxy_pass http://unix:/var/run/sqlpage.sock; + proxy_set_header Host $host; + } +} +``` + +# Example NGINX configuration for SQLPage + +You can find a fully working example of an NGINX configuration for SQLPage +illustrating all the features described in this guide +in the [examples/nginx](https://github.com/sqlpage/SQLPage/tree/main/examples/nginx) +directory of the SQLPage Github repository. +The example uses Docker and docker-compose to run NGINX and SQLPage. \ No newline at end of file diff --git a/examples/official-site/your-first-sql-website/nginx.sql b/examples/official-site/your-first-sql-website/nginx.sql new file mode 100644 index 0000000..bd2d6b6 --- /dev/null +++ b/examples/official-site/your-first-sql-website/nginx.sql @@ -0,0 +1,5 @@ +select 'http_header' as component, + 'public, max-age=300, stale-while-revalidate=3600, stale-if-error=86400' as "Cache-Control"; + +select 'dynamic' as component, properties FROM example WHERE component = 'shell' LIMIT 1; +select 'text' as component, sqlpage.read_file_as_text('your-first-sql-website/nginx.md') as contents_md; \ No newline at end of file diff --git a/examples/official-site/your-first-sql-website/not_found.jpg b/examples/official-site/your-first-sql-website/not_found.jpg new file mode 100644 index 0000000..2d00175 Binary files /dev/null and b/examples/official-site/your-first-sql-website/not_found.jpg differ diff --git a/examples/official-site/your-first-sql-website/sql.webp b/examples/official-site/your-first-sql-website/sql.webp new file mode 100644 index 0000000..2139578 Binary files /dev/null and b/examples/official-site/your-first-sql-website/sql.webp differ diff --git a/examples/official-site/your-first-sql-website/tutorial-install-any.md b/examples/official-site/your-first-sql-website/tutorial-install-any.md new file mode 100644 index 0000000..d91533b --- /dev/null +++ b/examples/official-site/your-first-sql-website/tutorial-install-any.md @@ -0,0 +1,25 @@ +# Download SQLPage + +SQLPage is a small single-file program that will +execute the SQL files you write, +and render the database responses as nice web pages. + +If you have already downloaded SQLPage, +you can skip this step and [start writing your website](#tutorial). + +[Download the latest SQLPage](https://github.com/sqlpage/SQLPage/releases) for your operating system. +In the _release assets_ section, you will find files named `sqlpage-windows.zip`, `sqlpage-linux.tgz`, and `sqlpage-macos.tgz`. +Download the one that corresponds to your operating system, and extract the executable file from the archive. + +> **Note**: On Mac OS, Apple blocks the execution of downloaded files by default. The easiest way to run SQLPage is to use [Homebrew](https://brew.sh). + +> **Note**: Advanced users can alternatively install SQLPage using: +> - [docker](https://hub.docker.com/repository/docker/lovasoa/SQLPage/general) (docker images are also available for ARM, making it easy to run SQLPage on a Raspberry Pi, for example), +> - [brew](https://formulae.brew.sh/formula/sqlpage) (the easiest way to install SQLPage on Mac OS), +> - [nix](https://search.nixos.org/packages?channel=unstable&show=sqlpage) (declarative package management for reproducible deployments), +> - [scoop](https://scoop.sh/#/apps?q=sqlpage&id=305b3437817cd197058954a2f76ac1cf0e444116) (a command-line installer for Windows), +> - or [cargo](https://crates.io/crates/sqlpage) (the Rust package manager). + +You can also find the source code of SQLPage on [GitHub](https://github.com/sqlpage/SQLPage), [install rust](https://www.rust-lang.org/tools/install) on your computer, and compile it yourself with `cargo install sqlpage`. + +See the instructions for [MacOS](?os=macos#download), or for [Windows](?os=windows#download). diff --git a/examples/official-site/your-first-sql-website/tutorial-install-macos.md b/examples/official-site/your-first-sql-website/tutorial-install-macos.md new file mode 100644 index 0000000..d020ee3 --- /dev/null +++ b/examples/official-site/your-first-sql-website/tutorial-install-macos.md @@ -0,0 +1,18 @@ +# Download SQLPage for Mac OS + +On Mac OS, Apple blocks the execution of downloaded files by default. The easiest way to run SQLPage is to use [Homebrew](https://brew.sh). +Open a terminal and run the following commands: + +```sh +/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" +brew install sqlpage +sqlpage +``` + +> **Note**: Advanced users can alternatively install SQLPage using +> [the precompiled binaries](https://github.com/sqlpage/SQLPage/releases/latest), +> [docker](https://hub.docker.com/repository/docker/lovasoa/SQLPage/general), +> [nix](https://search.nixos.org/packages?channel=unstable&show=sqlpage), +> or [cargo](https://crates.io/crates/sqlpage). + +> **Not on Mac OS?** See the instructions for [Windows](?os=windows#download), or for [Other Systems](?os=any#download). \ No newline at end of file diff --git a/examples/official-site/your-first-sql-website/tutorial-install-windows.md b/examples/official-site/your-first-sql-website/tutorial-install-windows.md new file mode 100644 index 0000000..19767d6 --- /dev/null +++ b/examples/official-site/your-first-sql-website/tutorial-install-windows.md @@ -0,0 +1,14 @@ +# Download the SQLPage executable for Windows + +SQLPage offers a small executable file (`sqlpage.exe`) that will take requests to your website, +execute the SQL files you write, and render the database responses as nice web pages. + +[Download the latest SQLPage for Windows](https://github.com/sqlpage/SQLPage/releases/latest/download/sqlpage-windows.zip). +Download the file, and extract the executable file from the zip archive. + +> **Note**: Advanced users can alternatively install SQLPage using +> [docker](https://hub.docker.com/repository/docker/lovasoa/SQLPage/general), +> [scoop](https://scoop.sh/#/apps?q=sqlpage&id=305b3437817cd197058954a2f76ac1cf0e444116), +> or [cargo](https://crates.io/crates/sqlpage). + +> **Not on Windows?** See the instructions for [Mac OS](?os=macos#download), or for [Other Systems](?os=any#download). \ No newline at end of file diff --git a/examples/official-site/your-first-sql-website/tutorial.md b/examples/official-site/your-first-sql-website/tutorial.md new file mode 100644 index 0000000..582a946 --- /dev/null +++ b/examples/official-site/your-first-sql-website/tutorial.md @@ -0,0 +1,199 @@ +# Building your website locally + +Create a folder on your computer where you will store all contents related to your sql website. +In the rest of this tutorial, we will call this folder the **root folder** of your website. + +- On **Windows**, place the `sqlpage.exe` you downloaded above at the root of the folder. Then double-click the `sqlpage.exe` file to start the server. +- On **Linux**, place `sqlpage.bin` at the root of the folder. Then open a terminal, cd to the root folder of your website, and run `./sqlpage.bin` to start the server. +- On **Mac OS**, if you installed SQLPage using Homebrew, then you do not need to place anything at the root of the folder. Open Terminal, cd to the root folder of your website, and type `sqlpage` to start the server. + +![screenshot for the sql website setup on linux](first-sql-website-launch.png) + +You should see a message in your terminal telling you that SQLPage is ready, and giving you the address of your website. + +You can open your website locally by visiting [`http://localhost:8080`](http://localhost:8080) + +SQLPage should have automatically created a folder called `sqlpage` with a SQLite database file named `sqlpage.db`. This is your website's default database - don't worry, we'll learn how to connect to other databases like PostgreSQL, MySQL, or SQL Server later! + +# Your website's first SQL file + +In the root folder of your SQLPage website, create a new SQL file called `index.sql`. +Open it in a text editor that supports SQL syntax highlighting (I recommend [VSCode](https://code.visualstudio.com/)). + +The `index.sql` file will be executed every time a visitor opens your website's home page, and the results will be displayed to the visitor +using the components you specify in the file. + +Let's start with a simple `index.sql` that displays a list of popular websites: + +```sql +SELECT 'list' AS component, + 'Popular websites' AS title; + +SELECT 'Hello' AS title, + 'world' AS description, + 'https://wikipedia.org' AS link; +``` + +![screenshot of the first sql website](hello-world.png) + +The first line of the file defines the component that will be used to display the data, and properties of that component. +In this case, we use the [`list` component](/component.sql?component=list) to display a list of items. +The second line defines the data that will populate the component. +All the components you can use and their properties are documented in [SQLPage's online documentation](https://sql-page.com/documentation.sql). + +# Your database schema + +> If you already have a database populated with data, +> or if you intend to use other tools to manage your database structure, +> you can skip this section. + +The [database schema](https://en.wikipedia.org/wiki/Database_schema) for your SQLPage website +can be defined using SQL scripts located in the +**`sqlpage/migrations`** subdirectory of your website's root folder. + +For our first website, let's create a file located in `sqlpage/migrations/0001_create_users_table.sql` with the following contents: + +```sql +CREATE TABLE users ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL +); +``` + +If you need to quickly test a database schema and associated queries online, +before making any change to your database, I can recommend [sqliteonline.com](https://sqliteonline.com/) (which actually also works with Postgres, MySQL, and SQL Server). + +Please read our [**introduction to database migrations**](./migrations.sql) to +learn how to maintain your database schema in the long term. + +> **Note**: The migration system is not supported on Microsoft SQL Server databases. +> If you are using a SQL Server database, you should create your tables using a different tool, such as _SQL Server Management Studio_. + +# Connect to a custom database + +By default, SQLPage uses a [SQLite](https://www.sqlite.org/about.html) database stored in a file named `sqlpage.db` +in the `sqlpage` configuration folder. +You can change this by creating a file named `sqlpage.json` in a folder called `sqlpage`. +So, if your website's root folder is `/my_website`, you should create a file at `/my_website/sqlpage/sqlpage.json`. + +Here is an example `sqlpage.json` file: + +```sql +{ "database_url": "sqlite://:memory:" } +``` + +This will tell SQLPage to use an in-memory SQLite database instead of the default file-based database. While this means all changes to the database will be lost when you stop the SQLPage server, it's useful for quickly testing and iterating on your database schema. +If you then deploy your website online using a service like [DataPage.app](https://datapage.app), it will automatically use a persisted database instead. + +Later, when you want to deploy your website online, you can switch back to a persistent database like + +- a SQLite file with `sqlite://your-database-file.db` ([see options](https://docs.rs/sqlx-oldapi/latest/sqlx_oldapi/sqlite/struct.SqliteConnectOptions.html)), +- a PostgreSQL-compatible server with `postgres://user:password@host/database` ([see options](https://docs.rs/sqlx-oldapi/latest/sqlx_oldapi/postgres/struct.PgConnectOptions.html)), +- a MySQL-compatible server with `mysql://user:password@host/database` ([see options](https://docs.rs/sqlx-oldapi/latest/sqlx_oldapi/mysql/struct.MySqlConnectOptions.html)), +- a Microsoft SQL Server with `mssql://user:password@host/database` ([see options](https://docs.rs/sqlx-oldapi/latest/sqlx_oldapi/mssql/struct.MssqlConnectOptions.html#method.from_str), [note about named instances](https://github.com/sqlpage/SQLPage/issues/92)), +- any ODBC-compatible database like DuckDB, ClickHouse, Databricks, Snowflake, BigQuery, Oracle, Db2, and many more. See [ODBC database connection instructions](https://github.com/sqlpage/SQLPage#odbc-setup). + +> If `user` or `password` **contains special characters**, you should [**percent-encode**](https://en.wikipedia.org/wiki/Percent-encoding) them. +> +> For instance, a SQL Server database named `db` running on `localhost` port `1433` with the username `funny:user` and the password `p@ssw0rd` would be represented as +> `mssql://funny%3Auser:p%40ssw0rd@localhost:1433/db`. + +For more information about the properties that can be set in sqlpage.json, see [SQLPage's configuration documentation](https://github.com/sqlpage/SQLPage/blob/main/configuration.md#configuring-sqlpage) + +![screenshot for the full sql website folder organisation](full-website.png) + +# Use parameterized SQL queries to let users interact with your database + +### Displaying a form + +Let's create a form to let our users insert data into our database. Add the following code to your `index.sql` file: + +```sql +SELECT 'form' AS component, 'Add a user' AS title; +SELECT 'Username' as name, TRUE as required; +``` + +The first SELECT statement opens the [`form` component](https://sql-page.com/component.sql?component=form). +The second SELECT statement adds a field to the form. Since we do not specify a `type`, it will be a text field. The label displayed above the field will be the same as its name by default. + +### Handling form submission + +Nothing happens when you submit the form at the moment. Let's fix that. +Add the following below the previous code: + +```sql +INSERT INTO users (name) +SELECT :Username +WHERE :Username IS NOT NULL; +``` + +The snippet above uses an [`INSERT INTO SELECT` SQL statement](https://www.sqlite.org/lang_insert.html) to +[safely](../safety.sql) insert a new row into the `users` table when the form is submitted. +It uses a `WHERE` clause to make sure that the `INSERT` statement is only executed when the `:Username` parameter is present. +The `:Username` parameter is set to `NULL` when you initially load the page, and then SQLPage automatically sets it to the value +from the text field when the user submits the form. + +#### Parameters + +There are two types of parameters you can use in your SQL queries: + +- **URL parameters** like **`$ParameterName`**. If you add `?x=1&y=2` to the end of the URL of your page, `$x` will be set to the string `'1'` and `$y` will be set to the string `'2'`. This is useful to create links with parameters. For instance, if you have a database of products, you can create a link to a product page with the URL `product?product_id=12` (or `product.sql?product_id=12` - both work). Then, in the `product.sql` file, you can use the `$product_id` variable to get the product with the corresponding ID from your database. URL parameters are also sometimes called *query parameters*, or *GET parameters*. +- **Form parameters** like **`:ParameterName`**. They refer to the value of the field with the corresponding `name` entered by the user in a [form](/component.sql?component=form). If no form was submitted, it is set to `NULL`. Form parameters are also sometimes called *POST parameters*. + +> Note: Currently, if a `$parameter` is not present in the URL, it is first looked for in the form parameters. If it is not found there either, it is set to `NULL`. Please do not rely on this behavior, as it may change in the future. + +You can also set parameters yourself at any point in your SQL files in order to reuse +their value in several places, using the `SET ParameterName = value` syntax. +For instance, we could use the following code to save the username in uppercase: + +```sql +SET Username = UPPER(:Username); +INSERT INTO users (name) VALUES ($Username); +``` + +### Displaying data from our database + +Now, users are present in our database, but we can't see them. +Let's see how to use data from our database to populate a [list](/component.sql?component=list) component, in order to display the list of users. + +Add the following code to your `index.sql` file: + +```sql +SELECT 'list' AS component, 'Users' AS title; +SELECT name AS title, CONCAT(name, ' is a user on this website.') as description FROM users; +``` + +### Your first SQLPage website is ready! + +You can view [the full source code for this example on Github](https://github.com/sqlpage/SQLPage/tree/main/examples/simple-website-example) + +Here is a screenshot of the final result: + +![final result](final-result.png) + +To go further, have a look at [the examples](../examples/). + + +# Deploy your SQLPage website online + +### Using DataPage.app +To deploy your SQLPage website online, the easiest way is to use [DataPage.app](https://datapage.app), +a managed hosting service for SQLPage websites maintained by the same people who develop SQLPage. +Just create an account, and follow the instructions to upload your website to our servers. It will be live in seconds! + +### Manually +If you prefer to host your website yourself, you can use a cloud provider or a VPS provider. You will need to: +- Configure domain name resolution to point to your server +- Open the port you are using (8080 by default) in your server's firewall +- [Setup docker](https://github.com/sqlpage/SQLPage?tab=readme-ov-file#with-docker) or another process manager such as [systemd](https://github.com/sqlpage/SQLPage/blob/main/sqlpage.service) to start SQLPage automatically when your server boots and to keep it running +- Optionally, [setup a reverse proxy](nginx.sql) to avoid exposing SQLPage directly to the internet +- Optionally, setup a TLS certificate to enable HTTPS +- Configure connection to a cloud database or a database running on your server in [`sqlpage.json`](https://github.com/sqlpage/SQLPage/blob/main/configuration.md#configuring-sqlpage) + +# Go further + +- Check out [learnsqlpage.com](https://learnsqlpage.com) by Nick Antonaccio for an in-depth tutorial with many examples +- Read the [SQLPage documentation](/documentation.sql) to learn about all the components available in SQLPage +- Read about [SQLPage's extensions to SQL](/extensions-to-sql) for a specification of the SQL syntax you can use in SQLPage, the data types used when exchanging data with the browser and with the database, a clear explanation of how *SQLPage variables* and *SQLPage functions* work. +- Join the [SQLPage community](https://github.com/sqlpage/SQLPage/discussions) to ask questions and share your projects +- If you like videos better, check this series that shows how to build and deploy your app from scratch [SQLPage on Youtube](https://www.youtube.com/playlist?list=PLTue_qIAHxAf9fEjBY2CN0N_5XOiffOk_) \ No newline at end of file diff --git a/examples/plots tables and forms/README.md b/examples/plots tables and forms/README.md new file mode 100644 index 0000000..3f143ed --- /dev/null +++ b/examples/plots tables and forms/README.md @@ -0,0 +1,10 @@ +# SQLPage demo + +This short demo illustrates the usage of + - plots + - tables + - forms + - interactivity to filter data based on an URL parameter + - debugging using the `debug` component + + The [`index.sql`](./index.sql) page is heavily commented to explain the different components and concepts. \ No newline at end of file diff --git a/examples/plots tables and forms/docker-compose.yml b/examples/plots tables and forms/docker-compose.yml new file mode 100644 index 0000000..8d0813b --- /dev/null +++ b/examples/plots tables and forms/docker-compose.yml @@ -0,0 +1,7 @@ +services: + web: + image: lovasoa/sqlpage:main + ports: + - "8080:8080" + volumes: + - .:/var/www diff --git a/examples/plots tables and forms/index.sql b/examples/plots tables and forms/index.sql new file mode 100644 index 0000000..0a41f0a --- /dev/null +++ b/examples/plots tables and forms/index.sql @@ -0,0 +1,167 @@ +-- Welcome to SQLPage ! This is a short demonstration of a few things you can do with SQLPage +-- Using the 'shell' component at the top allows you to customize your web page, giving it a title and a description +select 'shell' as component, + 'SQLpage' as title, + '/' as link, + 'en' as lang, + 'SQLPage Demo' as description; + +-- Making a web page with SQLPage works by using a set of predefined "components" +-- and filling them with contents from the results of your SQL queries +select 'hero' as component, -- We select a component. The documentation for each component can be found on https://sql-page.com/documentation.sql + 'It works !' as title, -- 'title' is top-level parameter of the 'hero' component + 'If you can see this, then SQLPage is running correctly on your server. Congratulations! ' as description; +-- Properties can be textual, numeric, or booleans + +-- Let's start with the text component +SELECT 'text' as component, -- We can switch to another component at any time just with a select statement. + 'Get started' as title; +-- We are now inside the text component. Each row that will be returned by our SELECT queries will be a span of text +-- The text component has a property called "contents" that can be that we use to set the contents of our block of text +-- and a property called "center" that we use to center the text +SELECT 'In order to get started ' as contents; +select 'visit SQLPage''s website' as contents, + 'https://sql-page.com/' as link, + true as italics; +SELECT '. You can replace this page''s contents by creating a file named ' as contents; +SELECT 'index.sql' as contents, true as italics; +SELECT ' in the folder where sqlpage is running. ' as contents; +SELECT 'Alternatively, you can create a table called sqlpage_files in your database with the following columns: path, contents, and last_modified.' as contents; + +-- The text component also support rich text using the markdown syntax with the property "contents_md" +SELECT ' +## Rich text +You can use markdown syntax in SQLPage to make your text **bold**, *italic*, or even [add links](https://sql-page.com/). +' as contents_md; + +select 'text' as component, + 'Demo' as title; +-- We can switch to another component at any time just with a select statement. +-- Let's draw a chart +select 'chart' as component, -- selecting a different component + 'Revenue per country' as title, -- setting the component's top-level properties. The documentation for each component's properties can be found on https://sql-page.com/documentation.sql + 'bar' as type, + 'time' as xtitle, + 'price' as ytitle, + true as stacked; +-- Inside the chart component, we have access to the "series", "label", and "value" row-level properties +-- Here, we are selecting static data, but you can also use a query to a real database +select 'Russia' as series, + '2022-01' as label, + 2 as value +union +select 'Russia', + '2022-02', + 4 +union +select 'Russia', + '2022-03', + 2; +select 'Brasil' as series, + '2022-01' as label, + 4 as value +union +select 'Brasil', + '2022-03', + 1 +union +select 'Brasil', + '2022-04', + 1; +-- Let's make a new chart, this time generating the data with a more complex query +select 'chart' as component, + 'Collatz conjecture' as title, + 'area' as type; + +SELECT 'syracuse' as series, x, y +FROM ( + SELECT 0 AS x, 15 AS y UNION SELECT 1, 46 UNION SELECT 2, 23 UNION SELECT 3, 70 UNION SELECT 4, 35 UNION SELECT 5, 106 UNION SELECT 6, 53 UNION SELECT 7, 160 UNION SELECT 8, 80 UNION SELECT 9, 40 UNION SELECT 10, 20 UNION SELECT 11, 10 UNION SELECT 12, 5 +) AS syracuse ORDER BY x; + +select 'table' as component, + true as sort, + true as search; +-- The table component lets you just select your data as it is, without imposing a particular set of properties +select 'John' as "First Name", + 'Doe' as "Last Name", + 1994 as "Birth Date" +union +select 'Jane', + 'Smith', + 1989; +-- Here, things get a little more interesting. We are making a small app to learn our times table +-- We will display a set of cards, each one displaying the result of the multiplication a * b +select 'card' as component, + 5 as columns; + +WITH nums(x) AS ( + SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9 UNION SELECT 10 +) +SELECT a.x || ' times ' || b.x as title, + CASE + a.x % 4 + WHEN 0 THEN 'red' + WHEN 1 THEN 'green' + WHEN 3 THEN 'yellow' + ELSE 'blue' + END as color, + a.x || ' x ' || b.x || ' = ' || (a.x * b.x) as description, + 'This is basic math' as footer, + '?x=' || a.x as link -- This is the interesting part. Each card has a link. When you click the card, the current page is reloaded with '?x=a' appended to the end of the URL +FROM nums as a, nums as b +WHERE -- The powerful thing is here + $x IS NULL + OR -- The syntax $x allows us to extract the value 'a' when the URL ends with '?x=a'. It will be null if the URL does not contain '?x=' + b.x = CAST($x AS DECIMAL) +ORDER BY a.x, b.x; +-- So when we click the card for "a times b", we will reload the page, and display only the multiplication table of a +--------------------------- +-- FORMS -- +-- Until now, we have only read data. Let's see how we can write new data to our database +-- You can use an existing table in your database +-- or create the table by just creating a file at 'sqlpage/migrations/00_create_users.sql' +-- containing the SQL query to create the table. For this example, we will use: +-- CREATE TABLE IF NOT EXISTS users(name TEXT); + +-- Displaying a form is as easy as displaying a table; we use the "form" component +-- Let's display a form to our users +select 'form' as component, + 'Create' as validate, + 'New User' as title; +select 'number' as type, + 'age' as placeholder; +select 'First Name' as name, + true as autocomplete, + true as required, + 'We need your name for legal reasons.' as description; +select 'Last name' as name, + true as autocomplete; +select 'radio' as type, + 'favorite_food' as name, + 'banana' as value, + 'I like bananas the most' as label; +select 'radio' as type, + 'favorite_food' as name, + 'cake' as value, + 'I like cake more' as label, + 'Bananas are okay, but I prefer cake' as description; +select 'checkbox' as type, + 'checks[]' as name, + 1 as value, + 'Accept the terms and conditions' as label; +select 'checkbox' as type, + 'checks[]' as name, + 2 as value, + 'Subscribe to the newsletter' as label; + +-- We can access the values entered in the form using the syntax :xxx where 'xxx' is the name of one of the fields in the form +-- insert into users select :"First Name" where :"First Name" IS NOT NULL; +-- We don't want to add a line in the database if the page was loaded without entering a value in the form, so we add a WHERE clause +-- Let's show the users we have in our database +-- select 'list' as component, 'Users' as title; +-- select name as title from users; +-- The debug component displays the raw results returned by a query +select 'debug' as component; +select $x as x, + :"First Name" as firstName, + :checks as checks; diff --git a/examples/plots tables and forms/test.hurl b/examples/plots tables and forms/test.hurl new file mode 100644 index 0000000..8112344 --- /dev/null +++ b/examples/plots tables and forms/test.hurl @@ -0,0 +1,35 @@ +GET http://localhost:8080/ + +HTTP 200 +[Asserts] +header "Content-Type" contains "text/html" +body contains "SQLPage Demo" +body contains "Revenue per country" +body contains "John" +body contains "Jane" +body contains "1 times 1" +body contains "10 times 10" +body contains "New User" +body not contains "An error occurred" + +GET http://localhost:8080/?x=3 + +HTTP 200 +[Asserts] +body contains "1 times 3" +body contains "10 times 3" +body contains "New User" +body not contains "An error occurred" + +POST http://localhost:8080/ +Content-Type: application/x-www-form-urlencoded +``` +age=42&First%20Name=Ada&Last%20name=Lovelace&favorite_food=cake&checks%5B%5D=1&checks%5B%5D=2 +``` + +HTTP 200 +[Asserts] +body contains "Ada" +body contains "1" +body contains "2" +body not contains "An error occurred" diff --git a/examples/read-and-set-http-cookies/Dockerfile b/examples/read-and-set-http-cookies/Dockerfile new file mode 100644 index 0000000..117809e --- /dev/null +++ b/examples/read-and-set-http-cookies/Dockerfile @@ -0,0 +1,3 @@ +FROM lovasoa/sqlpage + +COPY *.sql sqlpage ./ \ No newline at end of file diff --git a/examples/read-and-set-http-cookies/README.md b/examples/read-and-set-http-cookies/README.md new file mode 100644 index 0000000..929d623 --- /dev/null +++ b/examples/read-and-set-http-cookies/README.md @@ -0,0 +1,5 @@ +# SQLPage application with custom login in Postgres + +This is a very simple example of a website that uses the SQLPage web application framework. It uses a Postgres database for storing the data. + +It lets a user log in and out, and it shows a list of the users that have logged in. \ No newline at end of file diff --git a/examples/read-and-set-http-cookies/docker-compose.yml b/examples/read-and-set-http-cookies/docker-compose.yml new file mode 100644 index 0000000..7305eda --- /dev/null +++ b/examples/read-and-set-http-cookies/docker-compose.yml @@ -0,0 +1,8 @@ +services: + web: + image: lovasoa/sqlpage:main + ports: + - "8080:8080" + volumes: + - .:/var/www + - ./sqlpage:/etc/sqlpage diff --git a/examples/read-and-set-http-cookies/index.sql b/examples/read-and-set-http-cookies/index.sql new file mode 100644 index 0000000..022d5f1 --- /dev/null +++ b/examples/read-and-set-http-cookies/index.sql @@ -0,0 +1,17 @@ +-- Sets the username cookie to the value of the username parameter +SELECT 'cookie' as component, + 'username' as name, + :username as value +WHERE :username IS NOT NULL; + +SELECT 'form' as component; +SELECT 'username' as name, + 'User Name' as label, + COALESCE(:username, sqlpage.cookie('username')) as value, + 'try leaving this page and coming back, the value should be saved in a cookie' as description; + +select 'text' as component; +select 'log out' as contents, 'logout.sql' as link; + +select 'text' as component; +select 'View the cookie from a subdirectory' as contents, 'subdirectory/read_cookies.sql' as link; diff --git a/examples/read-and-set-http-cookies/logout.sql b/examples/read-and-set-http-cookies/logout.sql new file mode 100644 index 0000000..98eba70 --- /dev/null +++ b/examples/read-and-set-http-cookies/logout.sql @@ -0,0 +1,6 @@ +-- Remove the username cookie +SELECT 'cookie' as component, + 'username' as name, + TRUE as remove; + +SELECT 'redirect' as component, 'index.sql' as link; \ No newline at end of file diff --git a/examples/read-and-set-http-cookies/sqlpage/sqlpage.json b/examples/read-and-set-http-cookies/sqlpage/sqlpage.json new file mode 100644 index 0000000..268bd02 --- /dev/null +++ b/examples/read-and-set-http-cookies/sqlpage/sqlpage.json @@ -0,0 +1,3 @@ +{ + "database_url": "sqlite://:memory:" +} diff --git a/examples/read-and-set-http-cookies/subdirectory/read_cookies.sql b/examples/read-and-set-http-cookies/subdirectory/read_cookies.sql new file mode 100644 index 0000000..8822f8f --- /dev/null +++ b/examples/read-and-set-http-cookies/subdirectory/read_cookies.sql @@ -0,0 +1,9 @@ +-- Cookies can be specific to a certain path in the website +-- This page demonstrates that a gobal cookie can be removed from a subdirectory +select 'cookie' as component, 'username' as name, true as remove, '/' as path; + +SELECT 'text' as component; +SELECT 'The value of your username cookie was: ' || + COALESCE(sqlpage.cookie('username'), 'NULL') || + '. It has now been removed. You can reload this page.' as contents; + diff --git a/examples/read-and-set-http-cookies/test.hurl b/examples/read-and-set-http-cookies/test.hurl new file mode 100644 index 0000000..ec197e3 --- /dev/null +++ b/examples/read-and-set-http-cookies/test.hurl @@ -0,0 +1,34 @@ +GET http://localhost:8080/ +HTTP 200 +[Asserts] +body contains "User Name" +body contains "View the cookie from a subdirectory" +body not contains "An error occurred" + +POST http://localhost:8080/ +[FormParams] +username: Hurl Cookie +HTTP 200 +[Asserts] +header "Set-Cookie" contains "username=Hurl%20Cookie" +body contains "Hurl Cookie" +body not contains "An error occurred" + +GET http://localhost:8080/ +HTTP 200 +[Asserts] +body contains "Hurl Cookie" +body not contains "An error occurred" + +GET http://localhost:8080/subdirectory/read_cookies.sql +HTTP 200 +[Asserts] +body contains "The value of your username cookie was: Hurl Cookie" +body contains "It has now been removed" +body not contains "An error occurred" + +GET http://localhost:8080/ +HTTP 200 +[Asserts] +body not contains "Hurl Cookie" +body not contains "An error occurred" diff --git a/examples/rich-text-editor/README.md b/examples/rich-text-editor/README.md new file mode 100644 index 0000000..eb0424c --- /dev/null +++ b/examples/rich-text-editor/README.md @@ -0,0 +1,6 @@ +# SQLPage rich text editor + +This demo shows how to build an application where users can +input and safely store rich text (with titles, bold, italics, and images). + +![image](https://github.com/user-attachments/assets/47e43ee2-b7cb-4d72-a244-4a8885b51577) diff --git a/examples/rich-text-editor/create_blog_post.sql b/examples/rich-text-editor/create_blog_post.sql new file mode 100644 index 0000000..0128912 --- /dev/null +++ b/examples/rich-text-editor/create_blog_post.sql @@ -0,0 +1,5 @@ +insert into blog_posts (title, content) +values (:title, :content) +returning + 'redirect' as component, + 'post?id=' || id as link; \ No newline at end of file diff --git a/examples/rich-text-editor/docker-compose.yml b/examples/rich-text-editor/docker-compose.yml new file mode 100644 index 0000000..7305eda --- /dev/null +++ b/examples/rich-text-editor/docker-compose.yml @@ -0,0 +1,8 @@ +services: + web: + image: lovasoa/sqlpage:main + ports: + - "8080:8080" + volumes: + - .:/var/www + - ./sqlpage:/etc/sqlpage diff --git a/examples/rich-text-editor/edit.sql b/examples/rich-text-editor/edit.sql new file mode 100644 index 0000000..ff44c06 --- /dev/null +++ b/examples/rich-text-editor/edit.sql @@ -0,0 +1,35 @@ +insert or replace into blog_posts (id, title, content) +select $id, :title, :content +where $id is not null and :title is not null and :content is not null +returning 'redirect' as component, 'post?id=' || $id as link; + +select 'shell' as component, + 'Edit blog post' as title, + '/rich_text_editor.js' as javascript_module; + + +select 'form' as component, 'Update' as validate; + +with post as ( + select title, content + from blog_posts + where id = $id +), +fields as ( + select json_object( + 'name', 'title', + 'label', 'Blog post title', + 'value', title + ) as props + from post + union all + select json_object( + 'name', 'content', + 'type', 'textarea', + 'label', 'Your blog post here', + 'value', content, + 'required', true + ) + from post +) +select 'dynamic' as component, json_group_array(props) as properties from fields; \ No newline at end of file diff --git a/examples/rich-text-editor/index.sql b/examples/rich-text-editor/index.sql new file mode 100644 index 0000000..1ca5daf --- /dev/null +++ b/examples/rich-text-editor/index.sql @@ -0,0 +1,18 @@ +select 'shell' as component, + 'Rich text editor' as title, + '/rich_text_editor.js' as javascript_module; + + +select 'form' as component, + 'Create a new blog post' as title, + 'create_blog_post' as action, + 'Create' as validate; + +select 'title' as name, 'Blog post title' as label, 'My new post' as value; +select 'content' as name, 'textarea' as type, 'Your blog post here' as label, 'Your blog post here' as value, true as required, $disabled is not null as disabled; + +select 'list' as component, + 'Blog posts' as title; + +select title, sqlpage.link('post', json_object('id', id)) as link +from blog_posts; diff --git a/examples/rich-text-editor/post.sql b/examples/rich-text-editor/post.sql new file mode 100644 index 0000000..1e8f85d --- /dev/null +++ b/examples/rich-text-editor/post.sql @@ -0,0 +1,16 @@ +select 'shell' as component, + title +from blog_posts +where id = $id; + +select 'text' as component, + true as article, + content as contents_md +from blog_posts +where id = $id; + +select 'list' as component; +select + 'Edit' as title, + 'pencil' as icon, + 'edit?id=' || $id as link; diff --git a/examples/rich-text-editor/rich_text_editor.js b/examples/rich-text-editor/rich_text_editor.js new file mode 100644 index 0000000..3359334 --- /dev/null +++ b/examples/rich-text-editor/rich_text_editor.js @@ -0,0 +1,903 @@ +import { fromMarkdown } from "https://esm.sh/mdast-util-from-markdown@2.0.0"; +import { toMarkdown as mdastUtilToMarkdown } from "https://esm.sh/mdast-util-to-markdown@2.1.2"; +import Quill from "https://esm.sh/quill@2.0.3"; + +/** + * @typedef {Object} QuillAttributes + * @property {boolean} [bold] - Whether the text is bold. + * @property {boolean} [italic] - Whether the text is italic. + * @property {string} [link] - URL if the text is a link. + * @property {number} [header] - Header level (1-3). + * @property {string} [list] - List type ('ordered' or 'bullet'). + * @property {boolean} [blockquote] - Whether the text is in a blockquote. + * @property {string} [code-block] - Code language if in a code block. + * @property {string} [alt] - Alt text for images. + */ + +/** + * @typedef {Object} QuillOperation + * @property {string|Object} [insert] - Content to insert (string or object with image URL). + * @property {number} [delete] - Number of characters to delete. + * @property {number} [retain] - Number of characters to retain. + * @property {QuillAttributes} [attributes] - Formatting attributes. + */ + +/** + * @typedef {Object} QuillDelta + * @property {Array} ops - Array of operations in the delta. + */ + +/** + * Converts Quill Delta object to a Markdown string using mdast. + * @param {QuillDelta} delta - Quill Delta object (https://quilljs.com/docs/delta/). + * @returns {string} - Markdown representation. + */ +function deltaToMarkdown(delta) { + const mdastTree = deltaToMdast(delta); + const options = { + bullet: "*", + listItemIndent: "one", + handlers: {}, + unknownHandler: (node) => { + console.warn(`Unknown node type encountered: ${node.type}`, node); + return false; + }, + }; + return mdastUtilToMarkdown(mdastTree, options); +} + +/** + * Creates a div to replace the textarea and prepares it for Quill. + * @param {HTMLTextAreaElement} textarea - The original textarea. + * @returns {HTMLDivElement} - The div element created for the Quill editor. + */ +function createAndReplaceTextarea(textarea) { + const editorDiv = document.createElement("div"); + editorDiv.className = "mb-3"; + editorDiv.style.height = "250px"; + + const label = textarea.closest("label"); + if (!label) { + textarea.parentNode.insertBefore(editorDiv, textarea); + } else { + label.parentNode.insertBefore(editorDiv, label.nextSibling); + } + // Hide the original textarea, but keep it focusable for validation + textarea.style = "transform: scale(0); position: absolute; opacity: 0;"; + return editorDiv; +} + +/** + * Returns the toolbar options array configured for Markdown compatibility. + * @returns {Array>} - Quill toolbar options. + */ +function getMarkdownToolbarOptions() { + return [ + [{ header: 1 }, { header: 2 }, { header: 3 }], + ["bold", "italic", "code"], + ["link", "image", "blockquote", "code-block"], + [{ list: "ordered" }, { list: "bullet" }], + ["clean"], + ]; +} + +/** + * Initializes a Quill editor instance on a given div. + * @param {HTMLDivElement} editorDiv - The div element for the editor. + * @param {Array>} toolbarOptions - The toolbar configuration. + * @param {string} initialValue - The initial content for the editor. + * @returns {Quill} - The initialized Quill instance. + */ +function initializeQuillEditor( + editorDiv, + toolbarOptions, + initialValue, + readOnly, +) { + const quill = new Quill(editorDiv, { + theme: "snow", + modules: { + toolbar: toolbarOptions, + }, + readOnly: readOnly, + formats: [ + "bold", + "italic", + "link", + "header", + "list", + "blockquote", + "code", + "code-block", + "image", + ], + }); + if (initialValue) { + const delta = markdownToDelta(initialValue); + quill.setContents(delta); + } + return quill; +} + +/** + * Converts Markdown string to a Quill Delta object. + * @param {string} markdown - The markdown string to convert. + * @returns {QuillDelta} - Quill Delta representation. + */ +function markdownToDelta(markdown) { + try { + const mdastTree = fromMarkdown(markdown); + return mdastToDelta(mdastTree); + } catch (error) { + console.error("Error parsing markdown:", error); + return { ops: [{ insert: markdown }] }; + } +} + +/** + * Converts MDAST to Quill Delta. + * @param {MdastNode} tree - The MDAST tree to convert. + * @returns {QuillDelta} - Quill Delta representation. + */ +function mdastToDelta(tree) { + const delta = { ops: [] }; + if (!tree || !tree.children) return delta; + + for (const node of tree.children) { + traverseMdastNode(node, delta); + } + + return delta; +} + +/** + * Recursively traverse MDAST nodes and convert to Delta operations. + * @param {MdastNode} node - The MDAST node to process. + * @param {QuillDelta} delta - The Delta object to append operations to. + * @param {QuillAttributes} [attributes={}] - The current attributes to apply. + */ +function traverseMdastNode(node, delta, attributes = {}) { + if (!node) return; + + switch (node.type) { + case "root": + for (const child of node.children || []) { + traverseMdastNode(child, delta); + } + break; + + case "paragraph": { + for (const child of node.children || []) { + traverseMdastNode(child, delta, attributes); + } + const pLineAttributes = {}; + if (attributes.blockquote) { + pLineAttributes.blockquote = true; + } + delta.ops.push({ insert: "\n", attributes: pLineAttributes }); + break; + } + + case "heading": { + const headingContentAttributes = { ...attributes, header: node.depth }; + for (const child of node.children || []) { + traverseMdastNode(child, delta, headingContentAttributes); + } + const headingLineAttributes = { header: node.depth }; + if (attributes.blockquote) { + headingLineAttributes.blockquote = true; + } + delta.ops.push({ insert: "\n", attributes: headingLineAttributes }); + break; + } + + case "text": + delta.ops.push({ insert: node.value || "", attributes }); + break; + + case "strong": + for (const child of node.children || []) { + traverseMdastNode(child, delta, { ...attributes, bold: true }); + } + break; + + case "emphasis": + for (const child of node.children || []) { + traverseMdastNode(child, delta, { ...attributes, italic: true }); + } + break; + + case "link": + for (const child of node.children || []) { + traverseMdastNode(child, delta, { ...attributes, link: node.url }); + } + break; + + case "image": + delta.ops.push({ + insert: { image: node.url }, + attributes: { alt: node.alt || "" }, + }); + break; + + case "list": + for (const child of node.children || []) { + traverseMdastNode(child, delta, { + ...attributes, + list: node.ordered ? "ordered" : "bullet", + }); + } + break; + + case "listItem": { + // biome-ignore lint/correctness/noUnusedVariables: object destructuring with a spread + const { list, ...listItemChildrenAttributes } = attributes; + + for (const child of node.children || []) { + traverseMdastNode(child, delta, listItemChildrenAttributes); + } + + // Attributes for the listItem's newline (e.g., { list: 'bullet', blockquote: true }) + // are in `attributes` passed to this `listItem` case. + { + const lastOp = delta.ops[delta.ops.length - 1]; + if (lastOp && lastOp.insert === "\n") { + lastOp.attributes = { ...lastOp.attributes, ...attributes }; + } else { + delta.ops.push({ insert: "\n", attributes }); + } + } + break; + } + + case "blockquote": + for (const child of node.children || []) { + traverseMdastNode(child, delta, { ...attributes, blockquote: true }); + } + break; + + case "code": { + // mdast 'code' is a block + const codeBlockLineFormat = { "code-block": node.lang || true }; + if (attributes.blockquote) { + codeBlockLineFormat.blockquote = true; + } + + const textInCodeAttributes = {}; + if (attributes.blockquote) { + // Text lines also get blockquote if active + textInCodeAttributes.blockquote = true; + } + + const lines = (node.value || "").split("\n"); + for (const lineText of lines) { + delta.ops.push({ insert: lineText, attributes: textInCodeAttributes }); + delta.ops.push({ insert: "\n", attributes: codeBlockLineFormat }); + } + break; + } + + case "inlineCode": + delta.ops.push({ + insert: node.value || "", + attributes: { ...attributes, code: true }, + }); + break; + + default: + if (node.children) { + for (const child of node.children) { + traverseMdastNode(child, delta, attributes); + } + } else if (node.value) { + delta.ops.push({ insert: node.value, attributes }); + } + } +} + +/** + * Attaches a submit event listener to the form to update the hidden textarea. + * @param {HTMLFormElement|null} form - The form containing the editor. + * @param {HTMLTextAreaElement} textarea - The original (hidden) textarea. + * @param {Quill} quill - The Quill editor instance. + * @returns {void} + */ +function updateTextareaOnSubmit(form, textarea, quill) { + if (!form) { + console.warn( + "Textarea not inside a form, submission handling skipped for:", + textarea.name || textarea.id, + ); + return; + } + form.addEventListener("submit", (event) => { + const delta = quill.getContents(); + const markdownContent = deltaToMarkdown(delta); + textarea.value = markdownContent; + console.log( + `${textarea.name}:\n${markdownContent}\ntransformed from delta:\n${JSON.stringify(delta, null, 2)}`, + ); + if (textarea.required && !markdownContent) { + textarea.setCustomValidity(`${textarea.name} cannot be empty`); + quill.once("text-change", (delta) => { + textarea.value = deltaToMarkdown(delta); + textarea.setCustomValidity(""); + }); + quill.focus(); + event.preventDefault(); + } + }); +} + +/** + * Loads the Quill CSS stylesheet. + * @returns {void} + */ +function loadQuillStylesheet() { + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = "https://esm.sh/quill@2.0.3/dist/quill.snow.css"; + document.head.appendChild(link); +} + +/** + * Handles errors during editor initialization. + * @param {HTMLTextAreaElement} textarea - The textarea that failed initialization. + * @param {Error} error - The error that occurred. + * @returns {void} + */ +function handleEditorInitError(textarea, error) { + console.error("Failed to initialize Quill for textarea:", textarea, error); + textarea.style.display = ""; + const errorMsg = document.createElement("p"); + errorMsg.textContent = "Failed to load rich text editor."; + errorMsg.style.color = "red"; + textarea.parentNode.insertBefore(errorMsg, textarea.nextSibling); +} + +/** + * Sets up a single editor for a textarea. + * @param {HTMLTextAreaElement} textarea - The textarea to replace with an editor. + * @param {Array>} toolbarOptions - The toolbar configuration. + * @returns {boolean} - Whether the setup was successful. + */ +function setupSingleEditor(textarea, toolbarOptions) { + if (textarea.dataset.quillInitialized === "true") { + return false; + } + + try { + const initialValue = textarea.value; + const readOnly = textarea.readOnly || textarea.disabled; + const form = textarea.closest("form"); + const editorDiv = createAndReplaceTextarea(textarea); + const quill = initializeQuillEditor( + editorDiv, + toolbarOptions, + initialValue, + readOnly, + ); + updateTextareaOnSubmit(form, textarea, quill); + textarea.dataset.quillInitialized = "true"; + return true; + } catch (error) { + handleEditorInitError(textarea, error); + return false; + } +} + +/** + * Initializes Quill editors for all textareas in the document. + * @returns {void} + */ +function initializeEditors() { + loadQuillStylesheet(); + + const textareas = document.getElementsByTagName("textarea"); + if (textareas.length === 0) { + return; + } + + const toolbarOptions = getMarkdownToolbarOptions(); + let initializedCount = 0; + + for (const textarea of textareas) { + if (setupSingleEditor(textarea, toolbarOptions)) { + initializedCount++; + } + } + + if (initializedCount > 0) { + console.log( + `Successfully initialized Quill for ${initializedCount} textareas.`, + ); + } +} + +// MDAST conversion functions +/** + * @typedef {Object} MdastNode + * @property {string} type - The type of the node. + * @property {Array} [children] - Child nodes. + * @property {string} [value] - Text value for text nodes. + * @property {string} [url] - URL for link and image nodes. + * @property {string} [title] - Title for image nodes. + * @property {string} [alt] - Alt text for image nodes. + * @property {number} [depth] - Depth for heading nodes. + * @property {boolean} [ordered] - Whether the list is ordered. + * @property {boolean} [spread] - Whether the list is spread. + * @property {string} [lang] - Language for code blocks. + */ + +/** + * Converts a Quill Delta to a MDAST (Markdown Abstract Syntax Tree). + * @param {QuillDelta} delta - The Quill Delta to convert. + * @returns {MdastNode} - The root MDAST node. + */ +function deltaToMdast(delta) { + const mdast = createRootNode(); + /** @type {MdastNode|null} */ + let currentParagraph = null; + /** @type {MdastNode|null} */ + let currentList = null; + let textBuffer = ""; + + for (const op of delta.ops) { + if (isImageInsert(op)) { + if (!currentParagraph) { + currentParagraph = createParagraphNode(); + } + currentParagraph.children.push(createImageNode(op)); + } + if (typeof op.insert !== "string") continue; + + const text = op.insert; + const attributes = op.attributes || {}; + + // Handle newlines within text content + if (text.includes("\n") && text !== "\n") { + const lines = text.split("\n"); + + // Process all lines except the last one as complete lines + for (let i = 0; i < lines.length - 1; i++) { + const line = lines[i]; + if (line.length > 0) { + // Add text to current paragraph + if (!currentParagraph) { + currentParagraph = createParagraphNode(); + } + const nodes = createTextNodes(line, attributes); + currentParagraph.children.push(...nodes); + textBuffer = line; + } + + // Process line break + currentList = processLineBreak( + mdast, + currentParagraph, + attributes, + textBuffer, + currentList, + ); + + currentParagraph = null; + textBuffer = ""; + } + + // Add the last line to the buffer without processing the line break yet + const lastLine = lines[lines.length - 1]; + if (lastLine.length > 0) { + if (!currentParagraph) { + currentParagraph = createParagraphNode(); + } + const nodes = createTextNodes(lastLine, attributes); + currentParagraph.children.push(...nodes); + textBuffer = lastLine; + } + + continue; + } + + if (text === "\n") { + currentList = processLineBreak( + mdast, + currentParagraph, + attributes, + textBuffer, + currentList, + ); + + // Reset paragraph and buffer after processing line break + currentParagraph = null; + textBuffer = ""; + continue; + } + + // Process regular text + const nodes = createTextNodes(text, attributes); + + if (!currentParagraph) { + currentParagraph = createParagraphNode(); + } + + textBuffer += text; + currentParagraph.children.push(...nodes); + } + + if (currentParagraph) { + mdast.children.push(currentParagraph); + } + + return mdast; +} + +/** + * Creates a root MDAST node. + * @returns {MdastNode} - The root node. + */ +function createRootNode() { + return { + type: "root", + children: [], + }; +} + +/** + * Creates a paragraph MDAST node. + * @returns {MdastNode} - The paragraph node. + */ +function createParagraphNode() { + return { + type: "paragraph", + children: [], + }; +} + +/** + * Checks if an operation is an image insertion. + * @param {Object} op - The operation to check. + * @returns {boolean} - Whether the operation is an image insertion. + */ +function isImageInsert(op) { + return typeof op.insert === "object" && op.insert.image; +} + +/** + * Creates an image MDAST node. + * @param {Object} op - The operation containing the image. + * @returns {MdastNode} - The image node. + */ +function createImageNode(op) { + return { + type: "image", + url: op.insert.image, + title: op.attributes?.alt || "", + alt: op.attributes?.alt || "", + }; +} + +/** + * Creates a text MDAST node with optional formatting. + * @param {string} text - The text content. + * @param {Object} attributes - The formatting attributes. + * @returns {MdastNode[]} - The formatted text nodes. + */ +function createTextNodes(text, attributes) { + let nodes = text.split("\n").flatMap((value, i) => [ + ...(i > 0 ? [{ type: "break" }] : []), + { + type: "text", + value, + }, + ]); + + if (attributes.bold) { + nodes = [wrapNodesWith(nodes, "strong")]; + } + + if (attributes.italic) { + nodes = [wrapNodesWith(nodes, "emphasis")]; + } + + if (attributes.link) { + nodes = [{ ...wrapNodesWith(nodes, "link"), url: attributes.link }]; + } + + return nodes; +} + +/** + * Wraps a node with a formatting container. + * @param {MdastNode[]} children - The node to wrap. + * @param {string} type - The type of container. + * @returns {MdastNode} - The wrapped node. + */ +function wrapNodesWith(children, type) { + return { + type: type, + children, + }; +} + +/** + * Processes a line break in the Delta. + * @param {MdastNode} mdast - The root MDAST node. + * @param {MdastNode|null} currentParagraph - The current paragraph being built. + * @param {Object} attributes - The attributes for the line. + * @param {string} textBuffer - The text buffer for the current line. + * @param {MdastNode|null} currentList - The current list being built. + * @returns {MdastNode|null} - The updated current list. + */ +function processLineBreak( + mdast, + currentParagraph, + attributes, + textBuffer, + currentList, +) { + if (!currentParagraph) { + return handleEmptyLineWithAttributes(mdast, attributes, currentList); + } + + if (attributes.header) { + processHeaderLineBreak(mdast, textBuffer, attributes); + return null; + } + + if (attributes["code-block"]) { + processCodeBlockLineBreak(mdast, textBuffer, attributes); + return currentList; + } + + if (attributes.list) { + return processListLineBreak( + mdast, + currentParagraph, + attributes, + currentList, + ); + } + + if (attributes.blockquote) { + processBlockquoteLineBreak(mdast, currentParagraph); + return currentList; + } + + // Default case: regular paragraph + mdast.children.push(currentParagraph); + return null; +} + +/** + * Handles an empty line with special attributes. + * @param {MdastNode} mdast - The root MDAST node. + * @param {Object} attributes - The attributes for the line. + * @param {MdastNode|null} currentList - The current list being built. + * @returns {MdastNode|null} - The updated current list. + */ +function handleEmptyLineWithAttributes(mdast, attributes, currentList) { + if (attributes["code-block"]) { + mdast.children.push(createEmptyCodeBlock(attributes)); + return currentList; + } + + if (attributes.list) { + const list = ensureList(mdast, attributes, currentList); + list.children.push(createEmptyListItem()); + return list; + } + + if (attributes.blockquote) { + mdast.children.push(createEmptyBlockquote()); + return currentList; + } + + return null; +} + +/** + * Creates an empty code block MDAST node. + * @param {Object} attributes - The attributes for the code block. + * @returns {MdastNode} - The code block node. + */ +function createEmptyCodeBlock(attributes) { + return { + type: "code", + value: "", + lang: + attributes["code-block"] === "plain" ? null : attributes["code-block"], + }; +} + +/** + * Creates an empty list item MDAST node. + * @returns {MdastNode} - The list item node. + */ +function createEmptyListItem() { + return { + type: "listItem", + spread: false, + children: [{ type: "paragraph", children: [] }], + }; +} + +/** + * Creates an empty blockquote MDAST node. + * @returns {MdastNode} - The blockquote node. + */ +function createEmptyBlockquote() { + return { + type: "blockquote", + children: [{ type: "paragraph", children: [] }], + }; +} + +/** + * Processes a header line break. + * @param {MdastNode} mdast - The root MDAST node. + * @param {string} textBuffer - The text buffer for the current line. + * @param {Object} attributes - The attributes for the line. + * @returns {void} + */ +function processHeaderLineBreak(mdast, textBuffer, attributes) { + const lines = textBuffer.split("\n"); + + if (lines.length > 1) { + const lastLine = lines.pop(); + const previousLines = lines.join("\n"); + + if (previousLines) { + mdast.children.push({ + type: "paragraph", + children: [{ type: "text", value: previousLines }], + }); + } + + mdast.children.push({ + type: "heading", + depth: attributes.header, + children: [{ type: "text", value: lastLine }], + }); + } else { + mdast.children.push({ + type: "heading", + depth: attributes.header, + children: [{ type: "text", value: textBuffer }], + }); + } +} + +/** + * Processes a code block line break. + * @param {MdastNode} mdast - The root MDAST node. + * @param {string} textBuffer - The text buffer for the current line. + * @param {Object} attributes - The attributes for the line. + * @returns {void} + */ +function processCodeBlockLineBreak(mdast, textBuffer, attributes) { + const lang = + attributes["code-block"] === "plain" ? null : attributes["code-block"]; + + // Find the last code block with the same language + let lastCodeBlock = null; + for (let i = mdast.children.length - 1; i >= 0; i--) { + const child = mdast.children[i]; + if (child.type === "code" && child.lang === lang) { + lastCodeBlock = child; + break; + } + } + + if (lastCodeBlock) { + // Append to existing code block with same language + lastCodeBlock.value += `\n${textBuffer}`; + } else { + // Create new code block + mdast.children.push({ + type: "code", + value: textBuffer, + lang, + }); + } +} + +/** + * Ensures a list exists in the MDAST. + * @param {MdastNode} mdast - The root MDAST node. + * @param {Object} attributes - The attributes for the line. + * @param {MdastNode|null} currentList - The current list being built. + * @returns {MdastNode} - The list node. + */ +function ensureList(mdast, attributes, currentList) { + const isOrderedList = attributes.list === "ordered"; + + // If there's no current list or the list type doesn't match + if (!currentList || currentList.ordered !== isOrderedList) { + // Check if the last child is a list of the correct type + const lastChild = mdast.children[mdast.children.length - 1]; + if ( + lastChild && + lastChild.type === "list" && + lastChild.ordered === isOrderedList + ) { + // Use the last list if it matches the type + return lastChild; + } + + // Create a new list + const newList = { + type: "list", + ordered: isOrderedList, + spread: false, + children: [], + }; + mdast.children.push(newList); + return newList; + } + + return currentList; +} + +/** + * Processes a list line break. + * @param {MdastNode} mdast - The root MDAST node. + * @param {MdastNode} currentParagraph - The current paragraph being built. + * @param {Object} attributes - The attributes for the line. + * @param {MdastNode|null} currentList - The current list being built. + * @returns {MdastNode} - The updated list node. + */ +function processListLineBreak( + mdast, + currentParagraph, + attributes, + currentList, +) { + const list = ensureList(mdast, attributes, currentList); + + // Check if this list item already exists to avoid duplication + const paragraphContent = JSON.stringify(currentParagraph.children); + const isDuplicate = list.children.some( + (item) => + item.children?.length === 1 && + JSON.stringify(item.children[0].children) === paragraphContent, + ); + + if (!isDuplicate) { + const listItem = { + type: "listItem", + spread: false, + children: [currentParagraph], + }; + + list.children.push(listItem); + } + + return list; +} + +/** + * Processes a blockquote line break. + * @param {MdastNode} mdast - The root MDAST node. + * @param {MdastNode} currentParagraph - The current paragraph being built. + * @returns {void} + */ +function processBlockquoteLineBreak(mdast, currentParagraph) { + // Look for an existing blockquote with identical content to avoid duplication + const paragraphContent = JSON.stringify(currentParagraph.children); + const existingBlockquote = mdast.children.find( + (child) => + child.type === "blockquote" && + child.children?.length === 1 && + JSON.stringify(child.children[0].children) === paragraphContent, + ); + + if (!existingBlockquote) { + mdast.children.push({ + type: "blockquote", + children: [currentParagraph], + }); + } +} + +// Main execution +document.addEventListener("DOMContentLoaded", initializeEditors); diff --git a/examples/rich-text-editor/sqlpage/migrations/01_blog_posts.sql b/examples/rich-text-editor/sqlpage/migrations/01_blog_posts.sql new file mode 100644 index 0000000..c81195f --- /dev/null +++ b/examples/rich-text-editor/sqlpage/migrations/01_blog_posts.sql @@ -0,0 +1,5 @@ +create table blog_posts ( + id integer primary key autoincrement, + title text not null, + content text not null +); \ No newline at end of file diff --git a/examples/rich-text-editor/test.hurl b/examples/rich-text-editor/test.hurl new file mode 100644 index 0000000..29c64bc --- /dev/null +++ b/examples/rich-text-editor/test.hurl @@ -0,0 +1,41 @@ +GET http://localhost:8080 +HTTP 200 +[Asserts] +body contains "Rich text editor" +body contains "Create a new blog post" +body contains "Blog posts" +body not contains "An error occurred" + +POST http://localhost:8080/create_blog_post +[FormParams] +title: Hurl Rich Post +content: Hurl **rich** content +HTTP 302 +[Captures] +location: header "Location" +[Asserts] +header "Location" matches "^post\\?id=\\d+$" + +GET http://localhost:8080/{{location}} +HTTP 200 +[Asserts] +body contains "Hurl Rich Post" +body contains "Hurl" +body contains "rich" +body contains "Edit" +body not contains "An error occurred" + +POST http://localhost:8080/edit?id=1 +[FormParams] +title: Hurl Updated Post +content: Updated rich content +HTTP 302 +[Asserts] +header "Location" == "post?id=1" + +GET http://localhost:8080/post?id=1 +HTTP 200 +[Asserts] +body contains "Hurl Updated Post" +body contains "Updated rich content" +body not contains "An error occurred" diff --git a/examples/roundest_pokemon_rating/Dockerfile b/examples/roundest_pokemon_rating/Dockerfile new file mode 100644 index 0000000..3e67314 --- /dev/null +++ b/examples/roundest_pokemon_rating/Dockerfile @@ -0,0 +1,5 @@ +FROM lovasoa/sqlpage:latest + +COPY sqlpage /etc/sqlpage +COPY src /var/www +ENV DATABASE_URL=sqlite:///tmp/pokemon.db?mode=rwc diff --git a/examples/roundest_pokemon_rating/README.md b/examples/roundest_pokemon_rating/README.md new file mode 100644 index 0000000..e9e44a2 --- /dev/null +++ b/examples/roundest_pokemon_rating/README.md @@ -0,0 +1,27 @@ +# Roundest (SQLPage Version) + +This is a simple web app that allows you to vote on which Pokemon is the most round. + +| Vote UI | Results UI | +| --- | --- | +| ![vote ui screenshot](screenshots/vote.png) | ![results ui](screenshots/results.png) | + +It demonstrates how to build an entirely custom web app, +without using any of the pre-built components of SQLPage. + +All the custom components are in the [`sqlpage/templates/`](./sqlpage/templates/) folder. + +## Running the app + +### Using an installed version of SQLPage + +``` +sqlpage --web-root src +``` + +### Using Docker + +``` +docker build -t roundest-sqlpage . +docker run -p 8080:8080 -it roundest-sqlpage +``` diff --git a/examples/roundest_pokemon_rating/docker-compose.yml b/examples/roundest_pokemon_rating/docker-compose.yml new file mode 100644 index 0000000..8e46ffc --- /dev/null +++ b/examples/roundest_pokemon_rating/docker-compose.yml @@ -0,0 +1,5 @@ +services: + web: + build: . + ports: + - "8080:8080" diff --git a/examples/roundest_pokemon_rating/screenshots/results.png b/examples/roundest_pokemon_rating/screenshots/results.png new file mode 100644 index 0000000..03d23c1 Binary files /dev/null and b/examples/roundest_pokemon_rating/screenshots/results.png differ diff --git a/examples/roundest_pokemon_rating/screenshots/vote.png b/examples/roundest_pokemon_rating/screenshots/vote.png new file mode 100644 index 0000000..4f55223 Binary files /dev/null and b/examples/roundest_pokemon_rating/screenshots/vote.png differ diff --git a/examples/roundest_pokemon_rating/sqlpage/migrations/0000_pokemon_table.sql b/examples/roundest_pokemon_rating/sqlpage/migrations/0000_pokemon_table.sql new file mode 100644 index 0000000..6c03f69 --- /dev/null +++ b/examples/roundest_pokemon_rating/sqlpage/migrations/0000_pokemon_table.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS pokemon ( + dex_id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + up_votes INTEGER DEFAULT 0, + down_votes INTEGER DEFAULT 0 +); \ No newline at end of file diff --git a/examples/roundest_pokemon_rating/sqlpage/templates/pokemon.handlebars b/examples/roundest_pokemon_rating/sqlpage/templates/pokemon.handlebars new file mode 100644 index 0000000..8aa1fe8 --- /dev/null +++ b/examples/roundest_pokemon_rating/sqlpage/templates/pokemon.handlebars @@ -0,0 +1,30 @@ +
+
+ {{#each_row}} +
+ + {{name}} +
+ #{{dexNumber}} +

{{name}}

+
+ +
+
+
+ {{/each_row}} +
+
\ No newline at end of file diff --git a/examples/roundest_pokemon_rating/sqlpage/templates/results.handlebars b/examples/roundest_pokemon_rating/sqlpage/templates/results.handlebars new file mode 100644 index 0000000..895ea77 --- /dev/null +++ b/examples/roundest_pokemon_rating/sqlpage/templates/results.handlebars @@ -0,0 +1,29 @@ +
+
+ {{#each_row}} +
+
+
+ #{{rank}} +
+ + {{name}} + +
+
#{{dex_id}}
+

{{name}}

+
+ +
+
+ {{win_percentage}}% +
+
+ {{up_votes}}W - {{down_votes}}L +
+
+
+
+ {{/each_row}} +
+
diff --git a/examples/roundest_pokemon_rating/sqlpage/templates/shell.handlebars b/examples/roundest_pokemon_rating/sqlpage/templates/shell.handlebars new file mode 100644 index 0000000..69e61e8 --- /dev/null +++ b/examples/roundest_pokemon_rating/sqlpage/templates/shell.handlebars @@ -0,0 +1,39 @@ + + + + + +
+ +
+ +
{{~#each_row~}}{{~/each_row~}}
+ + + + \ No newline at end of file diff --git a/examples/roundest_pokemon_rating/src/favicon.ico b/examples/roundest_pokemon_rating/src/favicon.ico new file mode 100644 index 0000000..c7a8e9a Binary files /dev/null and b/examples/roundest_pokemon_rating/src/favicon.ico differ diff --git a/examples/roundest_pokemon_rating/src/index.sql b/examples/roundest_pokemon_rating/src/index.sql new file mode 100644 index 0000000..cba0da7 --- /dev/null +++ b/examples/roundest_pokemon_rating/src/index.sql @@ -0,0 +1,8 @@ +select 'redirect' as component, '/populate.sql' as link +where not exists(select 1 from pokemon); + +select 'pokemon' as component; + +select dex_id as dexNumber, name +from pokemon +order by random() limit 2; diff --git a/examples/roundest_pokemon_rating/src/populate.sql b/examples/roundest_pokemon_rating/src/populate.sql new file mode 100644 index 0000000..039802e --- /dev/null +++ b/examples/roundest_pokemon_rating/src/populate.sql @@ -0,0 +1,12 @@ +-- This updates our pokemon table with fresh data from pokeapi.co +insert into pokemon (dex_id, name) +select + cast(rtrim(substr(value->>'url', + length('https://pokeapi.co/api/v2/pokemon/') + 1), + '/') as integer) as dex_id, + value->>'name' as name +from json_each( + sqlpage.fetch('https://pokeapi.co/api/v2/pokemon?limit=100000&offset=0') -> 'results' +); + +select 'redirect' as component, '/' as link; \ No newline at end of file diff --git a/examples/roundest_pokemon_rating/src/results.sql b/examples/roundest_pokemon_rating/src/results.sql new file mode 100644 index 0000000..676f719 --- /dev/null +++ b/examples/roundest_pokemon_rating/src/results.sql @@ -0,0 +1,15 @@ +select 'results' as component; + +with ranked as ( + select + *, + case + when (up_votes + down_votes) = 0 then 0 + else 100 * up_votes / (up_votes + down_votes) + end as win_percentage, + up_votes - down_votes as score + from pokemon +) +select *, rank() over (order by score desc) as rank +from ranked +order by win_percentage desc, score desc; diff --git a/examples/roundest_pokemon_rating/src/vote.sql b/examples/roundest_pokemon_rating/src/vote.sql new file mode 100644 index 0000000..3929517 --- /dev/null +++ b/examples/roundest_pokemon_rating/src/vote.sql @@ -0,0 +1,7 @@ +update pokemon +set + up_votes = up_votes + (dex_id = $voted), + down_votes = down_votes + (dex_id != $voted) +where dex_id IN (:option_0, :option_1); + +select 'redirect' as component, '/' as link; diff --git a/examples/roundest_pokemon_rating/test.hurl b/examples/roundest_pokemon_rating/test.hurl new file mode 100644 index 0000000..e8d1206 --- /dev/null +++ b/examples/roundest_pokemon_rating/test.hurl @@ -0,0 +1,7 @@ +GET http://localhost:8080/ +HTTP 302 +[Asserts] +header "Location" == "/populate.sql" + +GET http://localhost:8080/favicon.ico +HTTP 200 diff --git a/examples/sending emails/README.md b/examples/sending emails/README.md new file mode 100644 index 0000000..e45b1b6 --- /dev/null +++ b/examples/sending emails/README.md @@ -0,0 +1,76 @@ +# Sending Emails with SQLPage + +SQLPage lets you interact with any email service through their API, +using the [`sqlpage.fetch` function](https://sql-page.com/functions.sql?function=fetch). + +## Why Use an Email Service? + +Sending emails directly from your server can be challenging: +- Many ISPs block direct email sending to prevent spam +- Email deliverability requires proper setup of SPF, DKIM, and DMARC records +- Managing bounce handling and spam complaints is complex +- Direct sending can impact your server's IP reputation + +Email services solve these problems by providing reliable APIs for sending emails while handling deliverability, tracking, and compliance. + +## Popular Email Services + +- [Mailgun](https://www.mailgun.com/) - Developer-friendly, great for transactional emails +- [SendGrid](https://sendgrid.com/) - Powerful features, owned by Twilio +- [Amazon SES](https://aws.amazon.com/ses/) - Cost-effective for high volume +- [Postmark](https://postmarkapp.com/) - Focused on transactional email delivery +- [SMTP2GO](https://www.smtp2go.com/) - Simple SMTP service with API options + +## Example: Sending Emails with Mailgun + +Here's a complete example using Mailgun's API to send emails through SQLPage: + +### [`email.sql`](./email.sql) +```sql +-- Configure the email request +set email_request = json_object( + 'url', 'https://api.mailgun.net/v3/' || sqlpage.environment_variable('MAILGUN_DOMAIN') || '/messages', + 'method', 'POST', + 'headers', json_object( + 'Content-Type', 'application/x-www-form-urlencoded', + 'Authorization', 'Basic ' || encode(('api:' || sqlpage.environment_variable('MAILGUN_API_KEY'))::bytea, 'base64') + ), + 'body', + 'from=Your Name ' + || '&to=' || $to_email + || '&subject=' || $subject + || '&text=' || $message_text + || '&html=' || $message_html +); + +-- Send the email using sqlpage.fetch +set email_response = sqlpage.fetch($email_request); + +-- Handle the response +select + 'alert' as component, + case + when $email_response->>'id' is not null then 'Email sent successfully' + else 'Failed to send email: ' || ($email_response->>'message') + end as title; +``` + +### Setup Instructions + +1. Sign up for a [Mailgun account](https://signup.mailgun.com/new/signup) +2. Verify your domain or use the sandbox domain for testing +3. Get your API key from the Mailgun dashboard +4. Set these environment variables in your SQLPage configuration: + ``` + MAILGUN_API_KEY=your-api-key-here + MAILGUN_DOMAIN=your-domain.com + ``` + +## Best Practices + +- If you share your code with others, it should not contain sensitive data like API keys + - Instead, use environment variables with [`sqlpage.environment_variable`](https://sql-page.com/functions.sql?function=environment_variable) +- Implement proper error handling +- Consider rate limiting for bulk sending +- Include unsubscribe links when sending marketing emails +- Follow email regulations (GDPR, CAN-SPAM Act) diff --git a/examples/sending emails/docker-compose.yml b/examples/sending emails/docker-compose.yml new file mode 100644 index 0000000..8d0813b --- /dev/null +++ b/examples/sending emails/docker-compose.yml @@ -0,0 +1,7 @@ +services: + web: + image: lovasoa/sqlpage:main + ports: + - "8080:8080" + volumes: + - .:/var/www diff --git a/examples/sending emails/email.sql b/examples/sending emails/email.sql new file mode 100644 index 0000000..c528cc1 --- /dev/null +++ b/examples/sending emails/email.sql @@ -0,0 +1,43 @@ +-- Configure the email request + +-- Obtain the authorization by encoding "api:YOUR_PERSONAL_API_KEY" in base64 +set authorization = 'YXBpOjI4ODlmODE3Njk5ZjZiNzA4MTdhODliOGUwODYyNmEyLWU2MWFlOGRkLTgzMjRjYWZm'; + +-- Find the domain in your Mailgun account +set domain = 'sandbox859545b401674a95b906ab417d48c97c.mailgun.org'; + +-- Set the recipient email address. +--In this demo, we accept sending any email to any address. +-- If you do this in production, spammers WILL use your account to send spam. +-- Your application should only allow emails to be sent to addresses you have verified. +set to_email = :to_email; + +-- Set the email subject +set subject = :subject; + +-- Set the email message text +set message_text = :message_text; + +set email_request = json_object( + 'url', 'https://api.mailgun.net/v3/' || $domain || '/messages', + 'method', 'POST', + 'headers', json_object( + 'Content-Type', 'application/x-www-form-urlencoded', + 'Authorization', 'Basic ' || $authorization + ), + 'body', + 'from=Your Name ' + || '&to=' || sqlpage.url_encode($to_email) + || '&subject=' || sqlpage.url_encode($subject) + || '&text=' || sqlpage.url_encode($message_text) +); +-- Send the email using sqlpage.fetch +set email_response = sqlpage.fetch($email_request); + +-- Handle the response +select + 'alert' as component, + case + when $email_response->>'id' is not null then 'Email sent successfully' + else 'Failed to send email: ' || ($email_response->>'message') + end as title; \ No newline at end of file diff --git a/examples/sending emails/index.sql b/examples/sending emails/index.sql new file mode 100644 index 0000000..dcee981 --- /dev/null +++ b/examples/sending emails/index.sql @@ -0,0 +1,5 @@ +select 'form' as component, 'Send an email' as title, 'email.sql' as action; + +select 'to_email' as name, 'To email' as label, 'recipient@example.com' as value; +select 'subject' as name, 'Subject' as label, 'Test email' as value; +select 'textarea' as type, 'message_text' as name, 'Message' as label, 'This is a test email' as value; diff --git a/examples/sending emails/test.hurl b/examples/sending emails/test.hurl new file mode 100644 index 0000000..17ba5c6 --- /dev/null +++ b/examples/sending emails/test.hurl @@ -0,0 +1,7 @@ +GET http://127.0.0.1:8080/ +HTTP 200 +[Asserts] +xpath "string(//form/@action)" == "email.sql" +xpath "string(//input[@name='to_email']/@value)" == "recipient@example.com" +xpath "string(//input[@name='subject']/@value)" == "Test email" +xpath "string(//textarea[@name='message_text'])" == "This is a test email" diff --git a/examples/simple-website-example/README.md b/examples/simple-website-example/README.md new file mode 100644 index 0000000..df8d5dd --- /dev/null +++ b/examples/simple-website-example/README.md @@ -0,0 +1,23 @@ +# SQLPage application example + +This is a very simple example of a website that uses the SQLPage web application framework. + +This website illustrates how to create a basic Create-Read-Update-Delete (CRUD) application using SQLPage. +It has the following bsic features: + + - Displays a list of user names using the [list component](https://sql-page.com/documentation.sql?component=list#component) (in [`index.sql`](./index.sql#L14-L20)) + - Add a new user name to the list through a [form](https://sql-page.com/documentation.sql?component=form#component) (in [`index.sql`](./index.sql#L1-L9)) + - View a user's personal page by clicking on a name in the list (in [`user.sql`](./user.sql)) + - Delete a user from the list by clicking on the delete button in the user's personal page (in [`delete.sql`](./delete.sql)) + +## Running the example + +To run the example, just launch the sqlpage binary from this directory. + +## Files + +- [`index.sql`](./index.sql): The main page of the website. Contains a form to add a new user and a list of all users. +- [`user.sql`](./user.sql): The user's personal page. Contains a link to delete the user. +- [`delete.sql`](./delete.sql): The page that deletes the user from the database, and the redirects to the main page. +- [`sqlpage/migrations/001_create_users_table.sql`](./sqlpage/migrations/001_create_users_table.sql): The SQL migration that creates the users table in the database. +- [`sqlpage/sqlpage.json`](./sqlpage/sqlpage.json): The [SQLPage configuration](../../configuration.md) file. diff --git a/examples/simple-website-example/delete.sql b/examples/simple-website-example/delete.sql new file mode 100644 index 0000000..73485d4 --- /dev/null +++ b/examples/simple-website-example/delete.sql @@ -0,0 +1,8 @@ +DELETE FROM users +WHERE id = $id +RETURNING + 'text' AS component, + ' +The user ' || username || ' has been deleted. + +[Back to the user list](index.sql)' as contents_md; \ No newline at end of file diff --git a/examples/simple-website-example/docker-compose.yml b/examples/simple-website-example/docker-compose.yml new file mode 100644 index 0000000..7305eda --- /dev/null +++ b/examples/simple-website-example/docker-compose.yml @@ -0,0 +1,8 @@ +services: + web: + image: lovasoa/sqlpage:main + ports: + - "8080:8080" + volumes: + - .:/var/www + - ./sqlpage:/etc/sqlpage diff --git a/examples/simple-website-example/edit.sql b/examples/simple-website-example/edit.sql new file mode 100644 index 0000000..4434a18 --- /dev/null +++ b/examples/simple-website-example/edit.sql @@ -0,0 +1,8 @@ +update users +set username = :Username, + is_admin = :Administrator is not null +where :Username is not null and id = $id; + +select 'form' as component; +select 'text' as type, 'Username' as name, username as value from users where id = $id; +select 'checkbox' as type, 'Has administrator privileges' as label, 'Administrator' as name, is_admin as checked from users where id = $id; \ No newline at end of file diff --git a/examples/simple-website-example/index.sql b/examples/simple-website-example/index.sql new file mode 100644 index 0000000..8ff4bed --- /dev/null +++ b/examples/simple-website-example/index.sql @@ -0,0 +1,21 @@ +-- A form to create a new entry in the database +SELECT 'form' AS component, + 'Add a user' AS title; +SELECT 'Username' as name, + TRUE as required; +-- Handle the form results when present +INSERT INTO users (username) +SELECT :Username +WHERE :Username IS NOT NULL; +---------------------------------- +-- Display the list of users +-- It is important that this query comes after the INSERT query above, +-- so that the updated list is visible immediately +SELECT 'list' AS component, + 'Users' AS title; +SELECT username AS title, + username || ' is a user on this website.' as description, + case when is_admin then 'red' end as color, + 'user' as icon, + 'user.sql?id=' || id as link +FROM users; \ No newline at end of file diff --git a/examples/simple-website-example/sqlpage/migrations/0001_create_users_table.sql b/examples/simple-website-example/sqlpage/migrations/0001_create_users_table.sql new file mode 100644 index 0000000..51e0d22 --- /dev/null +++ b/examples/simple-website-example/sqlpage/migrations/0001_create_users_table.sql @@ -0,0 +1,5 @@ +CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL, + is_admin BOOLEAN NOT NULL DEFAULT FALSE +); \ No newline at end of file diff --git a/examples/simple-website-example/sqlpage/sqlpage.json b/examples/simple-website-example/sqlpage/sqlpage.json new file mode 100644 index 0000000..268bd02 --- /dev/null +++ b/examples/simple-website-example/sqlpage/sqlpage.json @@ -0,0 +1,3 @@ +{ + "database_url": "sqlite://:memory:" +} diff --git a/examples/simple-website-example/table.sql b/examples/simple-website-example/table.sql new file mode 100644 index 0000000..6afa8fb --- /dev/null +++ b/examples/simple-website-example/table.sql @@ -0,0 +1,4 @@ +select 'table' as component, 'action' as markdown; +select *, + format('[Edit](edit.sql?id=%s)', id) as action +from users; \ No newline at end of file diff --git a/examples/simple-website-example/test.hurl b/examples/simple-website-example/test.hurl new file mode 100644 index 0000000..22e87e8 --- /dev/null +++ b/examples/simple-website-example/test.hurl @@ -0,0 +1,47 @@ +GET http://localhost:8080/ +HTTP 200 +[Asserts] +body contains "Add a user" +body contains "Users" +body not contains "An error occurred" + +POST http://localhost:8080/ +[FormParams] +Username: Hurl User +HTTP 200 +[Asserts] +body contains "Hurl User" +body contains "user.sql?id" +body not contains "An error occurred" + +GET http://localhost:8080/user.sql?id=1 +HTTP 200 +[Asserts] +body contains "Hurl User" +body contains "Edit user" +body contains "Delete this user" +body not contains "An error occurred" + +POST http://localhost:8080/edit.sql?id=1 +[FormParams] +Username: Hurl Admin +Administrator: on +HTTP 200 +[Asserts] +body contains "Hurl Admin" +body contains "Has administrator privileges" +body not contains "An error occurred" + +GET http://localhost:8080/ +HTTP 200 +[Asserts] +body contains "Hurl Admin" +body not contains "Hurl User is a user" +body not contains "An error occurred" + +GET http://localhost:8080/delete.sql?id=1 +HTTP 200 +[Asserts] +body contains "The user Hurl Admin has been deleted." +body contains "Back to the user list" +body not contains "An error occurred" diff --git a/examples/simple-website-example/user.sql b/examples/simple-website-example/user.sql new file mode 100644 index 0000000..ec515b1 --- /dev/null +++ b/examples/simple-website-example/user.sql @@ -0,0 +1,9 @@ +SELECT 'text' as component, + username as title, + username || ' is a user on this site. + +[Delete this user](delete.sql?id=' || id || ') + +[Edit user](edit.sql?id=' || id || ')' as contents_md +FROM users +WHERE id = $id; \ No newline at end of file diff --git a/examples/single sign on/README.md b/examples/single sign on/README.md new file mode 100644 index 0000000..c947d36 --- /dev/null +++ b/examples/single sign on/README.md @@ -0,0 +1,117 @@ +# SQLPage Single Sign-On demo + +This project demonstrates how to implement +external authentication (Single Sign-On) in a SQLPage application using SQLPage's built-in OIDC support. + +It demonstrates the implementation of two external authentication protocols: +- [OpenID Connect (OIDC)](https://openid.net/connect/) +- [Central Authentication Service (CAS)](https://apereo.github.io/cas/) + +Depending on your use case, you can choose the appropriate protocol for your application. + +## Screenshots + +| Home Page | Login Page | User Info | +| --- | --- | --- | +| ![Home Page](assets/homepage.png) | ![Login Page](assets/login_page.png) | ![User Info](assets/logged_in.png) | + +## Running the Demo + +To run the demo, you just need docker and docker-compose installed on your machine. Then, run the following commands: + +```bash +docker compose up --watch +``` + +This will start a Keycloak server and a SQLPage server. You can access the SQLPage application at http://localhost:8080. + +The credentials for the demo are: + - **Username: `demo`** + - **Password: `demo`** + +The credentials to the keycloak admin console accessible at http://localhost:8180 are `admin/admin`. + +## CAS Client + +This example also contains a CAS (Central Authentication Service) client +in the [`cas`](./cas) directory that demonstrates how to authenticate users using +the [CAS protocol](https://apereo.github.io/cas/) (version 3.0), which is mostly used in academic institutions. + +## OIDC Client + +OIDC is an authentication protocol that allows users to authenticate with a third-party identity provider and then access applications without having to log in again. This is useful for single sign-on (SSO) scenarios where users need to access multiple applications with a single set of credentials. +OIDC can be used to implement a "Login with Google" or "Login with Facebook" button in your application, since these providers support the OIDC protocol. + +SQLPage has built-in support for OIDC authentication since v0.35. +This project demonstrates how to use it with the free and open source [Keycloak](https://www.keycloak.org/) OIDC provider. +You can easily replace Keycloak with another OIDC provider, such as Google, or your enterprise OIDC provider, by following the steps in the [Configuration](#configuration) section. + +### Public and Protected Pages + +By default, SQLPage's built-in OIDC support protects the entire website. However, you can configure it to have a mix of public and protected pages using the `oidc_protected_paths` option in your `sqlpage.json` file. + +This allows you to create a public-facing area (like a homepage with a login button) and a separate protected area for authenticated users. + + +### Configuration + +To use OIDC authentication in your own SQLPage application, +you need to configure it in your `sqlpage.json` file: + +```json +{ + "oidc_issuer_url": "https://your-keycloak-server/auth/realms/your-realm", + "oidc_client_id": "your-client-id", + "oidc_client_secret": "your-client-secret", + "host": "localhost:8080", + "oidc_protected_paths": ["/protected"] +} +``` + +The configuration parameters are: +- `oidc_issuer_url`: The base URL of your OIDC provider +- `oidc_client_id`: The ID that identifies your SQLPage application to the OIDC provider +- `oidc_client_secret`: The secret key for your SQLPage application +- `host`: The web address where your application is accessible + +### Accessing User Information + +Once OIDC is configured, you can access information about the authenticated user in your SQL files using these functions: + +- `sqlpage.user_info(claim_name)`: Get a specific claim about the user (like name or email) +- `sqlpage.user_info_token()`: Get the entire identity token as JSON + +Example: +```sql +select 'text' as component, 'Welcome, ' || sqlpage.user_info('name') || '!' as contents_md; +``` + +### Implementation Details + +The demo includes several SQL files that demonstrate different aspects of OIDC integration: + +1. `index.sql`: A public page that shows a welcome message and a login button. If the user is logged in, it displays their email and a link to the protected page. + +2. `protected.sql`: A page that is only accessible to authenticated users. It displays the user's information. + +3. `logout.sql`: Logs the user out by removing the authentication cookie and redirecting to the OIDC provider's logout page. + +### Docker Setup + +The demo uses Docker Compose to set up both SQLPage and Keycloak. The configuration includes: + +- SQLPage service with: + - Volume mounts for the web root and configuration + - CAS configuration for optional CAS support + - Debug logging enabled + +- Keycloak service with: + - Pre-configured realm and users + - Health checks to ensure it's ready before SQLPage starts + - Admin credentials for management + +## References + +- [SQLPage OIDC Documentation](https://sql-page.com/sso) +- [OpenID Connect](https://openid.net/connect/) +- [Authorization Code Flow](https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth) diff --git a/examples/single sign on/assets/closed.jpeg b/examples/single sign on/assets/closed.jpeg new file mode 100644 index 0000000..adcf96a Binary files /dev/null and b/examples/single sign on/assets/closed.jpeg differ diff --git a/examples/single sign on/assets/homepage.md b/examples/single sign on/assets/homepage.md new file mode 100644 index 0000000..fa3919b --- /dev/null +++ b/examples/single sign on/assets/homepage.md @@ -0,0 +1,16 @@ +# Single Sign-On with OpenID Connect + +Welcome to this demonstration of how to implement *OpenID Connect* (OIDC) authentication in a SQLPage application. + +[OIDC](https://openid.net/connect/) is a standard authentication protocol that allows users to authenticate with a third-party identity provider and then access applications without having to log in again. This is useful for single sign-on (SSO) scenarios where users need to access multiple applications with a single set of credentials. OIDC can be used to implement a "Login with Google" or "Login with Facebook" button in your application, since these providers support the OIDC protocol. + +To test this application, click the login button on the top right corner of the page. +You will be redirected to the identity provider's login page, where you can login with the following credentials: +- **Username: `demo`** +- **Password: `demo`** + +After logging in, you will be redirected back to this page, and you will see the user information that was returned by the identity provider. + +This example also contains a CAS (Central Authentication Service) client that demonstrates how to authenticate users using the CAS protocol (version 3.0), which is mostly used in academic institutions. [Log in with CAS](/cas/login.sql) + +![closed](/assets/closed.jpeg) diff --git a/examples/single sign on/assets/homepage.png b/examples/single sign on/assets/homepage.png new file mode 100644 index 0000000..b53f1fd Binary files /dev/null and b/examples/single sign on/assets/homepage.png differ diff --git a/examples/single sign on/assets/keycloak_configuration.png b/examples/single sign on/assets/keycloak_configuration.png new file mode 100644 index 0000000..3218ed9 Binary files /dev/null and b/examples/single sign on/assets/keycloak_configuration.png differ diff --git a/examples/single sign on/assets/logged_in.png b/examples/single sign on/assets/logged_in.png new file mode 100644 index 0000000..14b7fd0 Binary files /dev/null and b/examples/single sign on/assets/logged_in.png differ diff --git a/examples/single sign on/assets/login_page.png b/examples/single sign on/assets/login_page.png new file mode 100644 index 0000000..b5115e7 Binary files /dev/null and b/examples/single sign on/assets/login_page.png differ diff --git a/examples/single sign on/assets/welcome.jpeg b/examples/single sign on/assets/welcome.jpeg new file mode 100644 index 0000000..5d67d09 Binary files /dev/null and b/examples/single sign on/assets/welcome.jpeg differ diff --git a/examples/single sign on/cas/README.md b/examples/single sign on/cas/README.md new file mode 100644 index 0000000..29ecf61 --- /dev/null +++ b/examples/single sign on/cas/README.md @@ -0,0 +1,89 @@ +# SQLPage CAS Client + +This is a demonstration of how to implement a +[Central Authentication Service (CAS)](https://apereo.github.io/cas/) +client in a SQLPage application. + +CAS is a single sign-on protocol that allows users to authenticate once and access multiple applications without having to log in again. It is primarily used in academic institutions and research organizations. + +The protocol is based on a ticketing system, where the user logs in once and receives a ticket that can be used to access other applications without having to log in again. The ticket is validated by the CAS server, which then returns the user's information to the application. + +This can be implemented in SQLPage with two `.sql` files: + - [`login.sql`](login.sql): This just redirects the user to the CAS server's login page. + - [`redirect_handler.sql`](redirect_handler.sql): This is the page where the CAS server redirects the user after login. It validates the ticket by sending a request to the CAS server and if the ticket is valid, it creates a session for the user in the SQLPage application. + +## Configuration + +To use this CAS client in your own SQLPage application, you need to follow these steps: + +1. Configure your CAS server to allow your SQLPage application to authenticate users. You will need to create a new service in the CAS server with the following information: + - **Service URL**: The URL of your `redirect_handler.sql` page. For example, `https://example.com/redirect_handler.sql`. + - **Service Name**: A descriptive name for your service. This can be anything you want. + - **Service Type**: `CAS 3.0`. +2. In your SQLPage application, set the following environment variable: + - `CAS_ROOT_URL`: The URL of your CAS server. For example, `https://cas.example.com/cas`. + +> Environment variables are global variables that can be made available to a program. +> Using environment variables is a good practice for storing sensitive information and configuration settings, +> so that they are not hard-coded in the code and are easy to change without modifying the code. +> You can set an environment variable by running `export VARIABLE_NAME=value` in the terminal before starting your SQLPage application. +> If you are running your application as a [systemd](https://en.wikipedia.org/wiki/Systemd) service, +> you can set environment variables in the service configuration file, like this: +> ```ini +> [Service] +> Environment="VARIABLE_NAME=value" +> ``` +> +> Alternatively, you could store the CAS root URL inside your database and replace +> `sqlpage.environment_variable('CAS_ROOT_URL')` with `(SELECT cas_root_url FROM cas_config)` +> in the `login.sql` and `redirect_handler.sql` files. + +## CAS v3 Authentication Flow, step by step + +### Login +The client (usually a web browser) requests a resource from the application (client service). +The application redirects the client to the CAS server with a service URL (the URL to which CAS should return the user after authentication). + +### CAS Server Authentication +The CAS server presents a login form. +The user submits their credentials (username and password). +Upon successful authentication, the CAS server redirects the user back to the application with a service ticket (ST) appended to the service URL. + +### Service Ticket Validation +The application receives the service ticket and makes a back-channel request to the CAS server to validate the service ticket. +The CAS server responds with a success or failure. If successful, it also returns the user's attributes (such as username, email, etc.). + +### User Session +Upon successful validation, the application creates a session for the user and grants access to the requested resource. + +### CAS v3 Pseudocode Implementation + +```plaintext +function authenticateUser(serviceUrl): + if userNotLoggedIn(): + redirectToCasServer(serviceUrl) + +function redirectToCasServer(serviceUrl): + casLoginUrl = "https://cas.example.com/login?service=" + urlEncode(serviceUrl) + redirect(casLoginUrl) + +function casCallback(request): + serviceTicket = request.getParameter("ticket") + if serviceTicket is not None: + validationUrl = "https://cas.example.com/serviceValidate?ticket=" + serviceTicket + "&service=" + urlEncode(serviceUrl) + validationResponse = httpRequest(validationUrl) + if validateResponse(validationResponse): + userAttributes = extractAttributes(validationResponse) + createUserSession(userAttributes) + redirectToService(serviceUrl) + else: + authenticationFailed() + else: + error("Invalid service ticket.") +``` + +## Notes + +- This implementation uses the CAS 3.0 protocol. If your CAS server uses a different version of the protocol, you may need to modify the code (the ticket validation URL in redirect_handler.sql in particular). +- This implementation does not handle single sign-out (SLO) or proxy tickets. These features can be added by extending the code in `redirect_handler.sql`. +- This implementation assumes that the CAS server returns the user's email address in the `mail` attribute of the user's profile. If your CAS server uses a different attribute to store the email address, or does not return the email address at all, you will need to modify the code to extract the email address from the user's profile in `redirect_handler.sql`. \ No newline at end of file diff --git a/examples/single sign on/cas/index.sql b/examples/single sign on/cas/index.sql new file mode 100644 index 0000000..6963abf --- /dev/null +++ b/examples/single sign on/cas/index.sql @@ -0,0 +1,4 @@ +set user_email = (select email from user_sessions where session_id = sqlpage.cookie('session_id')); + +select 'text' as component, 'You are not authenticated. [Log in](login.sql).' as contents_md where $user_email is null; +select 'text' as component, 'Welcome, ' || $user_email || '. You can now [log out](logout.sql).' as contents_md where $user_email is not null; diff --git a/examples/single sign on/cas/login.sql b/examples/single sign on/cas/login.sql new file mode 100644 index 0000000..ce9075c --- /dev/null +++ b/examples/single sign on/cas/login.sql @@ -0,0 +1,5 @@ +select + 'redirect' as component, + sqlpage.environment_variable('CAS_ROOT_URL') + || '/login?service=' || sqlpage.protocol() || '://' || sqlpage.header('host') || '/cas/redirect_handler.sql' + as link; \ No newline at end of file diff --git a/examples/single sign on/cas/logout.sql b/examples/single sign on/cas/logout.sql new file mode 100644 index 0000000..18088ca --- /dev/null +++ b/examples/single sign on/cas/logout.sql @@ -0,0 +1,10 @@ +-- remove the session cookie +select 'cookie' as component, 'session_id' as name, true as remove; +-- remove the session from the database +delete from user_sessions where session_id = sqlpage.cookie('session_id'); +-- log the user out of the cas server +select + 'redirect' as component, + sqlpage.environment_variable('CAS_ROOT_URL') + || '/logout?service=' || sqlpage.protocol() || '://' || sqlpage.header('host') || '/cas/redirect_handler.sql' + as link; \ No newline at end of file diff --git a/examples/single sign on/cas/redirect_handler.sql b/examples/single sign on/cas/redirect_handler.sql new file mode 100644 index 0000000..6c98efe --- /dev/null +++ b/examples/single sign on/cas/redirect_handler.sql @@ -0,0 +1,41 @@ +-- The CAS server will redirect the user to this URL after the user has authenticated +-- This page will be loaded with a ticket parameter in the query string, which we can read in the variable $ticket + +-- If we don't have a ticket, go back to the CAS login page +select 'redirect' as component, '/cas/' as link where $ticket is null; + +-- We must then validate the ticket with the CAS server +-- CAS v3 specifies the following URL for ticket validation (see https://apereo.github.io/cas/6.6.x/protocol/CAS-Protocol-Specification.html#28-p3servicevalidate-cas-30) +-- https://cas.example.org/p3/serviceValidate?ticket=ST-1856339-aA5Yuvrxzpv8Tau1cYQ7&service=http://myclient.example.org/myapp&format=JSON +set ticket_url = + sqlpage.environment_variable('CAS_ROOT_URL') + || '/p3/serviceValidate' + || '?ticket=' || sqlpage.url_encode($ticket) + || '&service=' || sqlpage.protocol() || '://' || sqlpage.header('host') || '/cas/redirect_handler.sql' + || '&format=JSON'; + +-- We must then make a request to the CAS server to validate the ticket +set validation_response = sqlpage.fetch($ticket_url); + +-- If the ticket is invalid, the CAS server will return a 200 OK response with a JSON object like this: +-- { "serviceResponse": { "authenticationFailure": { "code": "INVALID_TICKET", "description": "..." } } } +select 'redirect' as component, + '/cas/login.sql' as link +where $validation_response->'serviceResponse'->'authenticationFailure' is not null; + +-- If the ticket is valid, the CAS server will return a 200 OK response with a JSON object like this: +-- { "serviceResponse": { "authenticationSuccess": { "user": "username", "attributes": { "attribute": "value" } } } } +-- You can use the following SQL code to inspect what the CAS server returned: +-- select 'debug' as component, $validation_response; +insert into user_sessions(session_id, user_id, email, oidc_token) + values( + sqlpage.random_string(32), + $validation_response->'serviceResponse'->'authenticationSuccess'->>'user', -- The '->' operator extracts a JSON object field as JSON, while the '->>' operator extracts a JSON object field as text + $validation_response->'serviceResponse'->'authenticationSuccess'->'attributes'->>'mail', + $ticket + ) +returning + 'cookie' as component, 'session_id' as name, session_id as value; + +-- Redirect the user to the home page +select 'redirect' as component, '/cas/' as link; diff --git a/examples/single sign on/docker-compose.yaml b/examples/single sign on/docker-compose.yaml new file mode 100644 index 0000000..75e20a6 --- /dev/null +++ b/examples/single sign on/docker-compose.yaml @@ -0,0 +1,45 @@ +# This file lets you run the example with a single command: docker-compose up +# Download docker here: https://www.docker.com/products/docker-desktop +# +# This docker compose starts two services: +# 1. a SQLPage service that serves a simple page with a login button +# 2. a Keycloak service that acts as an OpenID Connect provider (manages users and authentication) +# + +services: + sqlpage: + image: lovasoa/sqlpage:main # Use the latest development version of SQLPage + build: + context: ../.. + volumes: + - .:/var/www + - ./sqlpage:/etc/sqlpage + environment: + # CAS (central authentication system) configuration + # (you can ignore this if you're only using OpenID Connect) + - CAS_ROOT_URL=http://localhost:8181/realms/sqlpage_demo/protocol/cas + + # SQLPage configuration + - RUST_LOG=sqlpage=debug + network_mode: host + depends_on: + keycloak: + condition: service_healthy + develop: + watch: + - action: restart + path: ./sqlpage/ + + keycloak: + build: + context: . + dockerfile: keycloak.Dockerfile + volumes: + - ./keycloak-configuration.json:/opt/keycloak/data/import/realm.json + network_mode: host + healthcheck: + test: ["CMD-SHELL", "/opt/keycloak/bin/kcadm.sh get realms/sqlpage_demo --server http://localhost:8181 --realm master --user admin --password admin || exit 1"] + interval: 10s + timeout: 2s + retries: 5 + start_period: 5s diff --git a/examples/single sign on/docker-compose.yml b/examples/single sign on/docker-compose.yml new file mode 100644 index 0000000..96056ec --- /dev/null +++ b/examples/single sign on/docker-compose.yml @@ -0,0 +1,39 @@ +# This file lets you run the example with a single command: docker compose up +# Download docker here: https://www.docker.com/products/docker-desktop +# +# This docker compose starts two services: +# 1. a SQLPage service that serves a simple page with a login button +# 2. a Keycloak service that acts as an OpenID Connect provider (manages users and authentication) +# + +services: + sqlpage: + image: lovasoa/sqlpage:main + volumes: + - .:/var/www + - ./sqlpage:/etc/sqlpage + environment: + - CAS_ROOT_URL=http://localhost:8181/realms/sqlpage_demo/protocol/cas + - RUST_LOG=sqlpage=debug + network_mode: host + depends_on: + keycloak: + condition: service_healthy + develop: + watch: + - action: restart + path: ./sqlpage/ + + keycloak: + build: + context: . + dockerfile: keycloak.Dockerfile + volumes: + - ./keycloak-configuration.json:/opt/keycloak/data/import/realm.json + network_mode: host + healthcheck: + test: ["CMD-SHELL", "/opt/keycloak/bin/kcadm.sh get realms/sqlpage_demo --server http://localhost:8181 --realm master --user admin --password admin || exit 1"] + interval: 10s + timeout: 2s + retries: 5 + start_period: 5s diff --git a/examples/single sign on/index.sql b/examples/single sign on/index.sql new file mode 100644 index 0000000..bc410f6 --- /dev/null +++ b/examples/single sign on/index.sql @@ -0,0 +1,27 @@ +SELECT 'shell' as component, 'My public app' as title; + +set email = sqlpage.user_info('email'); + +-- For anonymous users +SELECT 'hero' as component, + '/protected' as link, + 'Log in' as link_text, + 'Welcome' as title, + 'You are currently browsing as a guest. Log in to access the protected page.' as description, + '/protected/public/hello.jpeg' as image +WHERE $email IS NULL; + +-- For logged-in users +SELECT 'text' as component, + 'Welcome back, ' || sqlpage.user_info('name') || '!' as title, + 'You are logged in as ' || sqlpage.user_info('email') || + '. You can now access the [protected page](/protected) or [log out](' || + -- Secure OIDC logout with CSRF protection + -- This redirects to /sqlpage/oidc_logout which: + -- 1. Verifies the CSRF token + -- 2. Removes the auth cookies + -- 3. Redirects to the OIDC provider's logout endpoint + -- 4. Finally redirects back to the homepage + sqlpage.oidc_logout_url() + || ').' as contents_md +WHERE $email IS NOT NULL; diff --git a/examples/single sign on/keycloak-configuration.json b/examples/single sign on/keycloak-configuration.json new file mode 100644 index 0000000..f0f2501 --- /dev/null +++ b/examples/single sign on/keycloak-configuration.json @@ -0,0 +1,5195 @@ +[ + { + "id": "35cfdc9d-86a8-4974-a40c-adf25d4c9855", + "realm": "master", + "displayName": "Keycloak", + "displayNameHtml": "
Keycloak
", + "notBefore": 0, + "defaultSignatureAlgorithm": "RS256", + "revokeRefreshToken": false, + "refreshTokenMaxReuse": 0, + "accessTokenLifespan": 60, + "accessTokenLifespanForImplicitFlow": 900, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "ssoSessionIdleTimeoutRememberMe": 0, + "ssoSessionMaxLifespanRememberMe": 0, + "offlineSessionIdleTimeout": 2592000, + "offlineSessionMaxLifespanEnabled": false, + "offlineSessionMaxLifespan": 5184000, + "clientSessionIdleTimeout": 0, + "clientSessionMaxLifespan": 0, + "clientOfflineSessionIdleTimeout": 0, + "clientOfflineSessionMaxLifespan": 0, + "accessCodeLifespan": 60, + "accessCodeLifespanUserAction": 300, + "accessCodeLifespanLogin": 1800, + "actionTokenGeneratedByAdminLifespan": 43200, + "actionTokenGeneratedByUserLifespan": 300, + "oauth2DeviceCodeLifespan": 600, + "oauth2DevicePollingInterval": 5, + "enabled": true, + "sslRequired": "external", + "registrationAllowed": false, + "registrationEmailAsUsername": false, + "rememberMe": false, + "verifyEmail": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": false, + "editUsernameAllowed": false, + "bruteForceProtected": false, + "permanentLockout": false, + "maxTemporaryLockouts": 0, + "maxFailureWaitSeconds": 900, + "minimumQuickLoginWaitSeconds": 60, + "waitIncrementSeconds": 60, + "quickLoginCheckMilliSeconds": 1000, + "maxDeltaTimeSeconds": 43200, + "failureFactor": 30, + "roles": { + "realm": [ + { + "id": "157f6b82-51a3-41ec-ace0-84301dcd9c25", + "name": "admin", + "description": "${role_admin}", + "composite": true, + "composites": { + "realm": ["create-realm"], + "client": { + "sqlpage_demo-realm": [ + "view-realm", + "impersonation", + "query-clients", + "manage-identity-providers", + "manage-users", + "query-realms", + "create-client", + "view-authorization", + "manage-realm", + "manage-authorization", + "manage-events", + "view-events", + "view-clients", + "view-identity-providers", + "view-users", + "query-groups", + "manage-clients", + "query-users" + ], + "master-realm": [ + "query-users", + "manage-realm", + "manage-clients", + "manage-authorization", + "impersonation", + "manage-users", + "query-groups", + "view-authorization", + "view-identity-providers", + "query-clients", + "view-realm", + "manage-identity-providers", + "view-clients", + "create-client", + "view-events", + "query-realms", + "view-users", + "manage-events" + ] + } + }, + "clientRole": false, + "containerId": "35cfdc9d-86a8-4974-a40c-adf25d4c9855", + "attributes": {} + }, + { + "id": "8f554927-b25f-488a-9c9f-f57a0a543dc7", + "name": "create-realm", + "description": "${role_create-realm}", + "composite": false, + "clientRole": false, + "containerId": "35cfdc9d-86a8-4974-a40c-adf25d4c9855", + "attributes": {} + }, + { + "id": "7a9a5437-5ed4-4c07-93c2-312bab85725c", + "name": "default-roles-master", + "description": "${role_default-roles}", + "composite": true, + "composites": { + "realm": ["offline_access", "uma_authorization"], + "client": { + "account": ["manage-account", "view-profile"] + } + }, + "clientRole": false, + "containerId": "35cfdc9d-86a8-4974-a40c-adf25d4c9855", + "attributes": {} + }, + { + "id": "3b4b330e-9ef3-4226-bd0f-dd2537083a5d", + "name": "offline_access", + "description": "${role_offline-access}", + "composite": false, + "clientRole": false, + "containerId": "35cfdc9d-86a8-4974-a40c-adf25d4c9855", + "attributes": {} + }, + { + "id": "d4ead458-2552-44b9-9ab2-7c053e41f44e", + "name": "uma_authorization", + "description": "${role_uma_authorization}", + "composite": false, + "clientRole": false, + "containerId": "35cfdc9d-86a8-4974-a40c-adf25d4c9855", + "attributes": {} + } + ], + "client": { + "sqlpage_demo-realm": [ + { + "id": "8fb6a790-d944-4ad4-949b-40033e8237be", + "name": "manage-authorization", + "description": "${role_manage-authorization}", + "composite": false, + "clientRole": true, + "containerId": "560a2c01-0691-4698-a367-0b489f12b11d", + "attributes": {} + }, + { + "id": "ed155122-f596-437a-bf96-d7233b5a2a25", + "name": "view-realm", + "description": "${role_view-realm}", + "composite": false, + "clientRole": true, + "containerId": "560a2c01-0691-4698-a367-0b489f12b11d", + "attributes": {} + }, + { + "id": "ca72acd4-8e77-4f50-91dc-f8a80dcb4be2", + "name": "impersonation", + "description": "${role_impersonation}", + "composite": false, + "clientRole": true, + "containerId": "560a2c01-0691-4698-a367-0b489f12b11d", + "attributes": {} + }, + { + "id": "957f208f-f775-4c56-bbaa-6d620ae03000", + "name": "manage-events", + "description": "${role_manage-events}", + "composite": false, + "clientRole": true, + "containerId": "560a2c01-0691-4698-a367-0b489f12b11d", + "attributes": {} + }, + { + "id": "b17b9e52-b250-45ae-abff-c8f1243507ef", + "name": "query-clients", + "description": "${role_query-clients}", + "composite": false, + "clientRole": true, + "containerId": "560a2c01-0691-4698-a367-0b489f12b11d", + "attributes": {} + }, + { + "id": "711beb97-dd68-4bc8-8bd0-3a545849d1f6", + "name": "manage-identity-providers", + "description": "${role_manage-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "560a2c01-0691-4698-a367-0b489f12b11d", + "attributes": {} + }, + { + "id": "4b25dd76-f5a8-47f6-be6c-50810b8159b1", + "name": "manage-users", + "description": "${role_manage-users}", + "composite": false, + "clientRole": true, + "containerId": "560a2c01-0691-4698-a367-0b489f12b11d", + "attributes": {} + }, + { + "id": "bcd0f88e-5810-41ad-b8b3-84daf2578b59", + "name": "view-events", + "description": "${role_view-events}", + "composite": false, + "clientRole": true, + "containerId": "560a2c01-0691-4698-a367-0b489f12b11d", + "attributes": {} + }, + { + "id": "8754e583-158e-45cc-9a2d-ed14c546aae6", + "name": "view-clients", + "description": "${role_view-clients}", + "composite": true, + "composites": { + "client": { + "sqlpage_demo-realm": ["query-clients"] + } + }, + "clientRole": true, + "containerId": "560a2c01-0691-4698-a367-0b489f12b11d", + "attributes": {} + }, + { + "id": "bc742aa2-d368-4bbd-9b3d-de521d6c0c74", + "name": "query-realms", + "description": "${role_query-realms}", + "composite": false, + "clientRole": true, + "containerId": "560a2c01-0691-4698-a367-0b489f12b11d", + "attributes": {} + }, + { + "id": "7d290769-fb64-4609-a7ac-faabd85da902", + "name": "create-client", + "description": "${role_create-client}", + "composite": false, + "clientRole": true, + "containerId": "560a2c01-0691-4698-a367-0b489f12b11d", + "attributes": {} + }, + { + "id": "38b8ad0e-c36e-491e-bba5-a2b66e407988", + "name": "view-identity-providers", + "description": "${role_view-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "560a2c01-0691-4698-a367-0b489f12b11d", + "attributes": {} + }, + { + "id": "a1d78e33-27a4-4a14-a6d5-bb5cc670647f", + "name": "view-authorization", + "description": "${role_view-authorization}", + "composite": false, + "clientRole": true, + "containerId": "560a2c01-0691-4698-a367-0b489f12b11d", + "attributes": {} + }, + { + "id": "08fbd2d8-4e7d-4989-b3cf-6b3be44f53c2", + "name": "view-users", + "description": "${role_view-users}", + "composite": true, + "composites": { + "client": { + "sqlpage_demo-realm": ["query-groups", "query-users"] + } + }, + "clientRole": true, + "containerId": "560a2c01-0691-4698-a367-0b489f12b11d", + "attributes": {} + }, + { + "id": "8c4fa339-791d-40de-8add-2de668260a43", + "name": "manage-realm", + "description": "${role_manage-realm}", + "composite": false, + "clientRole": true, + "containerId": "560a2c01-0691-4698-a367-0b489f12b11d", + "attributes": {} + }, + { + "id": "63bd8ea4-2e0c-4bd5-a119-40cfc6bee1cc", + "name": "query-groups", + "description": "${role_query-groups}", + "composite": false, + "clientRole": true, + "containerId": "560a2c01-0691-4698-a367-0b489f12b11d", + "attributes": {} + }, + { + "id": "67f702c1-e7a5-41e9-bffc-0789969518a8", + "name": "manage-clients", + "description": "${role_manage-clients}", + "composite": false, + "clientRole": true, + "containerId": "560a2c01-0691-4698-a367-0b489f12b11d", + "attributes": {} + }, + { + "id": "aced8cae-7161-4d5b-bf26-d2a7ab7b14b1", + "name": "query-users", + "description": "${role_query-users}", + "composite": false, + "clientRole": true, + "containerId": "560a2c01-0691-4698-a367-0b489f12b11d", + "attributes": {} + } + ], + "security-admin-console": [], + "admin-cli": [], + "account-console": [], + "broker": [ + { + "id": "82980459-069c-4bf9-99db-a521b6c4f0bd", + "name": "read-token", + "description": "${role_read-token}", + "composite": false, + "clientRole": true, + "containerId": "a371454e-3a21-45fb-b634-7b9ee4bb9647", + "attributes": {} + } + ], + "master-realm": [ + { + "id": "f87d166f-1b7b-483c-afee-35a6fe49a34d", + "name": "manage-realm", + "description": "${role_manage-realm}", + "composite": false, + "clientRole": true, + "containerId": "1ee50014-2a1c-4061-a55e-828b723858b5", + "attributes": {} + }, + { + "id": "24c469bd-7cb7-4c8a-b8b7-54aeb2d3c5b8", + "name": "query-users", + "description": "${role_query-users}", + "composite": false, + "clientRole": true, + "containerId": "1ee50014-2a1c-4061-a55e-828b723858b5", + "attributes": {} + }, + { + "id": "cf002990-c6f8-43f0-a897-adb26881caa7", + "name": "view-identity-providers", + "description": "${role_view-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "1ee50014-2a1c-4061-a55e-828b723858b5", + "attributes": {} + }, + { + "id": "bd94e0cc-2ff3-487a-8076-5f4d65dd98d8", + "name": "manage-authorization", + "description": "${role_manage-authorization}", + "composite": false, + "clientRole": true, + "containerId": "1ee50014-2a1c-4061-a55e-828b723858b5", + "attributes": {} + }, + { + "id": "38430b67-2ba0-414f-af61-b07d4cc4704d", + "name": "manage-clients", + "description": "${role_manage-clients}", + "composite": false, + "clientRole": true, + "containerId": "1ee50014-2a1c-4061-a55e-828b723858b5", + "attributes": {} + }, + { + "id": "f44e09f2-0191-4edb-bcb7-6ef8cef5ef91", + "name": "impersonation", + "description": "${role_impersonation}", + "composite": false, + "clientRole": true, + "containerId": "1ee50014-2a1c-4061-a55e-828b723858b5", + "attributes": {} + }, + { + "id": "8ea944a9-0c18-4e6c-9da6-f610fb22aa2e", + "name": "query-clients", + "description": "${role_query-clients}", + "composite": false, + "clientRole": true, + "containerId": "1ee50014-2a1c-4061-a55e-828b723858b5", + "attributes": {} + }, + { + "id": "f4c97dbe-f4ae-4ff3-914a-dda48eb165fd", + "name": "manage-users", + "description": "${role_manage-users}", + "composite": false, + "clientRole": true, + "containerId": "1ee50014-2a1c-4061-a55e-828b723858b5", + "attributes": {} + }, + { + "id": "fa43ce74-999c-4d7a-bf95-49462982683c", + "name": "view-realm", + "description": "${role_view-realm}", + "composite": false, + "clientRole": true, + "containerId": "1ee50014-2a1c-4061-a55e-828b723858b5", + "attributes": {} + }, + { + "id": "0a810999-e124-4202-ab79-4dd835ca4f33", + "name": "manage-identity-providers", + "description": "${role_manage-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "1ee50014-2a1c-4061-a55e-828b723858b5", + "attributes": {} + }, + { + "id": "e3bb431d-3b63-4b6f-a76b-3ad95bcafc4c", + "name": "view-clients", + "description": "${role_view-clients}", + "composite": true, + "composites": { + "client": { + "master-realm": ["query-clients"] + } + }, + "clientRole": true, + "containerId": "1ee50014-2a1c-4061-a55e-828b723858b5", + "attributes": {} + }, + { + "id": "03a13c0b-4fe5-48cd-aa70-6802ad07e4ab", + "name": "create-client", + "description": "${role_create-client}", + "composite": false, + "clientRole": true, + "containerId": "1ee50014-2a1c-4061-a55e-828b723858b5", + "attributes": {} + }, + { + "id": "38929e9c-19a7-4ab4-ad6b-2089a137c8e4", + "name": "view-events", + "description": "${role_view-events}", + "composite": false, + "clientRole": true, + "containerId": "1ee50014-2a1c-4061-a55e-828b723858b5", + "attributes": {} + }, + { + "id": "3118f7c6-c071-4aaf-8c63-0a6f05c8c633", + "name": "query-realms", + "description": "${role_query-realms}", + "composite": false, + "clientRole": true, + "containerId": "1ee50014-2a1c-4061-a55e-828b723858b5", + "attributes": {} + }, + { + "id": "1b745171-139b-448e-b395-c5142178c359", + "name": "query-groups", + "description": "${role_query-groups}", + "composite": false, + "clientRole": true, + "containerId": "1ee50014-2a1c-4061-a55e-828b723858b5", + "attributes": {} + }, + { + "id": "0371bdc2-bca8-414b-bef8-f730bb83cf9d", + "name": "view-users", + "description": "${role_view-users}", + "composite": true, + "composites": { + "client": { + "master-realm": ["query-users", "query-groups"] + } + }, + "clientRole": true, + "containerId": "1ee50014-2a1c-4061-a55e-828b723858b5", + "attributes": {} + }, + { + "id": "92ce5daa-5ba8-4bde-a612-da0c027d621c", + "name": "manage-events", + "description": "${role_manage-events}", + "composite": false, + "clientRole": true, + "containerId": "1ee50014-2a1c-4061-a55e-828b723858b5", + "attributes": {} + }, + { + "id": "822ad55f-8dd9-4300-a7b9-9c0f03ecadf9", + "name": "view-authorization", + "description": "${role_view-authorization}", + "composite": false, + "clientRole": true, + "containerId": "1ee50014-2a1c-4061-a55e-828b723858b5", + "attributes": {} + } + ], + "account": [ + { + "id": "76fa7fbc-9f5f-45aa-96c0-200cdc49b8e1", + "name": "manage-account", + "description": "${role_manage-account}", + "composite": true, + "composites": { + "client": { + "account": ["manage-account-links"] + } + }, + "clientRole": true, + "containerId": "7465266e-fc1b-4c03-ab77-9356ea9a350e", + "attributes": {} + }, + { + "id": "931743f6-48c6-4099-945d-c54acbfa0728", + "name": "manage-consent", + "description": "${role_manage-consent}", + "composite": true, + "composites": { + "client": { + "account": ["view-consent"] + } + }, + "clientRole": true, + "containerId": "7465266e-fc1b-4c03-ab77-9356ea9a350e", + "attributes": {} + }, + { + "id": "584d55e9-c784-4dc2-bb6d-f9e4ce2b98fd", + "name": "manage-account-links", + "description": "${role_manage-account-links}", + "composite": false, + "clientRole": true, + "containerId": "7465266e-fc1b-4c03-ab77-9356ea9a350e", + "attributes": {} + }, + { + "id": "d0b8cafa-537c-46eb-8a9d-d9eb3ea92260", + "name": "view-applications", + "description": "${role_view-applications}", + "composite": false, + "clientRole": true, + "containerId": "7465266e-fc1b-4c03-ab77-9356ea9a350e", + "attributes": {} + }, + { + "id": "f4bf658d-b930-4f7a-b77d-4fff3752c38b", + "name": "delete-account", + "description": "${role_delete-account}", + "composite": false, + "clientRole": true, + "containerId": "7465266e-fc1b-4c03-ab77-9356ea9a350e", + "attributes": {} + }, + { + "id": "08a925a0-281b-41eb-8c59-324b71b90fe0", + "name": "view-groups", + "description": "${role_view-groups}", + "composite": false, + "clientRole": true, + "containerId": "7465266e-fc1b-4c03-ab77-9356ea9a350e", + "attributes": {} + }, + { + "id": "a0943dbf-5b50-48b2-96d3-53cf5d884d93", + "name": "view-profile", + "description": "${role_view-profile}", + "composite": false, + "clientRole": true, + "containerId": "7465266e-fc1b-4c03-ab77-9356ea9a350e", + "attributes": {} + }, + { + "id": "d1c3b3ba-aa46-4e63-a8ea-457b354a720b", + "name": "view-consent", + "description": "${role_view-consent}", + "composite": false, + "clientRole": true, + "containerId": "7465266e-fc1b-4c03-ab77-9356ea9a350e", + "attributes": {} + } + ] + } + }, + "groups": [], + "defaultRole": { + "id": "7a9a5437-5ed4-4c07-93c2-312bab85725c", + "name": "default-roles-master", + "description": "${role_default-roles}", + "composite": true, + "clientRole": false, + "containerId": "35cfdc9d-86a8-4974-a40c-adf25d4c9855" + }, + "requiredCredentials": ["password"], + "otpPolicyType": "totp", + "otpPolicyAlgorithm": "HmacSHA1", + "otpPolicyInitialCounter": 0, + "otpPolicyDigits": 6, + "otpPolicyLookAheadWindow": 1, + "otpPolicyPeriod": 30, + "otpPolicyCodeReusable": false, + "otpSupportedApplications": [ + "totpAppFreeOTPName", + "totpAppGoogleName", + "totpAppMicrosoftAuthenticatorName" + ], + "localizationTexts": {}, + "webAuthnPolicyRpEntityName": "keycloak", + "webAuthnPolicySignatureAlgorithms": ["ES256"], + "webAuthnPolicyRpId": "", + "webAuthnPolicyAttestationConveyancePreference": "not specified", + "webAuthnPolicyAuthenticatorAttachment": "not specified", + "webAuthnPolicyRequireResidentKey": "not specified", + "webAuthnPolicyUserVerificationRequirement": "not specified", + "webAuthnPolicyCreateTimeout": 0, + "webAuthnPolicyAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyAcceptableAaguids": [], + "webAuthnPolicyExtraOrigins": [], + "webAuthnPolicyPasswordlessRpEntityName": "keycloak", + "webAuthnPolicyPasswordlessSignatureAlgorithms": ["ES256"], + "webAuthnPolicyPasswordlessRpId": "", + "webAuthnPolicyPasswordlessAttestationConveyancePreference": "not specified", + "webAuthnPolicyPasswordlessAuthenticatorAttachment": "not specified", + "webAuthnPolicyPasswordlessRequireResidentKey": "not specified", + "webAuthnPolicyPasswordlessUserVerificationRequirement": "not specified", + "webAuthnPolicyPasswordlessCreateTimeout": 0, + "webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyPasswordlessAcceptableAaguids": [], + "webAuthnPolicyPasswordlessExtraOrigins": [], + "users": [ + { + "id": "04a670c1-5e58-4856-813b-4e228409ab05", + "username": "admin", + "emailVerified": false, + "createdTimestamp": 1753803522059, + "enabled": true, + "totp": false, + "credentials": [ + { + "id": "77364ac0-e861-4458-b4af-01565afaea76", + "type": "password", + "createdDate": 1753803522306, + "secretData": "{\"value\":\"JceAANUOmsxhmF6x9wVh7sEzNY4+hNwwsgRgYLtpUhm/dizEnvZXOgc/xMN9pTwHJJ6w34ndNJb36rYfGJdwkg==\",\"salt\":\"Pn49Qj7LqwiiHrIsWaJgDA==\",\"additionalParameters\":{}}", + "credentialData": "{\"hashIterations\":210000,\"algorithm\":\"pbkdf2-sha512\",\"additionalParameters\":{}}" + } + ], + "disableableCredentialTypes": [], + "requiredActions": [], + "realmRoles": ["admin", "default-roles-master"], + "notBefore": 0, + "groups": [] + } + ], + "scopeMappings": [ + { + "clientScope": "offline_access", + "roles": ["offline_access"] + } + ], + "clientScopeMappings": { + "account": [ + { + "client": "account-console", + "roles": ["manage-account", "view-groups"] + } + ] + }, + "clients": [ + { + "id": "7465266e-fc1b-4c03-ab77-9356ea9a350e", + "clientId": "account", + "name": "${client_account}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/master/account/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": ["/realms/master/account/*"], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "d75b6fac-269f-4fc3-93e8-f82daabdc2d9", + "name": "email", + "protocol": "cas", + "protocolMapper": "cas-usermodel-property-mapper", + "consentRequired": false, + "config": { + "user.attribute": "email", + "claim.name": "mail", + "jsonType.label": "String" + } + }, + { + "id": "aad6b1d8-1306-4129-a5d2-4eb86e4f04e0", + "name": "family name", + "protocol": "cas", + "protocolMapper": "cas-usermodel-property-mapper", + "consentRequired": false, + "config": { + "user.attribute": "lastName", + "claim.name": "sn", + "jsonType.label": "String" + } + }, + { + "id": "eb93fd40-0c55-4e8f-9e3d-262baa8cfed1", + "name": "full name", + "protocol": "cas", + "protocolMapper": "cas-full-name-mapper", + "consentRequired": false, + "config": { + "claim.name": "cn", + "jsonType.label": "String" + } + }, + { + "id": "682e1bbb-e2e8-46be-a696-1467791826cc", + "name": "given name", + "protocol": "cas", + "protocolMapper": "cas-usermodel-property-mapper", + "consentRequired": false, + "config": { + "user.attribute": "firstName", + "claim.name": "givenName", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "7e801f1c-1dd8-4dd6-bf0a-ef928b5d1b63", + "clientId": "account-console", + "name": "${client_account-console}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/master/account/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": ["/realms/master/account/*"], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+", + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "2df9337d-471a-49c5-a377-c56bec0ff59c", + "name": "email", + "protocol": "cas", + "protocolMapper": "cas-usermodel-property-mapper", + "consentRequired": false, + "config": { + "user.attribute": "email", + "claim.name": "mail", + "jsonType.label": "String" + } + }, + { + "id": "192bdac4-6e85-4e6f-8f2e-4589e4028db5", + "name": "given name", + "protocol": "cas", + "protocolMapper": "cas-usermodel-property-mapper", + "consentRequired": false, + "config": { + "user.attribute": "firstName", + "claim.name": "givenName", + "jsonType.label": "String" + } + }, + { + "id": "06406f18-0df3-4c24-9819-74f7af04172b", + "name": "family name", + "protocol": "cas", + "protocolMapper": "cas-usermodel-property-mapper", + "consentRequired": false, + "config": { + "user.attribute": "lastName", + "claim.name": "sn", + "jsonType.label": "String" + } + }, + { + "id": "5d077697-a7b0-42b1-a15b-482e8b589dc2", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": {} + }, + { + "id": "a92eae83-6a3e-4437-9594-7ef67b711e8c", + "name": "full name", + "protocol": "cas", + "protocolMapper": "cas-full-name-mapper", + "consentRequired": false, + "config": { + "claim.name": "cn", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "7ec606cb-4325-457a-bd50-bbd5d7044fa3", + "clientId": "admin-cli", + "name": "${client_admin-cli}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": {}, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "17a2ba41-8a93-40e3-8201-4a79b0ff6895", + "name": "email", + "protocol": "cas", + "protocolMapper": "cas-usermodel-property-mapper", + "consentRequired": false, + "config": { + "user.attribute": "email", + "claim.name": "mail", + "jsonType.label": "String" + } + }, + { + "id": "529233d6-9010-4084-aa91-60f0a97ddcb0", + "name": "full name", + "protocol": "cas", + "protocolMapper": "cas-full-name-mapper", + "consentRequired": false, + "config": { + "claim.name": "cn", + "jsonType.label": "String" + } + }, + { + "id": "a98fe901-96b6-4608-8873-654b7fb866dc", + "name": "given name", + "protocol": "cas", + "protocolMapper": "cas-usermodel-property-mapper", + "consentRequired": false, + "config": { + "user.attribute": "firstName", + "claim.name": "givenName", + "jsonType.label": "String" + } + }, + { + "id": "d056ee6c-f540-43f0-b37d-b332932b858b", + "name": "family name", + "protocol": "cas", + "protocolMapper": "cas-usermodel-property-mapper", + "consentRequired": false, + "config": { + "user.attribute": "lastName", + "claim.name": "sn", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "a371454e-3a21-45fb-b634-7b9ee4bb9647", + "clientId": "broker", + "name": "${client_broker}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": {}, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "806a9098-be8c-42f3-8a29-11fbf19679f7", + "name": "given name", + "protocol": "cas", + "protocolMapper": "cas-usermodel-property-mapper", + "consentRequired": false, + "config": { + "user.attribute": "firstName", + "claim.name": "givenName", + "jsonType.label": "String" + } + }, + { + "id": "fb480b23-43a2-42bc-b3db-80200e301bc6", + "name": "full name", + "protocol": "cas", + "protocolMapper": "cas-full-name-mapper", + "consentRequired": false, + "config": { + "claim.name": "cn", + "jsonType.label": "String" + } + }, + { + "id": "081b408a-4f0f-41b0-a642-8344a48bbe4f", + "name": "email", + "protocol": "cas", + "protocolMapper": "cas-usermodel-property-mapper", + "consentRequired": false, + "config": { + "user.attribute": "email", + "claim.name": "mail", + "jsonType.label": "String" + } + }, + { + "id": "5e7118e2-e2ed-4463-8b1f-3c1b980485c1", + "name": "family name", + "protocol": "cas", + "protocolMapper": "cas-usermodel-property-mapper", + "consentRequired": false, + "config": { + "user.attribute": "lastName", + "claim.name": "sn", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "1ee50014-2a1c-4061-a55e-828b723858b5", + "clientId": "master-realm", + "name": "master Realm", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "attributes": {}, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "80bd1c1e-5e0c-4585-9d27-a79ff7e4dbb5", + "clientId": "security-admin-console", + "name": "${client_security-admin-console}", + "rootUrl": "${authAdminUrl}", + "baseUrl": "/admin/master/console/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": ["/admin/master/console/*"], + "webOrigins": ["+"], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+", + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "653ac14a-6fb4-4b59-b851-7e1be5350d0f", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + }, + { + "id": "02e4f360-d2f2-4448-a258-7644f54de2b5", + "name": "given name", + "protocol": "cas", + "protocolMapper": "cas-usermodel-property-mapper", + "consentRequired": false, + "config": { + "user.attribute": "firstName", + "claim.name": "givenName", + "jsonType.label": "String" + } + }, + { + "id": "241ee0dd-53f5-4201-b76e-434f9e9ad67e", + "name": "full name", + "protocol": "cas", + "protocolMapper": "cas-full-name-mapper", + "consentRequired": false, + "config": { + "claim.name": "cn", + "jsonType.label": "String" + } + }, + { + "id": "5ca8d9a0-45c1-41fd-9574-1cab6d0296d7", + "name": "email", + "protocol": "cas", + "protocolMapper": "cas-usermodel-property-mapper", + "consentRequired": false, + "config": { + "user.attribute": "email", + "claim.name": "mail", + "jsonType.label": "String" + } + }, + { + "id": "b128bae5-b933-41f2-ba13-aa76750ef40f", + "name": "family name", + "protocol": "cas", + "protocolMapper": "cas-usermodel-property-mapper", + "consentRequired": false, + "config": { + "user.attribute": "lastName", + "claim.name": "sn", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "560a2c01-0691-4698-a367-0b489f12b11d", + "clientId": "sqlpage_demo-realm", + "name": "sqlpage_demo Realm", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "attributes": {}, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [], + "optionalClientScopes": [] + } + ], + "clientScopes": [ + { + "id": "1ded49f3-1c48-45aa-aad5-1a00ca0d34ab", + "name": "email", + "description": "OpenID Connect built-in scope: email", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${emailScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "49063613-767b-4383-8687-9425936bc7ba", + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "email", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email", + "jsonType.label": "String" + } + }, + { + "id": "2cd93725-862d-4561-b30b-c0cfe69f0aba", + "name": "email verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "emailVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email_verified", + "jsonType.label": "boolean" + } + } + ] + }, + { + "id": "d4630702-a4bb-4d25-8b40-f2b2b52b9d91", + "name": "microprofile-jwt", + "description": "Microprofile - JWT built-in scope", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "e1c92a52-ab2c-4708-ba3e-23bcae03c655", + "name": "upn", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "upn", + "jsonType.label": "String" + } + }, + { + "id": "19017dd8-2039-41df-a14b-fc352cc81b81", + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "multivalued": "true", + "user.attribute": "foo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "groups", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "e675e5b0-872b-4935-aaf7-fff44b165a8a", + "name": "offline_access", + "description": "OpenID Connect built-in scope: offline_access", + "protocol": "openid-connect", + "attributes": { + "consent.screen.text": "${offlineAccessScopeConsentText}", + "display.on.consent.screen": "true" + } + }, + { + "id": "1163daab-51aa-4712-919c-0d76e84b73cd", + "name": "profile", + "description": "OpenID Connect built-in scope: profile", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${profileScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "8b00aa80-3a15-4d5f-9a09-9036c9feb77e", + "name": "gender", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "gender", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "gender", + "jsonType.label": "String" + } + }, + { + "id": "44da3ec2-c1fc-4485-984e-3650b7385491", + "name": "nickname", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "nickname", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "nickname", + "jsonType.label": "String" + } + }, + { + "id": "9be45694-0770-40d1-941e-1781aa0de39c", + "name": "given name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "firstName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "given_name", + "jsonType.label": "String" + } + }, + { + "id": "060db496-5cad-45d6-8bae-8e0233ddffca", + "name": "username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "preferred_username", + "jsonType.label": "String" + } + }, + { + "id": "503bbde2-104a-4073-9e63-96f75bc6ab4b", + "name": "full name", + "protocol": "openid-connect", + "protocolMapper": "oidc-full-name-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "id": "aff3ec1f-91d0-439f-a724-73d1906d6ff1", + "name": "website", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "website", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "website", + "jsonType.label": "String" + } + }, + { + "id": "9081d800-5958-4a56-bceb-79b4537b649c", + "name": "zoneinfo", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "zoneinfo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "zoneinfo", + "jsonType.label": "String" + } + }, + { + "id": "be4517e3-4aec-4a67-a1ba-9cbfc0d0529e", + "name": "picture", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "picture", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "picture", + "jsonType.label": "String" + } + }, + { + "id": "e80fef90-31eb-493f-b8b6-8c49166b602c", + "name": "updated at", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "updatedAt", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "updated_at", + "jsonType.label": "long" + } + }, + { + "id": "7d6236ae-f782-474e-87fb-fb82209059d9", + "name": "family name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "lastName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "family_name", + "jsonType.label": "String" + } + }, + { + "id": "09211191-9f60-45ca-93cf-53240c8bd535", + "name": "middle name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "middleName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "middle_name", + "jsonType.label": "String" + } + }, + { + "id": "ed4a7fdb-cf50-4d1f-aef4-9a045434dfb1", + "name": "birthdate", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "birthdate", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "birthdate", + "jsonType.label": "String" + } + }, + { + "id": "a9c631d9-0783-45c9-b668-77c93103c8c4", + "name": "profile", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "profile", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "profile", + "jsonType.label": "String" + } + }, + { + "id": "6ef0bcea-ecc3-4efb-8304-a7e71afc5727", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "3a68440a-2f77-4d91-94c4-ef715dd09ec1", + "name": "roles", + "description": "OpenID Connect scope for add user roles to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "true", + "consent.screen.text": "${rolesScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "a97a22cb-4b2d-4ebb-ab80-ded30fd539d4", + "name": "realm roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "multivalued": "true", + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "realm_access.roles", + "jsonType.label": "String" + } + }, + { + "id": "285a69d3-df85-411c-8547-1b86c175ace7", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "access.token.claim": "true" + } + }, + { + "id": "29a6c94a-a5aa-4bc6-a3ff-58960d1d756a", + "name": "client roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-client-role-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "multivalued": "true", + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "resource_access.${client_id}.roles", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "d9387cdf-022a-44da-a7ee-494f87e8bdb3", + "name": "acr", + "description": "OpenID Connect scope for add acr (authentication context class reference) to the token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "d89d463d-3fc3-489e-85a2-a0a82f774194", + "name": "acr loa level", + "protocol": "openid-connect", + "protocolMapper": "oidc-acr-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true" + } + } + ] + }, + { + "id": "5e7694ff-87a8-4906-b645-2725c48e3f4b", + "name": "address", + "description": "OpenID Connect built-in scope: address", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${addressScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "df22f9c8-f584-4eaf-99df-7bd6a48791de", + "name": "address", + "protocol": "openid-connect", + "protocolMapper": "oidc-address-mapper", + "consentRequired": false, + "config": { + "user.attribute.formatted": "formatted", + "user.attribute.country": "country", + "introspection.token.claim": "true", + "user.attribute.postal_code": "postal_code", + "userinfo.token.claim": "true", + "user.attribute.street": "street", + "id.token.claim": "true", + "user.attribute.region": "region", + "access.token.claim": "true", + "user.attribute.locality": "locality" + } + } + ] + }, + { + "id": "ed277102-63aa-4292-b3f7-33d643d3f391", + "name": "web-origins", + "description": "OpenID Connect scope for add allowed web origins to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false", + "consent.screen.text": "" + }, + "protocolMappers": [ + { + "id": "13f2569c-216c-4785-b5be-37e7b3207317", + "name": "allowed web origins", + "protocol": "openid-connect", + "protocolMapper": "oidc-allowed-origins-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "access.token.claim": "true" + } + } + ] + }, + { + "id": "7968ff86-3ee4-4510-b084-143c95547655", + "name": "role_list", + "description": "SAML role list", + "protocol": "saml", + "attributes": { + "consent.screen.text": "${samlRoleListScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "c90d27e9-e7ae-42a5-ac6d-25c1601befef", + "name": "role list", + "protocol": "saml", + "protocolMapper": "saml-role-list-mapper", + "consentRequired": false, + "config": { + "single": "false", + "attribute.nameformat": "Basic", + "attribute.name": "Role" + } + } + ] + }, + { + "id": "cfc366e8-a0cb-437f-aeb7-3f20c6c827c8", + "name": "phone", + "description": "OpenID Connect built-in scope: phone", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${phoneScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "fadc6eb1-8500-4658-be01-8a5b2d37b42f", + "name": "phone number", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "phoneNumber", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number", + "jsonType.label": "String" + } + }, + { + "id": "93e058fa-d051-495a-b103-8c23f7334314", + "name": "phone number verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "phoneNumberVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number_verified", + "jsonType.label": "boolean" + } + } + ] + } + ], + "defaultDefaultClientScopes": [ + "role_list", + "profile", + "email", + "roles", + "web-origins", + "acr" + ], + "defaultOptionalClientScopes": [ + "offline_access", + "address", + "phone", + "microprofile-jwt" + ], + "browserSecurityHeaders": { + "contentSecurityPolicyReportOnly": "", + "xContentTypeOptions": "nosniff", + "referrerPolicy": "no-referrer", + "xRobotsTag": "none", + "xFrameOptions": "SAMEORIGIN", + "xXSSProtection": "1; mode=block", + "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", + "strictTransportSecurity": "max-age=31536000; includeSubDomains" + }, + "smtpServer": {}, + "eventsEnabled": false, + "eventsListeners": ["jboss-logging"], + "enabledEventTypes": [], + "adminEventsEnabled": false, + "adminEventsDetailsEnabled": false, + "identityProviders": [], + "identityProviderMappers": [], + "components": { + "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [ + { + "id": "c38f2db7-a630-48f0-a137-b75240f68929", + "name": "Full Scope Disabled", + "providerId": "scope", + "subType": "anonymous", + "subComponents": {}, + "config": {} + }, + { + "id": "7385872f-3cc5-4621-a5ae-e570c9d2b41b", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allow-default-scopes": ["true"] + } + }, + { + "id": "8622f9a0-167b-4bdd-840b-c264aa2480ec", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "oidc-usermodel-property-mapper", + "saml-role-list-mapper", + "oidc-address-mapper", + "oidc-sha256-pairwise-sub-mapper", + "saml-user-attribute-mapper", + "saml-user-property-mapper", + "oidc-usermodel-attribute-mapper", + "oidc-full-name-mapper" + ] + } + }, + { + "id": "852f5a95-ed46-4359-9959-e7647d9ca57c", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allow-default-scopes": ["true"] + } + }, + { + "id": "93a1f518-fc63-40ee-b333-c53e94aa4acc", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "saml-user-property-mapper", + "oidc-usermodel-property-mapper", + "saml-user-attribute-mapper", + "oidc-address-mapper", + "oidc-full-name-mapper", + "oidc-usermodel-attribute-mapper", + "oidc-sha256-pairwise-sub-mapper", + "saml-role-list-mapper" + ] + } + }, + { + "id": "49b1aede-3298-4e9d-a73e-cfc2e92c044e", + "name": "Consent Required", + "providerId": "consent-required", + "subType": "anonymous", + "subComponents": {}, + "config": {} + }, + { + "id": "45b0dc89-a9c5-47b5-b6b0-a0b3ecee5c28", + "name": "Max Clients Limit", + "providerId": "max-clients", + "subType": "anonymous", + "subComponents": {}, + "config": { + "max-clients": ["200"] + } + }, + { + "id": "5a1bc1c3-8eef-46b1-b602-7471efbdee8e", + "name": "Trusted Hosts", + "providerId": "trusted-hosts", + "subType": "anonymous", + "subComponents": {}, + "config": { + "host-sending-registration-request-must-match": ["true"], + "client-uris-must-match": ["true"] + } + } + ], + "org.keycloak.userprofile.UserProfileProvider": [ + { + "id": "d89b22da-5f54-48ed-9f06-3040c1102334", + "providerId": "declarative-user-profile", + "subComponents": {}, + "config": { + "kc.user.profile.config": [ + "{\"attributes\":[{\"name\":\"username\",\"displayName\":\"${username}\",\"validations\":{\"length\":{\"min\":3,\"max\":255},\"username-prohibited-characters\":{},\"up-username-not-idn-homograph\":{}},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"email\",\"displayName\":\"${email}\",\"validations\":{\"email\":{},\"length\":{\"max\":255}},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"firstName\",\"displayName\":\"${firstName}\",\"validations\":{\"length\":{\"max\":255},\"person-name-prohibited-characters\":{}},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"lastName\",\"displayName\":\"${lastName}\",\"validations\":{\"length\":{\"max\":255},\"person-name-prohibited-characters\":{}},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false}],\"groups\":[{\"name\":\"user-metadata\",\"displayHeader\":\"User metadata\",\"displayDescription\":\"Attributes, which refer to user metadata\"}]}" + ] + } + } + ], + "org.keycloak.keys.KeyProvider": [ + { + "id": "8abdfd18-4dbd-4cd4-9343-ca7533c369a9", + "name": "hmac-generated-hs512", + "providerId": "hmac-generated", + "subComponents": {}, + "config": { + "kid": ["94d5d134-0606-43f2-b870-b75868520879"], + "secret": [ + "Wi2ojXjy9tWmGh_fRtpHlOdkGiT4br9JXeKnKk5xd8gDlO3gNlkGb8HQHlFbU7mLLde2gFFTXdtU1xy-kBvjfb2mohkSVVSp3HrGxlIEnr0DBoXWXDGQbfrUxWfKZMjWTNwHSN1JciQKsw5JIrCkd9MiAH8_xyZuLJxADhRBw78" + ], + "priority": ["100"], + "algorithm": ["HS512"] + } + }, + { + "id": "2a4da4f6-e7a7-4a52-92de-9d8762be6a39", + "name": "aes-generated", + "providerId": "aes-generated", + "subComponents": {}, + "config": { + "kid": ["fccff2a4-532d-48f6-88af-34f519784a8c"], + "secret": ["Qk9RHWnOg_mk2kOtVYwxPw"], + "priority": ["100"] + } + }, + { + "id": "68720305-ee2b-4a0e-b37c-ac8fb63a53a1", + "name": "rsa-generated", + "providerId": "rsa-generated", + "subComponents": {}, + "config": { + "privateKey": [ + "MIIEowIBAAKCAQEAsXZ7Dh1Uj12Q1WmsowZOSlpo4rTBKSC/ToCRmSwQDhbE5lnGRcwZwzPd7jXes86iFxNuzmZCM/f1XNWBo+/ST87fw85pkxDjmiwHFtqULY0NDwVt6x3Lqm4IujkJ6H+8cREebahRdVIcFhlDCJPwR8kuWNDNgZIeEi6aYPzv8T5tVB4wsIk7jJUNnCkK4tFR/ZOob9pKVPBDIlndLu110OrtU12S2siv0pM7EMnm8sENdLwkATCieNsDixctab2lDNC8BUx5E21AkGBJy7bmukeIpyeippk+qkasTsbkJ1YqLZowTxjq3Ypy0XXGu3t4Yvq92Zr/Ujhg/blimBy+jQIDAQABAoIBAAgGE9/vqo7wNj1ipvLDX7nT5vx3xhNeeYKKpJ/40oxYh4mPaJFnqN3fE/s9G0x6R3DkqN9ogeNzTs2Q574qTeeG3S4vay2Xl2WKn71u9OvZqKFTvQRi7Kj64FEkhWf/kofINDMqvO124Mc3rYS576THRLMGVIG1whSktR01LSzKna+T/mu2yoam8WFoO+Ct9hRT8ejSj4zf3Zf0gRO9JJs4QBmQGjEYABPIm9ZWoiBCLG+TjzaxJzGuR3efwVs1+VowHDkgjX7X9Z3ZTtrqmclznGCOFlBauJCf54sSZoa+BkZezDfu1fjiNaTYHjp3CK4uvJh99INNNIFjgP+joT8CgYEA1fTeqEEP/l/yCgaqUvFYjoGkxeMiao9ptyXxNe/anbqyHrMUzCMtyRsEXznwGefcBTn4qiUecwRNxBa9ujTWTBVNvd2ax+R3NW5R1QVjOiF7KL5q/TjmrtM2PWmaRiwN2bgxnMQAtf6aKf30xSqQ6lFtn/I6REHf60rzKQixQosCgYEA1FXJcl9i0DjSLq3DCBh6aVmh8eebfYECCRXuB09asbJOHm1aFzSDqS4PasS16EQa1r9SPvXbVcGwhuzV41H9bh6QppasGD8BaGIYasMtYlJ/LpQEJgum0bqZmRnLCnrs4QxVKKdpp9BHdCXuFjUDc+MJAro/OUrNFGG4sxNVHkcCgYAKeRoNEmI+CWRHqVvdA4NaNm5iYWPl429BT9Im2b7Rybm+VvXFqFMtbO0h3CwsmHTkrJnHelmrN6K23oYa/0seHkzX5mkVL9HGA8htrP3Wcp0cuXVzP73LAPu+tdSfarii16lWCyIdxoC1XYEFxbeiQKolEi5X+QGE+v48G/jRUQKBgBJi91WzEtBrCzBFlazeycLToyVaY+mDQVTeFEWHxpe6k+8okvONdZUxyt34+LOLKjPMT2fqTDrp0cptObw8flCJzwbN50sWMZ4DWI/uJMDt2duDr7RHsANbQC+0vxNCP77hHYKutIR2kalqG2rK3mirkT0uOYlRg96u85p2IxnDAoGBALdM+45DTkGUmTtVtrI26xvmL0Mg/fUUBkErMPpfb4Q4dRaBq8n2G5tgJsJ+tY0tySp1fl834SfUeqALuJPUV6pUtkegfRB3P4uiTVlYJ1vaoANO7aLgCnxsH1WbGgVCm0BY5KFuwX/bKvHAp6kgfUIvNrVYwvCkkZm5putSCBM2" + ], + "keyUse": ["SIG"], + "certificate": [ + "MIICmzCCAYMCBgGYVtX7PDANBgkqhkiG9w0BAQsFADARMQ8wDQYDVQQDDAZtYXN0ZXIwHhcNMjUwNzI5MTUzNzAwWhcNMzUwNzI5MTUzODQwWjARMQ8wDQYDVQQDDAZtYXN0ZXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCxdnsOHVSPXZDVaayjBk5KWmjitMEpIL9OgJGZLBAOFsTmWcZFzBnDM93uNd6zzqIXE27OZkIz9/Vc1YGj79JPzt/DzmmTEOOaLAcW2pQtjQ0PBW3rHcuqbgi6OQnof7xxER5tqFF1UhwWGUMIk/BHyS5Y0M2Bkh4SLppg/O/xPm1UHjCwiTuMlQ2cKQri0VH9k6hv2kpU8EMiWd0u7XXQ6u1TXZLayK/SkzsQyebywQ10vCQBMKJ42wOLFy1pvaUM0LwFTHkTbUCQYEnLtua6R4inJ6KmmT6qRqxOxuQnViotmjBPGOrdinLRdca7e3hi+r3Zmv9SOGD9uWKYHL6NAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAK6WL6N+Sq3gO77b8Z5GzP8ut+6v3Der3zAh2aUlPkLEB8zmAfygsHFilqgddzP+AQduc1wXqLzJzhvgN2Im48VaLzzog+5M1KGXYrKP5ifeQ0cKQ1jqrITvOfLROsEnaR0glwpy4rQjgwU/e55SLn6B7lcng1/3uOrsCEo02cqVsREhOWLX2gS/2aG1lBbNlFS53RfFhstLn7DtWKAwxFPIZ+jEft+nZMSN+R8/Iq976/z13lf7p08M8cJ9ZeuAGIkcsLeT2YaRhKgjNzgKahb78zBXMYtI2rg1uMPthIoJWhasktabYCsb1JW6Xa968Ic8zHIN2XTBkj/ZhG6cFj4=" + ], + "priority": ["100"] + } + }, + { + "id": "6fa1bd11-30a0-41f9-be42-37acc553c765", + "name": "rsa-enc-generated", + "providerId": "rsa-enc-generated", + "subComponents": {}, + "config": { + "privateKey": [ + "MIIEowIBAAKCAQEAwSejHG9SftAI29iifSmqHXizhBZ03G/5jCI2G21BpJtCAHEZPTPeFqYsEKh9xmKJ8ri2vz+/Zej+h/TJDAe1E+8OapA0xcbvukK3AyBkBikydLBoyIyAi81hvZIiXxzFj1WPaKBy1r4//Od3Cz1WdwTJZbODCO36mFLCP6MCoyUNqlqxv/lKD+Q3QdU76kyfAIIcc2BtDTabWjlN32BWFSPu405kV4DZhj7HeR/nO/uVZzO58zguzFyI3llGxVjs+Z4Iu0L/OUok8HAdCOD0rbJSPpfSQmtc5X1lo1n7uUNyYEjgkwGhMsMKenxXgOmyVfr2PMbdo4chVpDdTbCAWwIDAQABAoIBADm6AWcVpCePQB6IQ6O5iIRvVuNinMFm28N0VAxlXkl2N0cPhhYDAtxtBF1kJdbdC1JVvxQwVqD7/dofH9jvEsCm4P2bJJJ5TpsxpiWSkCJBPLmgIWjSiPZ/RrdTzd70J90bGpWP4lJooJISkUL0LXu7m/8/o6lPCmZs0W4YZ0jfXKBYVZl1ODAgrlbimYbbL9aVr5Jm6z+uY/4YOXVwqjd9DQJcg9nokPIq1k3Xx5EEP09RY8bZ7AqfG0vYtLlEP9Z4MxVjDCn7kXer+BLnS3hVQP6JfiolJj6TTZYoKRJ52aT4EYKwVpgkwKn2sArrputUFtvuYhBgT8T4mbUfmLUCgYEA4yIeavLvCBTbw/W2+ce+l1vv9vA7bOgI38dXVIkOLb4SlSNE9TGXOZcoKhTHRtvKe+iwZqe1Uc3ogqA74gCY7T9N4DU2dkbhmvWfg2FiXT/5xcgDQaV1EfbxXLBPywTfJvR2WLht04UiS9tpJFYzySqX7bDTC3nwc8BNTYM9RA0CgYEA2bQDwvozN8muebHAyarSe414DL442i5zPOdiq1T5G6qg9117AVA92edkjzpgaTWAFdITKyPcLGvRACIG4RYczX78o4vHTs25RkDTi1m/evXAvUvamwjrBWUw/f8+geN6cMc7luTv/0KoGWDSzYMBA7IdWNY6NEVOPbBmR8i1NAcCgYEAkcWa+g7SJECmzvyLI4Hzq1bBCp4htYKx91ULkmCn7emYKYlKP4dFRBvkFiXhw3NaX+32ENw/vbHGMNe/twulGlbPlz7vpjdVoctURdChfbGKj0oP9PjIyu/O9ird+zE0Ot8YeVZcfi1q1n6J211LvScN/OnIeQwYq2FW+5FoJ50CgYAcgTKA5AuywUiEDJ8miKRYoxRV7s442x4hmlZUAqM/WR8MZIQHjv8aOe7zxfv7qpKjyMbTvjVE57UM5GesLx4EVh00OMgW7F7W8QQB2fV1XxombvknlYpYQYChsTr4/NT6UUvfHQjDjnG+KOxRFlcaqcan7Bzg3TY6Y49w1LnNHwKBgHyM+2woIBINOa3RopErgWha6jJMe37r/3Y1d7bGYboqojgfGQwXDrxjLy9pt/jnTGJhTSOcZcoT1/un58rNDAV4aL+zuIq2JQOsESWt+kTcVBJvhGfM2ou+Cj65EReXONLywHM7KxL+fEwCySU7Iyugyvp3kRJmll3ff38AySAo" + ], + "keyUse": ["ENC"], + "certificate": [ + "MIICmzCCAYMCBgGYVtX7qTANBgkqhkiG9w0BAQsFADARMQ8wDQYDVQQDDAZtYXN0ZXIwHhcNMjUwNzI5MTUzNzAwWhcNMzUwNzI5MTUzODQwWjARMQ8wDQYDVQQDDAZtYXN0ZXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDBJ6Mcb1J+0Ajb2KJ9KaodeLOEFnTcb/mMIjYbbUGkm0IAcRk9M94WpiwQqH3GYonyuLa/P79l6P6H9MkMB7UT7w5qkDTFxu+6QrcDIGQGKTJ0sGjIjICLzWG9kiJfHMWPVY9ooHLWvj/853cLPVZ3BMlls4MI7fqYUsI/owKjJQ2qWrG/+UoP5DdB1TvqTJ8AghxzYG0NNptaOU3fYFYVI+7jTmRXgNmGPsd5H+c7+5VnM7nzOC7MXIjeWUbFWOz5ngi7Qv85SiTwcB0I4PStslI+l9JCa1zlfWWjWfu5Q3JgSOCTAaEywwp6fFeA6bJV+vY8xt2jhyFWkN1NsIBbAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAIEARIGo/Wbp3lQodqWqzVtmKwEmwBPMEtw5P+DrtT5oFagtuZUf3FgHGvBzfe/K6TvqayHvmxKGXm/Z0ZYOfwtdHu8/EDblQMSXHuU41ta2oPy8CEGjXfqSJtZ7z0lE8mNZyxHJmMTVJGEB1isGE0xt294kjj9n60I86VMGyZe1x2ZF0BN/TWgQ5cSZj65qVhAVK0xaJaVkvQ7T0AsEF7JkumUNDHjuz+t4cHoSV3aPSkT4vojvbRLdBKn7BqEok3McU5uuqgPPNWXUQQEIjdK1rb2oVY4/t2iwsKssIzEdsdpmpr/HDsgJ0EUuJdhyOI6qoydGG0pAAqZ04X3xPXw=" + ], + "priority": ["100"], + "algorithm": ["RSA-OAEP"] + } + } + ] + }, + "internationalizationEnabled": false, + "supportedLocales": [], + "authenticationFlows": [ + { + "id": "afc1d656-9b81-4b5f-8de6-0116fe92a5d2", + "alias": "Account verification options", + "description": "Method with which to verity the existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-email-verification", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Verify Existing Account by Re-authentication", + "userSetupAllowed": false + } + ] + }, + { + "id": "260f109f-8f21-4516-b7f6-011bbec65277", + "alias": "Browser - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-otp-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "8c854d16-d132-4d3a-a94b-1e6bec1028f8", + "alias": "Direct Grant - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "direct-grant-validate-otp", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "268574ab-2cd9-4746-a029-7160912c76ae", + "alias": "First broker login - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-otp-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "d15f8bfa-e5b8-4428-99db-ea6ab9a760b0", + "alias": "Handle Existing Account", + "description": "Handle what to do if there is existing account with same email/username like authenticated identity provider", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-confirm-link", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Account verification options", + "userSetupAllowed": false + } + ] + }, + { + "id": "f5a7b666-8615-4e00-b878-7f8e735b5d5c", + "alias": "Reset - Conditional OTP", + "description": "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-otp", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "62214974-de92-4735-92de-331fc1305d12", + "alias": "User creation or linking", + "description": "Flow for the existing/non-existing user alternatives", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "create unique user config", + "authenticator": "idp-create-user-if-unique", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Handle Existing Account", + "userSetupAllowed": false + } + ] + }, + { + "id": "5e6c50b9-3c96-45bc-bcee-e72a15724a76", + "alias": "Verify Existing Account by Re-authentication", + "description": "Reauthentication of existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-username-password-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "First broker login - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "d39d7e6d-916e-4fa0-affd-2d1045c856bb", + "alias": "browser", + "description": "browser based authentication", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-cookie", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-spnego", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "identity-provider-redirector", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 25, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 30, + "autheticatorFlow": true, + "flowAlias": "forms", + "userSetupAllowed": false + } + ] + }, + { + "id": "9159381b-6b1f-4ab4-9a46-51545f05b03f", + "alias": "clients", + "description": "Base authentication for clients", + "providerId": "client-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "client-secret", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-jwt", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-secret-jwt", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-x509", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 40, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "6b61b414-ad49-4d73-9b91-83046f498625", + "alias": "direct grant", + "description": "OpenID Connect Resource Owner Grant", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "direct-grant-validate-username", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "direct-grant-validate-password", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 30, + "autheticatorFlow": true, + "flowAlias": "Direct Grant - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "b2bf7615-af7d-45f1-8d03-c8f7c4ad3461", + "alias": "docker auth", + "description": "Used by Docker clients to authenticate against the IDP", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "docker-http-basic-authenticator", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "2ee49771-5af4-4012-81bf-3372bc9fa9c1", + "alias": "first broker login", + "description": "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "review profile config", + "authenticator": "idp-review-profile", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "User creation or linking", + "userSetupAllowed": false + } + ] + }, + { + "id": "eaf3d406-e280-4af0-aaec-7e4876a6d739", + "alias": "forms", + "description": "Username, password, otp and other auth forms.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-username-password-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Browser - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "efdde3fe-8169-4140-9f58-4d2d2395b88c", + "alias": "registration", + "description": "registration flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-page-form", + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": true, + "flowAlias": "registration form", + "userSetupAllowed": false + } + ] + }, + { + "id": "896d8a8b-a228-4a1f-aa99-84fc4a7eabd8", + "alias": "registration form", + "description": "registration form", + "providerId": "form-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-user-creation", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-password-action", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 50, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-recaptcha-action", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 60, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-terms-and-conditions", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 70, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "e52b9c9f-9685-441e-80b6-bf14cc864a90", + "alias": "reset credentials", + "description": "Reset credentials for a user if they forgot their password or something", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "reset-credentials-choose-user", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-credential-email", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-password", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 40, + "autheticatorFlow": true, + "flowAlias": "Reset - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "571378fc-6b41-4d83-9450-fca07b0bc6d6", + "alias": "saml ecp", + "description": "SAML ECP Profile Authentication Flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "http-basic-authenticator", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + } + ], + "authenticatorConfig": [ + { + "id": "563c9f81-9af9-40f3-b83c-7174d367680c", + "alias": "create unique user config", + "config": { + "require.password.update.after.registration": "false" + } + }, + { + "id": "30be6cc6-ce8e-4ea1-8004-a090c944bcd7", + "alias": "review profile config", + "config": { + "update.profile.on.first.login": "missing" + } + } + ], + "requiredActions": [ + { + "alias": "CONFIGURE_TOTP", + "name": "Configure OTP", + "providerId": "CONFIGURE_TOTP", + "enabled": true, + "defaultAction": false, + "priority": 10, + "config": {} + }, + { + "alias": "TERMS_AND_CONDITIONS", + "name": "Terms and Conditions", + "providerId": "TERMS_AND_CONDITIONS", + "enabled": false, + "defaultAction": false, + "priority": 20, + "config": {} + }, + { + "alias": "UPDATE_PASSWORD", + "name": "Update Password", + "providerId": "UPDATE_PASSWORD", + "enabled": true, + "defaultAction": false, + "priority": 30, + "config": {} + }, + { + "alias": "UPDATE_PROFILE", + "name": "Update Profile", + "providerId": "UPDATE_PROFILE", + "enabled": true, + "defaultAction": false, + "priority": 40, + "config": {} + }, + { + "alias": "VERIFY_EMAIL", + "name": "Verify Email", + "providerId": "VERIFY_EMAIL", + "enabled": true, + "defaultAction": false, + "priority": 50, + "config": {} + }, + { + "alias": "delete_account", + "name": "Delete Account", + "providerId": "delete_account", + "enabled": false, + "defaultAction": false, + "priority": 60, + "config": {} + }, + { + "alias": "webauthn-register", + "name": "Webauthn Register", + "providerId": "webauthn-register", + "enabled": true, + "defaultAction": false, + "priority": 70, + "config": {} + }, + { + "alias": "webauthn-register-passwordless", + "name": "Webauthn Register Passwordless", + "providerId": "webauthn-register-passwordless", + "enabled": true, + "defaultAction": false, + "priority": 80, + "config": {} + }, + { + "alias": "VERIFY_PROFILE", + "name": "Verify Profile", + "providerId": "VERIFY_PROFILE", + "enabled": true, + "defaultAction": false, + "priority": 90, + "config": {} + }, + { + "alias": "delete_credential", + "name": "Delete Credential", + "providerId": "delete_credential", + "enabled": true, + "defaultAction": false, + "priority": 100, + "config": {} + }, + { + "alias": "update_user_locale", + "name": "Update User Locale", + "providerId": "update_user_locale", + "enabled": true, + "defaultAction": false, + "priority": 1000, + "config": {} + } + ], + "browserFlow": "browser", + "registrationFlow": "registration", + "directGrantFlow": "direct grant", + "resetCredentialsFlow": "reset credentials", + "clientAuthenticationFlow": "clients", + "dockerAuthenticationFlow": "docker auth", + "firstBrokerLoginFlow": "first broker login", + "attributes": { + "cibaBackchannelTokenDeliveryMode": "poll", + "cibaExpiresIn": "120", + "cibaAuthRequestedUserHint": "login_hint", + "parRequestUriLifespan": "60", + "cibaInterval": "5", + "realmReusableOtpCode": "false" + }, + "keycloakVersion": "24.0.5", + "userManagedAccessAllowed": false, + "clientProfiles": { + "profiles": [] + }, + "clientPolicies": { + "policies": [] + } + }, + { + "id": "d7757cae-367b-4dfa-87f7-a19a789af2b9", + "realm": "sqlpage_demo", + "displayName": "SQLPage Demo", + "displayNameHtml": "
SQLPage SSO Demo
", + "notBefore": 0, + "defaultSignatureAlgorithm": "RS256", + "revokeRefreshToken": false, + "refreshTokenMaxReuse": 0, + "accessTokenLifespan": 60, + "accessTokenLifespanForImplicitFlow": 900, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "ssoSessionIdleTimeoutRememberMe": 0, + "ssoSessionMaxLifespanRememberMe": 0, + "offlineSessionIdleTimeout": 2592000, + "offlineSessionMaxLifespanEnabled": false, + "offlineSessionMaxLifespan": 5184000, + "clientSessionIdleTimeout": 0, + "clientSessionMaxLifespan": 0, + "clientOfflineSessionIdleTimeout": 0, + "clientOfflineSessionMaxLifespan": 0, + "accessCodeLifespan": 60, + "accessCodeLifespanUserAction": 300, + "accessCodeLifespanLogin": 1800, + "actionTokenGeneratedByAdminLifespan": 43200, + "actionTokenGeneratedByUserLifespan": 300, + "oauth2DeviceCodeLifespan": 600, + "oauth2DevicePollingInterval": 5, + "enabled": true, + "sslRequired": "external", + "registrationAllowed": false, + "registrationEmailAsUsername": false, + "rememberMe": false, + "verifyEmail": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": false, + "editUsernameAllowed": false, + "bruteForceProtected": false, + "permanentLockout": false, + "maxTemporaryLockouts": 0, + "maxFailureWaitSeconds": 900, + "minimumQuickLoginWaitSeconds": 60, + "waitIncrementSeconds": 60, + "quickLoginCheckMilliSeconds": 1000, + "maxDeltaTimeSeconds": 43200, + "failureFactor": 30, + "roles": { + "realm": [ + { + "id": "50468abf-dd38-4957-8e16-d7b1b8345a19", + "name": "offline_access", + "description": "${role_offline-access}", + "composite": false, + "clientRole": false, + "containerId": "d7757cae-367b-4dfa-87f7-a19a789af2b9", + "attributes": {} + }, + { + "id": "5c8a5401-1d48-467d-bb5a-f9b8b07ea281", + "name": "uma_authorization", + "description": "${role_uma_authorization}", + "composite": false, + "clientRole": false, + "containerId": "d7757cae-367b-4dfa-87f7-a19a789af2b9", + "attributes": {} + }, + { + "id": "3473c742-cfa1-4d81-975e-0d74bdf56795", + "name": "default-roles-master", + "description": "${role_default-roles}", + "composite": true, + "composites": { + "realm": ["offline_access", "uma_authorization"] + }, + "clientRole": false, + "containerId": "d7757cae-367b-4dfa-87f7-a19a789af2b9", + "attributes": {} + } + ], + "client": { + "sqlpage_cas_demo": [], + "realm-management": [ + { + "id": "16d9a55f-c85f-4e81-88b1-fae50781e9cd", + "name": "query-groups", + "description": "${role_query-groups}", + "composite": false, + "clientRole": true, + "containerId": "5cfd9edd-a0a5-4ead-ac29-0a4b1ffa17db", + "attributes": {} + }, + { + "id": "356acf1d-dcae-448a-ad91-1eb7e87d5a66", + "name": "view-identity-providers", + "description": "${role_view-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "5cfd9edd-a0a5-4ead-ac29-0a4b1ffa17db", + "attributes": {} + }, + { + "id": "52d49f80-5549-471f-ac67-5eb50b047405", + "name": "manage-realm", + "description": "${role_manage-realm}", + "composite": false, + "clientRole": true, + "containerId": "5cfd9edd-a0a5-4ead-ac29-0a4b1ffa17db", + "attributes": {} + }, + { + "id": "5142283f-9900-4ab8-9759-19ec861f0b4e", + "name": "realm-admin", + "description": "${role_realm-admin}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "query-groups", + "view-identity-providers", + "manage-realm", + "create-client", + "view-events", + "view-authorization", + "view-realm", + "query-users", + "manage-authorization", + "manage-users", + "query-clients", + "manage-identity-providers", + "manage-clients", + "view-users", + "impersonation", + "manage-events", + "view-clients", + "query-realms" + ] + } + }, + "clientRole": true, + "containerId": "5cfd9edd-a0a5-4ead-ac29-0a4b1ffa17db", + "attributes": {} + }, + { + "id": "31ded332-e8e0-4bd0-928a-aec9c2917edc", + "name": "create-client", + "description": "${role_create-client}", + "composite": false, + "clientRole": true, + "containerId": "5cfd9edd-a0a5-4ead-ac29-0a4b1ffa17db", + "attributes": {} + }, + { + "id": "bf4d4971-836b-49bb-bf66-5d07992bda20", + "name": "view-events", + "description": "${role_view-events}", + "composite": false, + "clientRole": true, + "containerId": "5cfd9edd-a0a5-4ead-ac29-0a4b1ffa17db", + "attributes": {} + }, + { + "id": "9d665e00-274f-4389-ba0e-a8a8f7eae5fe", + "name": "view-authorization", + "description": "${role_view-authorization}", + "composite": false, + "clientRole": true, + "containerId": "5cfd9edd-a0a5-4ead-ac29-0a4b1ffa17db", + "attributes": {} + }, + { + "id": "d4d35d92-798c-4740-bf10-6ef454cb954d", + "name": "view-realm", + "description": "${role_view-realm}", + "composite": false, + "clientRole": true, + "containerId": "5cfd9edd-a0a5-4ead-ac29-0a4b1ffa17db", + "attributes": {} + }, + { + "id": "053ad20f-3570-4e1c-af3a-6d275236cdc2", + "name": "query-users", + "description": "${role_query-users}", + "composite": false, + "clientRole": true, + "containerId": "5cfd9edd-a0a5-4ead-ac29-0a4b1ffa17db", + "attributes": {} + }, + { + "id": "65bdc99b-ab0d-4693-b089-648d75650da2", + "name": "manage-authorization", + "description": "${role_manage-authorization}", + "composite": false, + "clientRole": true, + "containerId": "5cfd9edd-a0a5-4ead-ac29-0a4b1ffa17db", + "attributes": {} + }, + { + "id": "3c21aad6-86eb-48cd-a700-81e3195bfb58", + "name": "manage-users", + "description": "${role_manage-users}", + "composite": false, + "clientRole": true, + "containerId": "5cfd9edd-a0a5-4ead-ac29-0a4b1ffa17db", + "attributes": {} + }, + { + "id": "6b1169bb-7bd1-4154-9ef8-d104c94dff04", + "name": "query-clients", + "description": "${role_query-clients}", + "composite": false, + "clientRole": true, + "containerId": "5cfd9edd-a0a5-4ead-ac29-0a4b1ffa17db", + "attributes": {} + }, + { + "id": "752ec963-dcd1-4558-85c0-9c606f5d1181", + "name": "manage-clients", + "description": "${role_manage-clients}", + "composite": false, + "clientRole": true, + "containerId": "5cfd9edd-a0a5-4ead-ac29-0a4b1ffa17db", + "attributes": {} + }, + { + "id": "10befa18-80c8-461e-bcea-5dba1d864cb0", + "name": "manage-identity-providers", + "description": "${role_manage-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "5cfd9edd-a0a5-4ead-ac29-0a4b1ffa17db", + "attributes": {} + }, + { + "id": "c9991186-694b-4758-8e1b-ec6aa334ef4b", + "name": "impersonation", + "description": "${role_impersonation}", + "composite": false, + "clientRole": true, + "containerId": "5cfd9edd-a0a5-4ead-ac29-0a4b1ffa17db", + "attributes": {} + }, + { + "id": "b65a8c12-f5f8-427a-9d90-eccf22d847a2", + "name": "view-users", + "description": "${role_view-users}", + "composite": true, + "composites": { + "client": { + "realm-management": ["query-users", "query-groups"] + } + }, + "clientRole": true, + "containerId": "5cfd9edd-a0a5-4ead-ac29-0a4b1ffa17db", + "attributes": {} + }, + { + "id": "57dbd22f-cb06-47fb-a0d3-d7bd7f715595", + "name": "manage-events", + "description": "${role_manage-events}", + "composite": false, + "clientRole": true, + "containerId": "5cfd9edd-a0a5-4ead-ac29-0a4b1ffa17db", + "attributes": {} + }, + { + "id": "d533dda6-e2f7-4163-a0b6-4752cf74bcde", + "name": "view-clients", + "description": "${role_view-clients}", + "composite": true, + "composites": { + "client": { + "realm-management": ["query-clients"] + } + }, + "clientRole": true, + "containerId": "5cfd9edd-a0a5-4ead-ac29-0a4b1ffa17db", + "attributes": {} + }, + { + "id": "439fe1a2-c13b-4043-832d-00558a53ec6e", + "name": "query-realms", + "description": "${role_query-realms}", + "composite": false, + "clientRole": true, + "containerId": "5cfd9edd-a0a5-4ead-ac29-0a4b1ffa17db", + "attributes": {} + } + ], + "security-admin-console": [], + "admin-cli": [], + "account-console": [], + "sqlpage": [], + "broker": [], + "master-realm": [], + "account": [ + { + "id": "f97398fd-8608-46eb-ad58-39b92dee69a7", + "name": "view-groups", + "composite": false, + "clientRole": true, + "containerId": "d50163a2-d59f-4c1d-a1e5-087b5fa3920d", + "attributes": {} + }, + { + "id": "722736c4-47ce-4a8f-a74a-38b37f172bc3", + "name": "delete-account", + "description": "${role_delete-account}", + "composite": false, + "clientRole": true, + "containerId": "d50163a2-d59f-4c1d-a1e5-087b5fa3920d", + "attributes": {} + }, + { + "id": "9cd94925-88bd-4cd2-af60-797da71aa1e5", + "name": "manage-account", + "composite": false, + "clientRole": true, + "containerId": "d50163a2-d59f-4c1d-a1e5-087b5fa3920d", + "attributes": {} + } + ] + } + }, + "groups": [], + "defaultRole": { + "id": "3473c742-cfa1-4d81-975e-0d74bdf56795", + "name": "default-roles-master", + "description": "${role_default-roles}", + "composite": true, + "clientRole": false, + "containerId": "d7757cae-367b-4dfa-87f7-a19a789af2b9" + }, + "requiredCredentials": ["password"], + "otpPolicyType": "totp", + "otpPolicyAlgorithm": "HmacSHA1", + "otpPolicyInitialCounter": 0, + "otpPolicyDigits": 6, + "otpPolicyLookAheadWindow": 1, + "otpPolicyPeriod": 30, + "otpPolicyCodeReusable": false, + "otpSupportedApplications": [ + "totpAppFreeOTPName", + "totpAppGoogleName", + "totpAppMicrosoftAuthenticatorName" + ], + "localizationTexts": {}, + "webAuthnPolicyRpEntityName": "keycloak", + "webAuthnPolicySignatureAlgorithms": ["ES256"], + "webAuthnPolicyRpId": "", + "webAuthnPolicyAttestationConveyancePreference": "not specified", + "webAuthnPolicyAuthenticatorAttachment": "not specified", + "webAuthnPolicyRequireResidentKey": "not specified", + "webAuthnPolicyUserVerificationRequirement": "not specified", + "webAuthnPolicyCreateTimeout": 0, + "webAuthnPolicyAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyAcceptableAaguids": [], + "webAuthnPolicyExtraOrigins": [], + "webAuthnPolicyPasswordlessRpEntityName": "keycloak", + "webAuthnPolicyPasswordlessSignatureAlgorithms": ["ES256"], + "webAuthnPolicyPasswordlessRpId": "", + "webAuthnPolicyPasswordlessAttestationConveyancePreference": "not specified", + "webAuthnPolicyPasswordlessAuthenticatorAttachment": "not specified", + "webAuthnPolicyPasswordlessRequireResidentKey": "not specified", + "webAuthnPolicyPasswordlessUserVerificationRequirement": "not specified", + "webAuthnPolicyPasswordlessCreateTimeout": 0, + "webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyPasswordlessAcceptableAaguids": [], + "webAuthnPolicyPasswordlessExtraOrigins": [], + "users": [ + { + "id": "0cc0472e-a38b-4f45-8d91-77ecfe5c8b7d", + "username": "demo", + "firstName": "John", + "lastName": "Smith", + "email": "demo@example.com", + "emailVerified": false, + "createdTimestamp": 1714079479552, + "enabled": true, + "totp": false, + "credentials": [ + { + "id": "d453f7cb-5ba5-45ab-a694-38ddffb93503", + "type": "password", + "userLabel": "My password", + "createdDate": 1714079498525, + "secretData": "{\"value\":\"gxi8oR/w6GPvZjUXJAsxSuxWZCDsxL3hwzjlfymoeYsRLXxJIvJdy5SeRch4BOYwNdfRwrbOenBGScCleyQkfA==\",\"salt\":\"HTsvAP/Cig6pIOVo3SPFnw==\",\"additionalParameters\":{}}", + "credentialData": "{\"hashIterations\":210000,\"algorithm\":\"pbkdf2-sha512\",\"additionalParameters\":{}}" + } + ], + "disableableCredentialTypes": [], + "requiredActions": [], + "realmRoles": ["default-roles-master"], + "notBefore": 0, + "groups": [] + } + ], + "scopeMappings": [ + { + "clientScope": "offline_access", + "roles": ["offline_access"] + } + ], + "clientScopeMappings": { + "account": [ + { + "client": "account-console", + "roles": ["manage-account", "view-groups"] + } + ] + }, + "clients": [ + { + "id": "d50163a2-d59f-4c1d-a1e5-087b5fa3920d", + "clientId": "account", + "name": "${client_account}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/master/account/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": ["/realms/master/account/*"], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "89f96859-de46-4778-bd0b-e44256ff8ea2", + "name": "family name", + "protocol": "cas", + "protocolMapper": "cas-usermodel-property-mapper", + "consentRequired": false, + "config": { + "user.attribute": "lastName", + "claim.name": "sn", + "jsonType.label": "String" + } + }, + { + "id": "7a8f7d8b-93dc-4f5c-a8cf-2caa9802ae35", + "name": "email", + "protocol": "cas", + "protocolMapper": "cas-usermodel-property-mapper", + "consentRequired": false, + "config": { + "user.attribute": "email", + "claim.name": "mail", + "jsonType.label": "String" + } + }, + { + "id": "853110df-77fe-49b2-b8be-000ae4922b88", + "name": "full name", + "protocol": "cas", + "protocolMapper": "cas-full-name-mapper", + "consentRequired": false, + "config": { + "claim.name": "cn", + "jsonType.label": "String" + } + }, + { + "id": "12e0887d-af61-4066-9d29-a54f2523606d", + "name": "given name", + "protocol": "cas", + "protocolMapper": "cas-usermodel-property-mapper", + "consentRequired": false, + "config": { + "user.attribute": "firstName", + "claim.name": "givenName", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "ff46bc2b-4c0b-49b3-9bca-0938b05a9581", + "clientId": "account-console", + "name": "${client_account-console}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/master/account/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": ["/realms/master/account/*"], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+", + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "46a75d57-9af5-4466-8e0c-deb66d6d58e8", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": {} + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "12f13df0-1a10-4792-b62a-c67b3d0f9bb0", + "clientId": "admin-cli", + "name": "${client_admin-cli}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "0c800a73-f8f1-40ea-b91e-a6bc6e9c6b37", + "name": "email", + "protocol": "cas", + "protocolMapper": "cas-usermodel-property-mapper", + "consentRequired": false, + "config": { + "user.attribute": "email", + "claim.name": "mail", + "jsonType.label": "String" + } + }, + { + "id": "8e502dad-3945-4e58-bb7f-bdd4d2d0416d", + "name": "family name", + "protocol": "cas", + "protocolMapper": "cas-usermodel-property-mapper", + "consentRequired": false, + "config": { + "user.attribute": "lastName", + "claim.name": "sn", + "jsonType.label": "String" + } + }, + { + "id": "18c6e46a-c5cc-45ba-9ac6-cddc2c59fd1a", + "name": "full name", + "protocol": "cas", + "protocolMapper": "cas-full-name-mapper", + "consentRequired": false, + "config": { + "claim.name": "cn", + "jsonType.label": "String" + } + }, + { + "id": "b9171c27-55f9-4f99-9086-c1828ae0f884", + "name": "given name", + "protocol": "cas", + "protocolMapper": "cas-usermodel-property-mapper", + "consentRequired": false, + "config": { + "user.attribute": "firstName", + "claim.name": "givenName", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "1b5f331e-aaa5-4225-888d-b212d60211fb", + "clientId": "broker", + "name": "${client_broker}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "5342db5b-d1ba-4218-8900-3d5e2e0a8ccb", + "name": "given name", + "protocol": "cas", + "protocolMapper": "cas-usermodel-property-mapper", + "consentRequired": false, + "config": { + "user.attribute": "firstName", + "claim.name": "givenName", + "jsonType.label": "String" + } + }, + { + "id": "4bc5fc15-8488-4ef0-a0c0-4a28e0803fb4", + "name": "family name", + "protocol": "cas", + "protocolMapper": "cas-usermodel-property-mapper", + "consentRequired": false, + "config": { + "user.attribute": "lastName", + "claim.name": "sn", + "jsonType.label": "String" + } + }, + { + "id": "bae6a63f-981f-429a-b4d4-1687a9dc5386", + "name": "full name", + "protocol": "cas", + "protocolMapper": "cas-full-name-mapper", + "consentRequired": false, + "config": { + "claim.name": "cn", + "jsonType.label": "String" + } + }, + { + "id": "31c1c637-07a5-4884-892d-bd904f7ff1fb", + "name": "email", + "protocol": "cas", + "protocolMapper": "cas-usermodel-property-mapper", + "consentRequired": false, + "config": { + "user.attribute": "email", + "claim.name": "mail", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "a3bb7f5a-8746-4d66-a6d3-60c355489276", + "clientId": "master-realm", + "name": "master Realm", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "6e805d7f-9d7f-4f26-91e7-c58ea91df054", + "name": "full name", + "protocol": "cas", + "protocolMapper": "cas-full-name-mapper", + "consentRequired": false, + "config": { + "claim.name": "cn", + "jsonType.label": "String" + } + }, + { + "id": "ccd6d83f-2da8-40dd-b94d-77ded30dc831", + "name": "email", + "protocol": "cas", + "protocolMapper": "cas-usermodel-property-mapper", + "consentRequired": false, + "config": { + "user.attribute": "email", + "claim.name": "mail", + "jsonType.label": "String" + } + }, + { + "id": "119d7e06-8651-42b6-af42-4ea02bfb49e0", + "name": "given name", + "protocol": "cas", + "protocolMapper": "cas-usermodel-property-mapper", + "consentRequired": false, + "config": { + "user.attribute": "firstName", + "claim.name": "givenName", + "jsonType.label": "String" + } + }, + { + "id": "7dd81fc3-b99a-4f3a-8a5a-ab072e8bf935", + "name": "family name", + "protocol": "cas", + "protocolMapper": "cas-usermodel-property-mapper", + "consentRequired": false, + "config": { + "user.attribute": "lastName", + "claim.name": "sn", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "5cfd9edd-a0a5-4ead-ac29-0a4b1ffa17db", + "clientId": "realm-management", + "name": "${client_realm-management}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "d97602ca-263b-4115-88d8-fa1470e231db", + "name": "family name", + "protocol": "cas", + "protocolMapper": "cas-usermodel-property-mapper", + "consentRequired": false, + "config": { + "user.attribute": "lastName", + "claim.name": "sn", + "jsonType.label": "String" + } + }, + { + "id": "1d5c2948-1c22-4444-9de2-ec9d5fab24a2", + "name": "given name", + "protocol": "cas", + "protocolMapper": "cas-usermodel-property-mapper", + "consentRequired": false, + "config": { + "user.attribute": "firstName", + "claim.name": "givenName", + "jsonType.label": "String" + } + }, + { + "id": "64760efc-d03f-4f2f-8cae-63684200eace", + "name": "full name", + "protocol": "cas", + "protocolMapper": "cas-full-name-mapper", + "consentRequired": false, + "config": { + "claim.name": "cn", + "jsonType.label": "String" + } + }, + { + "id": "52dd9974-2d5c-47ef-8229-d1c97e59d4f0", + "name": "email", + "protocol": "cas", + "protocolMapper": "cas-usermodel-property-mapper", + "consentRequired": false, + "config": { + "user.attribute": "email", + "claim.name": "mail", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [], + "optionalClientScopes": [] + }, + { + "id": "12a6effc-cf82-4dff-9bdc-d7610b86d89c", + "clientId": "security-admin-console", + "name": "${client_security-admin-console}", + "rootUrl": "${authAdminUrl}", + "baseUrl": "/admin/master/console/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": ["/admin/master/console/*"], + "webOrigins": ["+"], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+", + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "baecafba-25d9-473e-a7af-72d18a84fd83", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "a2bec2b8-f850-405e-9f26-59063ffa6f08", + "clientId": "sqlpage", + "name": "SQLPage SSO Demo App", + "description": "", + "rootUrl": "http://localhost:8080/", + "adminUrl": "http://localhost:8080/", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": true, + "clientAuthenticatorType": "client-secret", + "secret": "qiawfnYrYzsmoaOZT28rRjPPRamfvrYr", + "redirectUris": ["http://localhost:8080/sqlpage/oidc_callback"], + "webOrigins": ["http://localhost:8080"], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": true, + "protocol": "openid-connect", + "attributes": { + "client.secret.creation.time": "1714080951", + "login_theme": "keycloak", + "post.logout.redirect.uris": "+##http://localhost:8080/", + "oauth2.device.authorization.grant.enabled": "false", + "backchannel.logout.revoke.offline.tokens": "false", + "use.refresh.tokens": "true", + "oidc.ciba.grant.enabled": "false", + "client.use.lightweight.access.token.enabled": "false", + "backchannel.logout.session.required": "true", + "client_credentials.use_refresh_token": "false", + "tls.client.certificate.bound.access.tokens": "false", + "require.pushed.authorization.requests": "false", + "acr.loa.map": "{}", + "display.on.consent.screen": "false", + "token.response.type.bearer.lower-case": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "protocolMappers": [ + { + "id": "62a6fb7a-c7e5-44c3-b878-5bbfa932dc3c", + "name": "email", + "protocol": "cas", + "protocolMapper": "cas-usermodel-property-mapper", + "consentRequired": false, + "config": { + "user.attribute": "email", + "claim.name": "mail", + "jsonType.label": "String" + } + }, + { + "id": "9b15bf7e-28c9-46dd-8e38-0d69f8cab137", + "name": "given name", + "protocol": "cas", + "protocolMapper": "cas-usermodel-property-mapper", + "consentRequired": false, + "config": { + "user.attribute": "firstName", + "claim.name": "givenName", + "jsonType.label": "String" + } + }, + { + "id": "15150602-9a7d-4850-8f69-17b3553174c4", + "name": "family name", + "protocol": "cas", + "protocolMapper": "cas-usermodel-property-mapper", + "consentRequired": false, + "config": { + "user.attribute": "lastName", + "claim.name": "sn", + "jsonType.label": "String" + } + }, + { + "id": "05e051d1-3954-43c4-a4ea-73b5879dac17", + "name": "full name", + "protocol": "cas", + "protocolMapper": "cas-full-name-mapper", + "consentRequired": false, + "config": { + "claim.name": "cn", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "4b0aaf08-2b73-4d18-8030-83a20b560e62", + "clientId": "sqlpage_cas_demo", + "name": "SQLPage CAS demo", + "description": "This is using keycloak, but hopefully this is compatible with Apero CAS.", + "rootUrl": "http://localhost:8080/", + "adminUrl": "http://localhost:8080/", + "baseUrl": "http://localhost:8080/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": ["http://localhost:8080/cas/redirect_handler.sql"], + "webOrigins": ["+"], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": true, + "protocol": "cas", + "attributes": { + "post.logout.redirect.uris": "http://localhost:8080/cas/redirect_handler.sql" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "protocolMappers": [ + { + "id": "afe008e7-a775-45ca-9b88-7d921a816aad", + "name": "family name", + "protocol": "cas", + "protocolMapper": "cas-usermodel-property-mapper", + "consentRequired": false, + "config": { + "user.attribute": "lastName", + "claim.name": "sn", + "jsonType.label": "String" + } + }, + { + "id": "e54f5acb-5ed1-4640-99ed-03fc3f92c520", + "name": "given name", + "protocol": "cas", + "protocolMapper": "cas-usermodel-property-mapper", + "consentRequired": false, + "config": { + "user.attribute": "firstName", + "claim.name": "givenName", + "jsonType.label": "String" + } + }, + { + "id": "af22294a-2ba1-4085-aee7-ccebab888ec4", + "name": "full name", + "protocol": "cas", + "protocolMapper": "cas-full-name-mapper", + "consentRequired": false, + "config": { + "claim.name": "cn", + "jsonType.label": "String" + } + }, + { + "id": "569ec0d9-c656-4492-b3a2-0a8d1a4df029", + "name": "email", + "protocol": "cas", + "protocolMapper": "cas-usermodel-property-mapper", + "consentRequired": false, + "config": { + "user.attribute": "email", + "claim.name": "mail", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [], + "optionalClientScopes": [] + } + ], + "clientScopes": [ + { + "id": "c14c09ff-087e-4272-9cc4-b2a997f64a55", + "name": "address", + "description": "OpenID Connect built-in scope: address", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${addressScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "60c43d69-2518-4424-a5b8-5cdc33e2ac17", + "name": "address", + "protocol": "openid-connect", + "protocolMapper": "oidc-address-mapper", + "consentRequired": false, + "config": { + "user.attribute.formatted": "formatted", + "user.attribute.country": "country", + "introspection.token.claim": "true", + "user.attribute.postal_code": "postal_code", + "userinfo.token.claim": "true", + "user.attribute.street": "street", + "id.token.claim": "true", + "user.attribute.region": "region", + "access.token.claim": "true", + "user.attribute.locality": "locality" + } + } + ] + }, + { + "id": "03ea4147-a506-45a9-84ae-e1efe2708eea", + "name": "email", + "description": "OpenID Connect built-in scope: email", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${emailScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "04d4097a-16b3-4155-9ff3-672d04079e16", + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "email", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email", + "jsonType.label": "String" + } + }, + { + "id": "f3bdae59-35e2-46b8-9625-a53a066993b2", + "name": "email verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "emailVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email_verified", + "jsonType.label": "boolean" + } + } + ] + }, + { + "id": "7bbedae3-a3b5-4d76-b457-b00010254408", + "name": "profile", + "description": "OpenID Connect built-in scope: profile", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${profileScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "7c97d390-3a24-4013-85a7-674427d49ab8", + "name": "updated at", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "updatedAt", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "updated_at", + "jsonType.label": "long" + } + }, + { + "id": "b740d3b8-38c7-460e-94aa-6525aec2e00b", + "name": "profile", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "profile", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "profile", + "jsonType.label": "String" + } + }, + { + "id": "df3c39d5-cd3f-4d38-899c-d0899d82dcbb", + "name": "gender", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "gender", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "gender", + "jsonType.label": "String" + } + }, + { + "id": "9dbeb8fe-333d-4311-a389-42075ff057b4", + "name": "birthdate", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "birthdate", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "birthdate", + "jsonType.label": "String" + } + }, + { + "id": "19e29316-24dd-4e4a-aa85-e767cd867a79", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + }, + { + "id": "2bfa792d-ea47-4d43-b456-11cde3c937f2", + "name": "given name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "firstName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "given_name", + "jsonType.label": "String" + } + }, + { + "id": "c4aa41a4-bef1-401b-abe8-3d48f101020d", + "name": "full name", + "protocol": "openid-connect", + "protocolMapper": "oidc-full-name-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "id": "c9617211-8247-4b5e-9b71-58e61bdaab21", + "name": "nickname", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "nickname", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "nickname", + "jsonType.label": "String" + } + }, + { + "id": "b7ae04f4-415e-4e0e-b7b6-1af6841d22bb", + "name": "middle name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "middleName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "middle_name", + "jsonType.label": "String" + } + }, + { + "id": "51a166d3-ba9a-4ba6-b1a1-d259563aa49a", + "name": "family name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "lastName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "family_name", + "jsonType.label": "String" + } + }, + { + "id": "e3c90b13-0db0-40f7-b640-487f18b01214", + "name": "username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "preferred_username", + "jsonType.label": "String" + } + }, + { + "id": "cd5cfea9-9151-486c-9ec1-e9d503543201", + "name": "zoneinfo", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "zoneinfo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "zoneinfo", + "jsonType.label": "String" + } + }, + { + "id": "bfc74493-3b09-4dac-b7be-41cc6d2e209e", + "name": "website", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "website", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "website", + "jsonType.label": "String" + } + }, + { + "id": "8dfd3646-a624-43ee-85de-82aa171f3ad7", + "name": "picture", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "picture", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "picture", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "990085f5-6624-43e0-bd8e-ee19427c9900", + "name": "microprofile-jwt", + "description": "Microprofile - JWT built-in scope", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "824634cf-dfc0-4fc9-b47b-3803a5868e6f", + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "multivalued": "true", + "userinfo.token.claim": "true", + "user.attribute": "foo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "groups", + "jsonType.label": "String" + } + }, + { + "id": "5b5a7a38-9746-4593-b94d-4dbcb79a57d8", + "name": "upn", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "upn", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "34321bc7-3fee-4999-b718-19d907b221a4", + "name": "role_list", + "description": "SAML role list", + "protocol": "saml", + "attributes": { + "consent.screen.text": "${samlRoleListScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "2a7bc0fb-adce-4b3d-b423-7035f31fb5a9", + "name": "role list", + "protocol": "saml", + "protocolMapper": "saml-role-list-mapper", + "consentRequired": false, + "config": { + "single": "false", + "attribute.nameformat": "Basic", + "attribute.name": "Role" + } + } + ] + }, + { + "id": "bd166300-5b05-4726-b0eb-68c29e35f1af", + "name": "phone", + "description": "OpenID Connect built-in scope: phone", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${phoneScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "ef839796-9f9a-47c0-9685-c9edcf6754a5", + "name": "phone number verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "phoneNumberVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number_verified", + "jsonType.label": "boolean" + } + }, + { + "id": "f74a2e81-da33-4e8d-943a-d5e665adf499", + "name": "phone number", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "phoneNumber", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "d5196e6b-5bb9-4eb0-92b3-2f8d129d5802", + "name": "web-origins", + "description": "OpenID Connect scope for add allowed web origins to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false", + "consent.screen.text": "" + }, + "protocolMappers": [ + { + "id": "cdf64576-9f80-482a-bcff-6c547309d9bd", + "name": "allowed web origins", + "protocol": "openid-connect", + "protocolMapper": "oidc-allowed-origins-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "access.token.claim": "true" + } + } + ] + }, + { + "id": "6c3ffd12-fa43-4cb8-a118-c4643b98df70", + "name": "offline_access", + "description": "OpenID Connect built-in scope: offline_access", + "protocol": "openid-connect", + "attributes": { + "consent.screen.text": "${offlineAccessScopeConsentText}", + "display.on.consent.screen": "true" + } + }, + { + "id": "c2045a80-7b4d-4e06-acb9-7299ba16134c", + "name": "acr", + "description": "OpenID Connect scope for add acr (authentication context class reference) to the token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "ad467869-6344-40b2-af7a-70687d76e6fd", + "name": "acr loa level", + "protocol": "openid-connect", + "protocolMapper": "oidc-acr-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + } + ] + }, + { + "id": "7c7710a3-e3a7-4c70-a246-04a14c9733bb", + "name": "roles", + "description": "OpenID Connect scope for add user roles to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "true", + "consent.screen.text": "${rolesScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "780cb5bb-a37d-4f87-a7d5-a0942a2c1589", + "name": "realm roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "multivalued": "true", + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "realm_access.roles", + "jsonType.label": "String" + } + }, + { + "id": "3f65310d-21f0-4973-8c67-457a0d851ba3", + "name": "client roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-client-role-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "multivalued": "true", + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "resource_access.${client_id}.roles", + "jsonType.label": "String" + } + }, + { + "id": "1a6720eb-c33d-4c39-9d42-051aa1cf7d85", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "access.token.claim": "true" + } + } + ] + } + ], + "defaultDefaultClientScopes": [ + "role_list", + "profile", + "email", + "roles", + "web-origins", + "acr" + ], + "defaultOptionalClientScopes": [ + "offline_access", + "address", + "phone", + "microprofile-jwt" + ], + "browserSecurityHeaders": { + "contentSecurityPolicyReportOnly": "", + "xContentTypeOptions": "nosniff", + "referrerPolicy": "no-referrer", + "xRobotsTag": "none", + "xFrameOptions": "SAMEORIGIN", + "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", + "xXSSProtection": "1; mode=block", + "strictTransportSecurity": "max-age=31536000; includeSubDomains" + }, + "smtpServer": {}, + "loginTheme": "keycloak", + "accountTheme": "keycloak.v3", + "adminTheme": "keycloak.v2", + "emailTheme": "keycloak", + "eventsEnabled": false, + "eventsListeners": ["jboss-logging"], + "enabledEventTypes": [], + "adminEventsEnabled": false, + "adminEventsDetailsEnabled": false, + "identityProviders": [], + "identityProviderMappers": [], + "components": { + "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [ + { + "id": "e89fe502-91fd-4f04-bdcc-4b14e817e4dc", + "name": "Full Scope Disabled", + "providerId": "scope", + "subType": "anonymous", + "subComponents": {}, + "config": {} + }, + { + "id": "1414070f-5cd6-4446-b53a-dcd9c28c26a5", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allow-default-scopes": ["true"] + } + }, + { + "id": "1e6d1ac0-d10b-4eaf-8b87-39ccf6a755d2", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "saml-user-attribute-mapper", + "oidc-usermodel-property-mapper", + "saml-role-list-mapper", + "oidc-sha256-pairwise-sub-mapper", + "oidc-address-mapper", + "oidc-full-name-mapper", + "saml-user-property-mapper", + "oidc-usermodel-attribute-mapper" + ] + } + }, + { + "id": "b34cb2f6-806e-49c3-822a-614b4465faf7", + "name": "Consent Required", + "providerId": "consent-required", + "subType": "anonymous", + "subComponents": {}, + "config": {} + }, + { + "id": "81af21c3-da8a-4f70-9b33-43d0150f0bbb", + "name": "Max Clients Limit", + "providerId": "max-clients", + "subType": "anonymous", + "subComponents": {}, + "config": { + "max-clients": ["200"] + } + }, + { + "id": "58f683ad-378d-4f9f-93db-630007bef02a", + "name": "Trusted Hosts", + "providerId": "trusted-hosts", + "subType": "anonymous", + "subComponents": {}, + "config": { + "host-sending-registration-request-must-match": ["true"], + "client-uris-must-match": ["true"] + } + }, + { + "id": "26a3c97d-052b-40b6-8d8f-8950befb1837", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "oidc-sha256-pairwise-sub-mapper", + "oidc-usermodel-attribute-mapper", + "saml-role-list-mapper", + "saml-user-attribute-mapper", + "oidc-address-mapper", + "saml-user-property-mapper", + "oidc-usermodel-property-mapper", + "oidc-full-name-mapper" + ] + } + }, + { + "id": "1a2b238f-2b2c-488f-977e-955c62134b1a", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allow-default-scopes": ["true"] + } + } + ], + "org.keycloak.userprofile.UserProfileProvider": [ + { + "id": "a49f2d51-c152-468d-a070-28a931fab95c", + "providerId": "declarative-user-profile", + "subComponents": {}, + "config": { + "kc.user.profile.config": [ + "{\"attributes\":[{\"name\":\"username\",\"displayName\":\"${username}\",\"validations\":{\"length\":{\"min\":3,\"max\":255},\"username-prohibited-characters\":{},\"up-username-not-idn-homograph\":{}},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"email\",\"displayName\":\"${email}\",\"validations\":{\"email\":{},\"length\":{\"max\":255}},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"firstName\",\"displayName\":\"${firstName}\",\"validations\":{\"length\":{\"max\":255},\"person-name-prohibited-characters\":{}},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"lastName\",\"displayName\":\"${lastName}\",\"validations\":{\"length\":{\"max\":255},\"person-name-prohibited-characters\":{}},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false}],\"groups\":[{\"name\":\"user-metadata\",\"displayHeader\":\"User metadata\",\"displayDescription\":\"Attributes, which refer to user metadata\"}]}" + ] + } + } + ], + "org.keycloak.keys.KeyProvider": [ + { + "id": "74f24861-5376-4224-bdc0-1d511b60e8df", + "name": "rsa-enc-generated", + "providerId": "rsa-enc-generated", + "subComponents": {}, + "config": { + "privateKey": [ + "MIIEpAIBAAKCAQEAvYkJyhtq3Pi3GjppeBXN8QLvNd6LNM/78d/3XJCZPNizPfCEQk5IDJlxJaqEIsoYZaB0f2Hr3/SBTVvYnSPQhr2hY/FKnk4ptkgieJf6Sc6rUO/2w/zITePexTjIdmbOD9ftS3FxLoiNq/kJ8ZeBpx/Jdpusu20CyrCIVmDS57O0lumXMrwenyu8bF2rx9MNwDlUzFcKENaQDcWnxmD9xPOue+MvtaAqG/BRf1/QRiu5CT1IPh1FjevCmiUOlpe4CUuZ2vRhb6SXKdE+Ha/HvbvjSuloZ9bFOL1LcOPRDY6wlHFiWNJ3Zs+Es0jMJ3dT7+/rAh4Z0pPQLBAhzryFqwIDAQABAoIBAAOzyRQAbBp2kJu7t11dTlaztXjVE4gLu1f9WtIdOvkOzJWG/LZk3BBu8OBeT9LJetIwC7EvTacOxMso4kwo1m/DWtiJGT7gDP2JXtmsh6WTGr1BKrinrUFO6bDq4TOQN4c6CGHDDLBu12xGuD8sCUrP7/oCpCGhyVOslhqF1/4m97IlYjNPLQ5PPTIg9Am1zDOl/psXxgu+zWzUlI89ARGDNsrwCbcd3qFrxKCidoNkXjbaNa/YsMSbOdSNLbthjvpR78qAoGBC0vYwaR/4c+jaAc2E29gahEn0DOKGEKKFqnprj7BRIiiXasEQX+A6Wfs6v/pPdcQlklLFFncZKF0CgYEA7UMx+/CaGZMSc/AVPnbRO6FQ1d/jjZEXI3tflz9cELM2gyqMMEIe9/uxRCu/RKH73t+V07z65iGRVdDWNMZ1iIQpt50hblicJvF83YT2tjxQExnQo7sle9Jm9wApTfIOb4bolrdtSc7hEYYYiTUIgPBA2tuj9dOQt91kLnN1r+cCgYEAzIDtw3Kmurn72kJ168z1CkHuWBx4DtfEQY0IGUu/X68x/jF6VE8ouXvJuWx49FLJtf1xFnBT4HJ0Il+4UFGomKn6Xd7z7635KZfgQ1L7rwYv7GqOlI4XS4FmMYezyAOtYaosOBLzB/yabNCCGKHMRSrulHzYUCK9aPY7/B5Ck50CgYEAlqeKP53BW+flWbTi6Gzt4t1FxOiLR0MP3DnkstdKkFgbjyIfLi1uGKy7HLxikSQCGL0EGBTxg9tgu4sF2TEDRJIXIz4lEjo1vQyt6sMZHRIjDl3f+3dED+HD+6cgkxvWSr7xRXJndOxmQYhSYB1KrwTfSZkZ/Wg/hmCP0mcCHZUCgYEAhuvQ8g/kXHFz7g3HCulQCZJyE4PE2dYUz0Kiwz2sZw6JJzGxiYooTieTcVhVfKxaFE2/nJRDYmNgp4ULb0JQv1f1rJT5z3myV3SyKvjGwDSOzaWHqA8O42vd5nOncyCp9TN2tRAbc3t+zqfKDUJCKKgoe6LafBRPbr512OKF/ikCgYAZrlAVoN3jzwZxdszFzyz5czDtv2MMepNoYvNsSBNo0tfo8qThByTcN6S5u3q5yMuAMPyhajJ/UYtPxKK75bLkjobkxeX9MjSzwCK6s8DNCwqJJlXFjOsNOigTuB9ku0xq9P5D7AGD1rOgaUE72NjBZLS/c2jwX3Xv2mE2qWpViQ==" + ], + "certificate": [ + "MIICpzCCAY8CBgGPFxVXxDANBgkqhkiG9w0BAQsFADAXMRUwEwYDVQQDDAxzcWxwYWdlX2RlbW8wHhcNMjQwNDI1MjEwNTI1WhcNMzQwNDI1MjEwNzA1WjAXMRUwEwYDVQQDDAxzcWxwYWdlX2RlbW8wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC9iQnKG2rc+LcaOml4Fc3xAu813os0z/vx3/dckJk82LM98IRCTkgMmXElqoQiyhhloHR/Yevf9IFNW9idI9CGvaFj8UqeTim2SCJ4l/pJzqtQ7/bD/MhN497FOMh2Zs4P1+1LcXEuiI2r+Qnxl4GnH8l2m6y7bQLKsIhWYNLns7SW6ZcyvB6fK7xsXavH0w3AOVTMVwoQ1pANxafGYP3E86574y+1oCob8FF/X9BGK7kJPUg+HUWN68KaJQ6Wl7gJS5na9GFvpJcp0T4dr8e9u+NK6Whn1sU4vUtw49ENjrCUcWJY0ndmz4SzSMwnd1Pv7+sCHhnSk9AsECHOvIWrAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAHVqRlWO/uDsyIztqQjJyYbk3TtA1JaCeCOUhHN+7MD78/KGHQwAMU8dTuU/3YajVwm+0uMbnZ4gpV/mUkZqt9aVsiDk2GJ6P4z4H5Tg8BGSLYCVYEuKU7lSXNePxfrHsYf0jC209KJmGPynPwVYmpqZ92ucC0GGQyFRARyjhpx0pg7yxy3ARbzhYT2uggtzdv9DdkZ6vnm6siPlQb4VjDZB76XueT6b8/qNeDVzjfh1igBTyavY3UES9l2bdpQAjUHc6JZVP2xQEAEioHpYv3opAOo6Egu90ON7DeQupukSQEqizvhS9LbVsGKg0iKiVFFazdNtYvamOG1+d4slhoU=" + ], + "priority": ["100"], + "algorithm": ["RSA-OAEP"] + } + }, + { + "id": "814f5c3e-a455-4b04-a90f-4752382b106e", + "name": "aes-generated", + "providerId": "aes-generated", + "subComponents": {}, + "config": { + "kid": ["17498c29-d8ba-4851-bd66-3b56739fbe42"], + "secret": ["Qo8khARW9YKp0Zdh7rLE1g"], + "priority": ["100"] + } + }, + { + "id": "6685a900-2ea7-49ec-b5b9-bd8717784f0d", + "name": "rsa-generated", + "providerId": "rsa-generated", + "subComponents": {}, + "config": { + "privateKey": [ + "MIIEogIBAAKCAQEAuP4foFrftmCEJmZ740D+Top9ezC7gnFVGmXxMbPFmGb8HAqRab2pTKy5CRv6e9DIGyrkEKfbkLM8Vj1XgWg/+7YEovDc08v7biHyHX1LlorVTcZQ6w7fXYdq63iIACV2B1HqhOgd9kGpkwplJRYSGG1+dg1+YySxghiziS3MIfGuwL7sTTAsOePrLj2Y6/MIUDqlu2bXcGXj8CB7ScWy/gdhOu8fAPLKniGIOhQno7me5tbj4s34VoqBy1t9CuWexEi+NIrPJdeum5EDPJ0x/BCGzlHBoA1Iq7MhmhKeFFARmQ+ubPfHdUfvuyswj4fKD2w37cZT8PtUgd3eMssQXQIDAQABAoIBACfA+IPps2SKViu4X0wlReET8sY74Te1ah/ro0rWgpJvIyNVhA0wpEalYXgbKpdb9PydmXgY0l7EnaU8tmbJQ+KwKUvordPX5Ha01cZPjCRUPmVhxjbVMdv0A16JvtQlOLl2+YpJJVMrpijClZzEIuxb706oNK5SjtDRxRcoH9N1MW+d4KNdJzuyHAfFBhFtydx02Uxjg5ptkwCCNwZGqdMtlp4/VGQGGRQEiZCnuOr9JX0U2o0slcCdc4OnJSs1Sf8QVHuefqM3Kf34WEDJ0jScDKV0EMKjGNBdMLLEXBpI9V9sKKvR72Bx5nP/j8/pFN7WOmSwYhwHiDkLS3Gk8hMCgYEA+mzM8W1MZBwToaHAOwMHvc/8lMDBztbfN6/mJrxlDal8v+f932C4ifvKZNmSCBtwFMJxIAcbcxBcJuX4Dj397Ch6ocXvs7kTOxdzppyWc4oI7uaEhGGcLHVQY27qkx4RLCS0ptipWdHTz/51Uj1dVgRtg3KEmIHInqe/4UZKCb8CgYEAvRxqvs2kwAoVxBM4CFBVFjNNnxgPFtVmHAfbKZTPAej87sfLzQCj4QBS8mK603A4J0fh6eJ08kL4FZovnyVGV9oevFIMvOPXZbU953qktGwCIrK9AxmiqZP4sgI8/plEh0VgMwhUg5uh4Mp/ZP9Ag/MXCkxKal19ppmRuRr+lOMCgYBtRsjvmRg6nx3Z7DFsDthz9axsZOitj4n8TN+Li641Ff5/54Ya0aP1YlBhTaexrfdst6SRq0hJH5x2xOdHn7mMMeXBbhQ5Qsunf4ZR8AafCF75kNHGyqlRpSedHCt0YyxvLN0/6U+NCEj7fDhJ2Mk/3dLEB1bhDdEzmlPaw8dPFQKBgEBgeh42F02goUQ8XqjF4BFMqbHtGMXnI3mLWxpOpCG8VM5ciY5iF2ezGomU/pCX9SW6HLfn9XO7RITmFiwRHl8ty6TEMb3juiHPjyFL6OHamueA/UMe6Pbdfp3qkSUCvAdooJT+0vZydqr1hGS3WBkTGdbRncuTxACA6tCe1eeNAoGABeQZWBOW/EvUx7k3lEYr51xWK+R8Lk3xibvg1oiNKtmj+9A+p9Adt5l8z6Q1vwnV10qPbDIy0ja8wkcX7mqCMDZf7A5yEQtvQFNbbk64iwLSPx2H207vn6lVimiSskAUZ1eVFMTxzIddaqmOxzEng6y6gfBOVK0xksgLNnV+NKw=" + ], + "certificate": [ + "MIICpzCCAY8CBgGPFxVYJTANBgkqhkiG9w0BAQsFADAXMRUwEwYDVQQDDAxzcWxwYWdlX2RlbW8wHhcNMjQwNDI1MjEwNTI1WhcNMzQwNDI1MjEwNzA1WjAXMRUwEwYDVQQDDAxzcWxwYWdlX2RlbW8wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC4/h+gWt+2YIQmZnvjQP5Oin17MLuCcVUaZfExs8WYZvwcCpFpvalMrLkJG/p70MgbKuQQp9uQszxWPVeBaD/7tgSi8NzTy/tuIfIdfUuWitVNxlDrDt9dh2rreIgAJXYHUeqE6B32QamTCmUlFhIYbX52DX5jJLGCGLOJLcwh8a7AvuxNMCw54+suPZjr8whQOqW7ZtdwZePwIHtJxbL+B2E67x8A8sqeIYg6FCejuZ7m1uPizfhWioHLW30K5Z7ESL40is8l166bkQM8nTH8EIbOUcGgDUirsyGaEp4UUBGZD65s98d1R++7KzCPh8oPbDftxlPw+1SB3d4yyxBdAgMBAAEwDQYJKoZIhvcNAQELBQADggEBALKVXfayldWsdDjwtUZOtzu9fTU3YbUekvkuYr0Fvs9348ZiWoPvt6JQ9i7ytqDxok9CvgVL347ZS+lDkMKhBpw7ryVS0bG/Vg7DeNmjmutGuGdiJR2nUF8z6SgDVWXqr4XrcDy6xwfVtAazc8MXau+eQozlZBiLV4bKDD793m9zqPeSIIipDozMrfKm4jYnam33d9pRQFGDgEGHqXiwR96x8tC5zlFjngKlX1IgigYqARSsOMaV4vU2aIhIq3bLpvSIGGSDo9iw6iYhBYn9tpmtsHCU/RFqsWPhglU168+0VQesCQphKCXoOZp3qIGnRUVySNZSZrHynQ/wLzI1Dos=" + ], + "priority": ["100"] + } + }, + { + "id": "2b7d2bdf-aee4-4f31-8bec-97bd01182220", + "name": "hmac-generated-hs512", + "providerId": "hmac-generated", + "subComponents": {}, + "config": { + "kid": ["f908ef09-5b02-4d60-8aa9-906f5be4b4b3"], + "secret": [ + "_PZq7evS8vCHPBBLIRlgHrcXtE46TgjSaao5Yh1LlyjHUyHhxarMYYbenDFELpc7nw3WDWr2U0lS-y0QY7EHySYvf6zx5er1hNwPV78g4kvJUYRKKf9U8OmWlsr2E8bDGKBr547El5HyU11_KWykzvi_dBkqX6LsceQB8guy8t4" + ], + "priority": ["100"], + "algorithm": ["HS512"] + } + } + ] + }, + "internationalizationEnabled": false, + "supportedLocales": [], + "authenticationFlows": [ + { + "id": "68f8d935-47fb-42dc-8e23-94cca509c6b1", + "alias": "Account verification options", + "description": "Method with which to verity the existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-email-verification", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Verify Existing Account by Re-authentication", + "userSetupAllowed": false + } + ] + }, + { + "id": "c5c5650b-a462-4b37-9dd9-f1499e369219", + "alias": "Browser - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-otp-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "16124aaf-0384-4d6d-a108-338b5749aa5d", + "alias": "Direct Grant - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "direct-grant-validate-otp", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "3bbf73c5-00f6-4a2a-a2cb-d74f82653fa3", + "alias": "First broker login - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-otp-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "b0e4e594-f55d-46be-86fd-676ec72d979e", + "alias": "Handle Existing Account", + "description": "Handle what to do if there is existing account with same email/username like authenticated identity provider", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-confirm-link", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Account verification options", + "userSetupAllowed": false + } + ] + }, + { + "id": "04618969-a055-4e6f-9e41-27c24b037d30", + "alias": "Reset - Conditional OTP", + "description": "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-otp", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "079f9946-bc86-4b50-970f-0fde0eebafb0", + "alias": "User creation or linking", + "description": "Flow for the existing/non-existing user alternatives", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "create unique user config", + "authenticator": "idp-create-user-if-unique", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Handle Existing Account", + "userSetupAllowed": false + } + ] + }, + { + "id": "b4056290-6f49-4343-a803-8029b8cab587", + "alias": "Verify Existing Account by Re-authentication", + "description": "Reauthentication of existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-username-password-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "First broker login - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "1b02c9b7-50d4-4001-8648-4674d2b5c83f", + "alias": "browser", + "description": "browser based authentication", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-cookie", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-spnego", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "identity-provider-redirector", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 25, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 30, + "autheticatorFlow": true, + "flowAlias": "forms", + "userSetupAllowed": false + } + ] + }, + { + "id": "2c52b4db-2764-4930-ba72-eed08a3568f9", + "alias": "clients", + "description": "Base authentication for clients", + "providerId": "client-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "client-secret", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-jwt", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-secret-jwt", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-x509", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 40, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "d4b0242b-f4a2-4e28-9dc5-5ee8bd64c4a7", + "alias": "direct grant", + "description": "OpenID Connect Resource Owner Grant", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "direct-grant-validate-username", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "direct-grant-validate-password", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 30, + "autheticatorFlow": true, + "flowAlias": "Direct Grant - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "5c90d095-2a79-40c5-a452-98e0cd9402e6", + "alias": "docker auth", + "description": "Used by Docker clients to authenticate against the IDP", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "docker-http-basic-authenticator", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "87054128-dec5-47c4-a461-0491ac601ee8", + "alias": "first broker login", + "description": "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "review profile config", + "authenticator": "idp-review-profile", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "User creation or linking", + "userSetupAllowed": false + } + ] + }, + { + "id": "197b6d93-54f1-46a1-8bc1-fee099e06978", + "alias": "forms", + "description": "Username, password, otp and other auth forms.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-username-password-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Browser - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "ca5c0b1a-51cf-4a79-bb61-5b923eed9bf7", + "alias": "registration", + "description": "registration flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-page-form", + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": true, + "flowAlias": "registration form", + "userSetupAllowed": false + } + ] + }, + { + "id": "856a26b0-cf52-4173-94fb-8726c77de2ab", + "alias": "registration form", + "description": "registration form", + "providerId": "form-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-user-creation", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-password-action", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 50, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-recaptcha-action", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 60, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-terms-and-conditions", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 70, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "ea7cafde-a77b-4fd6-9510-396748960980", + "alias": "reset credentials", + "description": "Reset credentials for a user if they forgot their password or something", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "reset-credentials-choose-user", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-credential-email", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-password", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 40, + "autheticatorFlow": true, + "flowAlias": "Reset - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "35a9e22a-3775-4cad-9a8b-d646cd74c8eb", + "alias": "saml ecp", + "description": "SAML ECP Profile Authentication Flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "http-basic-authenticator", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + } + ], + "authenticatorConfig": [ + { + "id": "4a307cbf-79bc-4125-8eca-4ea98f7cb27b", + "alias": "create unique user config", + "config": { + "require.password.update.after.registration": "false" + } + }, + { + "id": "0a0aaedd-39f6-4dc0-9153-ac6dbd255d3a", + "alias": "review profile config", + "config": { + "update.profile.on.first.login": "missing" + } + } + ], + "requiredActions": [ + { + "alias": "CONFIGURE_TOTP", + "name": "Configure OTP", + "providerId": "CONFIGURE_TOTP", + "enabled": true, + "defaultAction": false, + "priority": 10, + "config": {} + }, + { + "alias": "TERMS_AND_CONDITIONS", + "name": "Terms and Conditions", + "providerId": "TERMS_AND_CONDITIONS", + "enabled": false, + "defaultAction": false, + "priority": 20, + "config": {} + }, + { + "alias": "UPDATE_PASSWORD", + "name": "Update Password", + "providerId": "UPDATE_PASSWORD", + "enabled": true, + "defaultAction": false, + "priority": 30, + "config": {} + }, + { + "alias": "UPDATE_PROFILE", + "name": "Update Profile", + "providerId": "UPDATE_PROFILE", + "enabled": true, + "defaultAction": false, + "priority": 40, + "config": {} + }, + { + "alias": "VERIFY_EMAIL", + "name": "Verify Email", + "providerId": "VERIFY_EMAIL", + "enabled": true, + "defaultAction": false, + "priority": 50, + "config": {} + }, + { + "alias": "delete_account", + "name": "Delete Account", + "providerId": "delete_account", + "enabled": false, + "defaultAction": false, + "priority": 60, + "config": {} + }, + { + "alias": "webauthn-register", + "name": "Webauthn Register", + "providerId": "webauthn-register", + "enabled": true, + "defaultAction": false, + "priority": 70, + "config": {} + }, + { + "alias": "webauthn-register-passwordless", + "name": "Webauthn Register Passwordless", + "providerId": "webauthn-register-passwordless", + "enabled": true, + "defaultAction": false, + "priority": 80, + "config": {} + }, + { + "alias": "VERIFY_PROFILE", + "name": "Verify Profile", + "providerId": "VERIFY_PROFILE", + "enabled": true, + "defaultAction": false, + "priority": 90, + "config": {} + }, + { + "alias": "delete_credential", + "name": "Delete Credential", + "providerId": "delete_credential", + "enabled": true, + "defaultAction": false, + "priority": 100, + "config": {} + }, + { + "alias": "update_user_locale", + "name": "Update User Locale", + "providerId": "update_user_locale", + "enabled": true, + "defaultAction": false, + "priority": 1000, + "config": {} + } + ], + "browserFlow": "browser", + "registrationFlow": "registration", + "directGrantFlow": "direct grant", + "resetCredentialsFlow": "reset credentials", + "clientAuthenticationFlow": "clients", + "dockerAuthenticationFlow": "docker auth", + "firstBrokerLoginFlow": "first broker login", + "attributes": { + "cibaBackchannelTokenDeliveryMode": "poll", + "cibaAuthRequestedUserHint": "login_hint", + "clientOfflineSessionMaxLifespan": "0", + "oauth2DevicePollingInterval": "5", + "clientSessionIdleTimeout": "0", + "clientOfflineSessionIdleTimeout": "0", + "cibaInterval": "5", + "realmReusableOtpCode": "false", + "cibaExpiresIn": "120", + "oauth2DeviceCodeLifespan": "600", + "parRequestUriLifespan": "60", + "clientSessionMaxLifespan": "0", + "frontendUrl": "http://localhost:8181", + "acr.loa.map": "{}" + }, + "keycloakVersion": "24.0.5", + "userManagedAccessAllowed": false, + "clientProfiles": { + "profiles": [] + }, + "clientPolicies": { + "policies": [] + } + } +] diff --git a/examples/single sign on/keycloak.Dockerfile b/examples/single sign on/keycloak.Dockerfile new file mode 100644 index 0000000..7e432ed --- /dev/null +++ b/examples/single sign on/keycloak.Dockerfile @@ -0,0 +1,8 @@ +FROM keycloak/keycloak:26.6.2 + +ADD --chown=1000:0 https://github.com/jacekkow/keycloak-protocol-cas/releases/download/26.6.2/keycloak-protocol-cas-26.6.2.jar \ + /opt/keycloak/providers/keycloak-protocol-cas.jar + +COPY ./keycloak-configuration.json /opt/keycloak/data/import/realm.json + +CMD ["start-dev", "--import-realm", "--http-port", "8181"] diff --git a/examples/single sign on/protected/index.sql b/examples/single sign on/protected/index.sql new file mode 100644 index 0000000..b82ffe3 --- /dev/null +++ b/examples/single sign on/protected/index.sql @@ -0,0 +1,20 @@ +set user_email = sqlpage.user_info('email'); + +select 'shell' as component, 'My secure app' as title, + json_object( + 'title', 'Log Out', + 'link', sqlpage.oidc_logout_url() + ) as menu_item; + +select 'text' as component, + 'You''re in, '|| sqlpage.user_info('name') || ' !' as title, + 'You are logged in as *`' || $user_email || '`*. + +You have access to this protected page. + +![open door](/assets/welcome.jpeg)' + as contents_md; + +select 'list' as component; +select key as title, value as description +from json_each(sqlpage.user_info_token()); diff --git a/examples/single sign on/protected/public/hello.jpeg b/examples/single sign on/protected/public/hello.jpeg new file mode 100644 index 0000000..89ea689 Binary files /dev/null and b/examples/single sign on/protected/public/hello.jpeg differ diff --git a/examples/single sign on/sqlpage/migrations/000_sessions.sql b/examples/single sign on/sqlpage/migrations/000_sessions.sql new file mode 100644 index 0000000..1271484 --- /dev/null +++ b/examples/single sign on/sqlpage/migrations/000_sessions.sql @@ -0,0 +1,8 @@ +-- Table to store user sessions +CREATE TABLE user_sessions( + session_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + email TEXT NOT NULL, + oidc_token TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +) \ No newline at end of file diff --git a/examples/single sign on/sqlpage/sqlpage.yaml b/examples/single sign on/sqlpage/sqlpage.yaml new file mode 100644 index 0000000..7eeace5 --- /dev/null +++ b/examples/single sign on/sqlpage/sqlpage.yaml @@ -0,0 +1,6 @@ +oidc_issuer_url: http://localhost:8181/realms/sqlpage_demo # Given by keycloak as the "OpenID Endpoint Configuration" url. +oidc_client_id: sqlpage # configured in keycloak (http://localhost:8181/admin/master/console/#/sqlpage_demo/clients/a2bec2b8-f850-405e-9f26-59063ffa6f08/settings) +oidc_client_secret: qiawfnYrYzsmoaOZT28rRjPPRamfvrYr # For a safer setup, use environment variables to store this +oidc_protected_paths: ["/protected"] # Makes the website root is publicly accessible, requiring authentication only for the /protected path +oidc_public_paths: ["/protected/public"] # Adds an exception for the /protected/public path, which is publicly accessible too +oidc_additional_trusted_audiences: [] # For increased security, reject any token that has more than just the client ID in the "aud" claim diff --git a/examples/single sign on/test.hurl b/examples/single sign on/test.hurl new file mode 100644 index 0000000..e2ed4e8 --- /dev/null +++ b/examples/single sign on/test.hurl @@ -0,0 +1,50 @@ +GET http://localhost:8080 +HTTP 200 +[Asserts] +header "Content-Type" contains "text/html" +body contains "Welcome" +body contains "Log in" +body htmlUnescape contains "/protected" +body not contains "An error occurred" + +GET http://localhost:8080/protected +HTTP 303 +[Captures] +login_url: header "Location" +[Asserts] +header "Location" contains "http://localhost:8181/realms/sqlpage_demo/protocol/openid-connect/auth" + +GET {{login_url}} +HTTP 200 +[Captures] +login_action: xpath "string(//form[@id='kc-form-login']/@action)" +[Asserts] +xpath "//form[@id='kc-form-login']" exists +xpath "//input[@name='username']" exists +xpath "//input[@name='password']" exists +body htmlUnescape contains "client_id=sqlpage" + +POST {{login_action}} +[FormParams] +username: demo +password: demo +credentialId: +HTTP 302 +[Captures] +callback_url: header "Location" +[Asserts] +header "Location" contains "http://localhost:8080/sqlpage/oidc_callback" + +GET {{callback_url}} +HTTP 303 +[Asserts] +header "Location" == "/protected" + +GET http://localhost:8080/protected/ +HTTP 200 +[Asserts] +body htmlUnescape contains "You're in, John Smith" +body contains "demo@example.com" +body contains "You have access to this protected page." +body htmlUnescape contains "/assets/welcome.jpeg" +body not contains "An error occurred" diff --git a/examples/splitwise/README.md b/examples/splitwise/README.md new file mode 100644 index 0000000..98c0d22 --- /dev/null +++ b/examples/splitwise/README.md @@ -0,0 +1,6 @@ +# SQL-only expense tracker app + +This is a small web application that allows you to track shared expenses with your friends. +It is built entirely in SQL using SQLPage. + +![screenshot](../../docs/example-splitwise.png) \ No newline at end of file diff --git a/examples/splitwise/docker-compose.yml b/examples/splitwise/docker-compose.yml new file mode 100644 index 0000000..7305eda --- /dev/null +++ b/examples/splitwise/docker-compose.yml @@ -0,0 +1,8 @@ +services: + web: + image: lovasoa/sqlpage:main + ports: + - "8080:8080" + volumes: + - .:/var/www + - ./sqlpage:/etc/sqlpage diff --git a/examples/splitwise/group.sql b/examples/splitwise/group.sql new file mode 100644 index 0000000..54b85de --- /dev/null +++ b/examples/splitwise/group.sql @@ -0,0 +1,48 @@ +-- Write the name of the group in the title of the page +SELECT 'title' as component, name as contents FROM expense_group WHERE id = $id; + + +-- Handle the form to add a member to the group (we do it at the top of the page to see it right away) +INSERT INTO group_member(group_id, name) +SELECT $id, :new_member_name WHERE :new_member_name IS NOT NULL; + +-- List of members of the group +SELECT 'list' as component, 'Members' as title; +SELECT name AS title FROM group_member WHERE group_id = $id; + +-- Form to add a new member to the group +SELECT 'form' as component, 'Add a member to the group' as validate; +SELECT 'Member Name' AS 'label', 'new_member_name' AS name; + +SELECT 'title' as component, 'Expenses' as contents + +-- Form to add an expense +SELECT 'form' as component, 'Add an expense' as title, 'Add' as validate; +SELECT 'Description' AS name; +SELECT 'Amount' AS name, 'number' AS type; +SELECT 'Spent By' AS name, 'select' as type, + json_group_array(json_object('label', name, 'value', id)) as options +FROM group_member WHERE group_id = $id; + +-- Insert the expense posted by the form into the database +INSERT INTO expense(spent_by, name, amount) +SELECT :"Spent By", :Description, :Amount WHERE :Amount IS NOT NULL; + +-- List of expenses of the group +SELECT 'card' as component, 'Expenses' as title; +SELECT expense.name as title, + 'By ' || group_member.name || ', on ' || expense.date as description, + expense.amount || ' €' as footer, + CASE + WHEN expense.amount > 100 THEN 'red' + WHEN expense.amount > 50 THEN 'orange' + ELSE 'blue' + END AS color +FROM expense + INNER JOIN group_member on expense.spent_by = group_member.id +WHERE group_member.group_id = $id; + +-- Show the positive and negative debts of each member +SELECT 'chart' AS component, 'Debts by Person' AS title, 'bar' AS type, TRUE AS horizontal; +SELECT member_name AS label, is_owed AS value FROM individual_debts +WHERE group_id = $id ORDER BY is_owed DESC; diff --git a/examples/splitwise/index.sql b/examples/splitwise/index.sql new file mode 100644 index 0000000..7624f7f --- /dev/null +++ b/examples/splitwise/index.sql @@ -0,0 +1,18 @@ +-- Simple form to create a shared expense account +SELECT 'form' as component, + 'New shared expense account' as title, + 'Create the shared expense account!' as validate; +SELECT 'Account Name' AS label, + 'shared_expense_name' AS name; + +-- Insert the shared expense account posted by the form into the database +INSERT INTO expense_group(name) +SELECT :shared_expense_name +WHERE :shared_expense_name IS NOT NULL; + +-- List of shared expense accounts +-- (we put it after the insertion because we want to see new accounts right away when they are created) +SELECT 'list' as component; +SELECT name AS title, + 'group.sql?id=' || id AS link +FROM expense_group; diff --git a/examples/splitwise/sqlpage/migrations/0000_schema.sql b/examples/splitwise/sqlpage/migrations/0000_schema.sql new file mode 100644 index 0000000..f0daa72 --- /dev/null +++ b/examples/splitwise/sqlpage/migrations/0000_schema.sql @@ -0,0 +1,16 @@ +CREATE TABLE expense_group( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT +); +CREATE TABLE group_member( + id INTEGER PRIMARY KEY AUTOINCREMENT, + group_id INTEGER REFERENCES expense_group(id), + name TEXT +); +CREATE TABLE expense( + id INTEGER PRIMARY KEY AUTOINCREMENT, + spent_by INTEGER REFERENCES group_member(id), + date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + name TEXT, + amount DECIMAL +); \ No newline at end of file diff --git a/examples/splitwise/sqlpage/migrations/0001_views.sql b/examples/splitwise/sqlpage/migrations/0001_views.sql new file mode 100644 index 0000000..0620a9c --- /dev/null +++ b/examples/splitwise/sqlpage/migrations/0001_views.sql @@ -0,0 +1,27 @@ +-- A view that shows all the expenses of a group, with at least one line per member +DROP VIEW IF EXISTS members_with_expenses; +CREATE VIEW members_with_expenses AS +SELECT group_member.group_id AS group_id, + group_member.id AS spent_by_id, + COALESCE(expense.amount, 0) AS amount, + group_member.name AS spent_by_name +FROM group_member + LEFT JOIN expense on expense.spent_by = group_member.id; +-- A view that shows the total amount of expense per person of a group +DROP VIEW IF EXISTS average_debt_per_person; +CREATE VIEW average_debt_per_person AS +SELECT group_id, + sum(amount) / count(distinct spent_by_id) AS debt +FROM members_with_expenses +GROUP BY group_id; +-- A view that shows the total amount a person is owed in a group +DROP VIEW IF EXISTS individual_debts; +CREATE VIEW individual_debts AS +SELECT members_with_expenses.group_id AS group_id, + spent_by_id AS member_id, + spent_by_name AS member_name, + sum(members_with_expenses.amount) - average_debt_per_person.debt AS is_owed +FROM members_with_expenses + INNER JOIN average_debt_per_person ON average_debt_per_person.group_id = members_with_expenses.group_id +GROUP BY spent_by_id +ORDER BY is_owed DESC; \ No newline at end of file diff --git a/examples/splitwise/test.hurl b/examples/splitwise/test.hurl new file mode 100644 index 0000000..2b4946c --- /dev/null +++ b/examples/splitwise/test.hurl @@ -0,0 +1,44 @@ +GET http://localhost:8080/ +HTTP 200 +[Asserts] +body contains "New shared expense account" + +POST http://localhost:8080/ +Content-Type: application/x-www-form-urlencoded +[FormParams] +shared_expense_name: Hurl Trip +HTTP 200 +[Captures] +group_id: xpath "substring-after(string((//a[contains(., 'Hurl Trip')])[last()]/@href), 'id=')" +[Asserts] +body contains "Hurl Trip" + +POST http://localhost:8080/group.sql?id={{group_id}} +Content-Type: application/x-www-form-urlencoded +[FormParams] +new_member_name: Alice +HTTP 200 +[Asserts] +body contains "Alice" +body contains "Add an expense" + +POST http://localhost:8080/group.sql?id={{group_id}} +Content-Type: application/x-www-form-urlencoded +[FormParams] +new_member_name: Bob +HTTP 200 +[Captures] +alice_id: xpath "string(//select[@name='Spent By']/option[normalize-space(.)='Alice']/@value)" +[Asserts] +body contains "Bob" + +POST http://localhost:8080/group.sql?id={{group_id}} +Content-Type: application/x-www-form-urlencoded +`Description=Train+tickets&Amount=90&Spent+By={{alice_id}}` +HTTP 200 +[Asserts] +body contains "Train tickets" +body contains "90 €" +body contains "Debts by Person" +body htmlUnescape contains "\"Alice\",45" +body htmlUnescape contains "\"Bob\",-45" diff --git a/examples/telemetry/.gitignore b/examples/telemetry/.gitignore new file mode 100644 index 0000000..5516393 --- /dev/null +++ b/examples/telemetry/.gitignore @@ -0,0 +1 @@ +postgres-logs/ diff --git a/examples/telemetry/README.md b/examples/telemetry/README.md new file mode 100644 index 0000000..8f22eeb --- /dev/null +++ b/examples/telemetry/README.md @@ -0,0 +1,425 @@ +# Distributed Tracing and Logs for SQLPage with OpenTelemetry and Grafana + +SQLPage has built-in support for [OpenTelemetry](https://opentelemetry.io/) (OTel), +an open standard for collecting traces, metrics, and logs from your applications. +When enabled, every HTTP request to SQLPage produces a **trace** — a timeline of +everything that happened to serve that request, from receiving it to querying the +database and rendering the response. SQLPage also emits structured request-aware +logs, which this example forwards to Grafana Loki so you can inspect logs and traces +side by side. + +This is useful for: + +- **Debugging slow pages**: see exactly which SQL query is taking the longest. +- **Diagnosing connection pool exhaustion**: see how long requests wait for a database connection. +- **End-to-end visibility**: follow a single user request from your reverse proxy (nginx, Caddy, etc.) + through SQLPage and into PostgreSQL. + +## Quick start (this example) + +This directory contains a ready-to-run Docker Compose stack that demonstrates +the full tracing, logging, and PostgreSQL metrics pipeline. No prior +OpenTelemetry experience is needed. + +### Prerequisites + +- [Docker](https://docs.docker.com/get-docker/) and + [Docker Compose](https://docs.docker.com/compose/install/) installed on your machine. + +### Run + +```bash +cd examples/telemetry +docker compose up --build +``` + +This starts eight services: + +| Service | Role | Port | +|------------------|-----------------------------------------------------------|---------------| +| **nginx** | Reverse proxy, creates the root trace span | `localhost:80` | +| **SQLPage** | Your application, sends traces to the collector | (internal 8080) | +| **PostgreSQL** | Database | (internal 5432) | +| **Prometheus** | Stores PostgreSQL metrics scraped from the OTel Collector | (internal 9090) | +| **Tempo** | Trace storage backend | (internal 3200) | +| **Loki** | Log storage backend | (internal 3100) | +| **OTel Collector** | Receives traces, PostgreSQL metrics, and SQLPage logs | `localhost:4318`, `localhost:1514` | +| **Grafana** | Web UI to explore traces and logs | `localhost:3000` | + +### Explore traces and logs + +1. Open the todo app at [http://localhost](http://localhost) — add a few items, click to toggle them. +2. Open Grafana at [http://localhost:3000](http://localhost:3000). +3. The default home dashboard now shows recent traces, recent SQLPage logs, and PostgreSQL metrics. +4. Click any trace ID in the trace table to see the full span waterfall. +5. In the logs panel, click a `trace_id` derived field to jump straight to the matching trace. +6. The PostgreSQL metrics panels are populated by the collector's `postgresqlreceiver`. +7. In the left sidebar, click **Explore** (compass icon) if you want to search manually. +8. Select **Tempo** to search traces, **Loki** to search logs, or **Prometheus** to query metrics. + +### What you will see in a trace + +Each HTTP request produces a tree of **spans** (timed operations): + +``` +[nginx] GET /todos ← root span (created by nginx) + └─ [sqlpage] GET /todos ← HTTP request span + └─ [sqlpage] SQL website/todos.sql ← SQL file execution + ├─ db.pool.acquire ← time waiting for a DB connection + └─ db.query ← the actual SQL query + db.query.text = "SELECT title, ..." + db.system.name = "postgresql" +``` + +Key attributes on each span: + +| Span | Key attributes | +|---------------------|--------------------------------------------------------------| +| HTTP request | `http.request.method`, `http.route`, `http.response.status_code`, `user_agent.original` | +| SQL file execution | `code.file.path` — which `.sql` file was executed | +| `db.pool.acquire` | `db.client.connection.pool.name`; `sqlpage.db.pool.size` — current pool size when acquiring | +| `db.query` | `db.query.text` — the full SQL text; `db.system.name` — database type | + +### What you will see in the logs + +SQLPage writes one structured log line per event, for example: + +```text +ts=2026-03-08T20:56:15.000Z level=info target=sqlpage::access msg="200 OK" http.request.method=GET url.path=/ trace_id=4f2d... +``` + +Request-completion access logs use the target `sqlpage::access` and are written to stdout. +Diagnostic logs, warnings, and internal errors are written to stderr. Docker and most +container log drivers collect both streams by default, but custom log pipelines that read +only stderr need to collect stdout as well to keep access logs. + +The OpenTelemetry Collector receives these SQLPage container logs through Docker's syslog +logging driver and forwards them to Loki. +The homepage dashboard filters to the `sqlpage` service so you can see request logs update +live while you use the sample app. + +### PostgreSQL correlation and explain plans + +SQLPage automatically sets the +[`application_name`](https://www.postgresql.org/docs/current/runtime-config-logging.html#GUC-APPLICATION-NAME) +on each database connection to include the W3C +[traceparent](https://www.w3.org/TR/trace-context/#traceparent-header). +This means you can: + +- See trace IDs in `pg_stat_activity` when monitoring live queries: + ```sql + SELECT application_name, query, state FROM pg_stat_activity; + -- application_name: sqlpage 00-abc123...-def456...-01 + ``` +- Include trace IDs in PostgreSQL logs by adding `%a` to + [`log_line_prefix`](https://www.postgresql.org/docs/current/runtime-config-logging.html#GUC-LOG-LINE-PREFIX). + +This example also enables PostgreSQL's +[`auto_explain`](https://www.postgresql.org/docs/current/auto-explain.html) +extension for queries slower than 25 ms. The plans are logged in JSON and keep +the SQLPage trace context in the `app=[...]` prefix, so Grafana's Loki +`trace_id` derived field links each slow-query plan back to the originating +SQLPage trace. + +### Testing pool pressure + +To simulate database connection pool exhaustion (a common production issue), +reduce the pool size to 1 in `sqlpage/sqlpage.json`: + +```json +{ + "listen_on": "0.0.0.0:8080", + "max_database_pool_connections": 1 +} +``` + +Restart (`docker compose restart sqlpage`), then open several browser tabs +to `http://localhost` simultaneously. In Grafana, you will see `db.pool.acquire` +spans with longer durations as requests queue up waiting for the single connection. + +--- + +## How it works + +### Enabling tracing in SQLPage + +Tracing is **built into SQLPage** — there is nothing to install or compile. +It activates automatically when you set the `OTEL_EXPORTER_OTLP_ENDPOINT` +environment variable. When this variable is not set, SQLPage behaves exactly +as before (plain text logs, no tracing overhead). + +**Minimal setup — just two environment variables:** + +```bash +# Where to send traces (an OTLP-compatible endpoint) +export OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4318" + +# A name to identify this service in traces +export OTEL_SERVICE_NAME="sqlpage" + +# Now start SQLPage as usual +sqlpage +``` + +These are [standard OpenTelemetry environment variables](https://opentelemetry.io/docs/specs/otel/protocol/exporter/) +understood by all OTel-compatible tools. SQLPage reads them directly — no +`sqlpage.json` configuration is needed for tracing. + +### The role of each component + +**OpenTelemetry** is a standard, not a product. It defines a protocol (OTLP) for +sending trace data. Here is how the pieces fit together: + +``` + Traces: SQLPage -> OTel Collector -> Tempo -> Grafana + Logs: SQLPage -> Docker syslog logging driver -> OTel Collector -> Loki -> Grafana + Metrics: PostgreSQL -> OTel Collector postgresqlreceiver -> Prometheus -> Grafana +``` + +- **SQLPage** generates trace data and sends it via the OTLP HTTP protocol. +- A **collector** (optional) receives traces and forwards them to one or more backends. + Useful for buffering, sampling, or fanning out to multiple destinations. + You can skip the collector and send directly from SQLPage to most backends. +- The **OTel Collector** also receives SQLPage container logs and forwards them to Loki. +- **Tempo** stores traces, **Loki** stores logs, and **Grafana** lets you search both. + +### Trace context propagation + +When a reverse proxy (like nginx) sits in front of SQLPage, you want the trace +to start at nginx and continue into SQLPage as a single, connected trace. +This works via the +[W3C Trace Context](https://www.w3.org/TR/trace-context/) standard: +nginx adds a `traceparent` HTTP header to the request it forwards to SQLPage, +and SQLPage reads it to continue the same trace. + +Most modern reverse proxies and load balancers support this. +For nginx specifically, use the [`ngx_otel_module`](https://nginx.org/en/docs/ngx_otel_module.html) +(included in the `nginx:otel` Docker image). + +--- + +## Setup guides by deployment scenario + +### Self-hosted with Grafana Tempo and Loki + +This is what the Docker Compose example in this directory uses. +[Grafana Tempo](https://grafana.com/oss/tempo/) is a free, open-source trace backend, and +[Grafana Loki](https://grafana.com/oss/loki/) is the corresponding log backend. + +**Components:** +- [Grafana Tempo](https://grafana.com/docs/tempo/latest/) stores the traces. +- [Grafana Loki](https://grafana.com/docs/loki/latest/) stores the logs. +- [Grafana](https://grafana.com/docs/grafana/latest/) provides the web UI. +- An [OTel Collector](https://opentelemetry.io/docs/collector/) receives SQLPage traces, + SQLPage logs, and PostgreSQL metrics in this example. + +**SQLPage environment variables:** + +```bash +OTEL_EXPORTER_OTLP_ENDPOINT=http://:4318 +OTEL_SERVICE_NAME=sqlpage +``` + +**Links:** +- [Tempo installation guide](https://grafana.com/docs/tempo/latest/setup/) +- [OTel Collector installation](https://opentelemetry.io/docs/collector/installation/) + +### Self-hosted with Jaeger + +[Jaeger](https://www.jaegertracing.io/) is another popular open-source tracing +backend. Version 2+ natively accepts OTLP — no collector needed. + +**Start Jaeger with one command:** + +```bash +docker run -d --name jaeger \ + -p 16686:16686 \ + -p 4317:4317 \ + -p 4318:4318 \ + jaegertracing/jaeger:latest +``` + +**SQLPage environment variables:** + +```bash +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 +OTEL_SERVICE_NAME=sqlpage +``` + +Open the Jaeger UI at [http://localhost:16686](http://localhost:16686) to explore traces. + +**Links:** +- [Jaeger getting started](https://www.jaegertracing.io/docs/latest/getting-started/) + +### Grafana Cloud + +[Grafana Cloud](https://grafana.com/products/cloud/) has a free tier that +includes trace storage. SQLPage can send traces directly — no collector needed. + +**SQLPage environment variables:** + +```bash +OTEL_EXPORTER_OTLP_ENDPOINT=https://otlp-gateway-prod-.grafana.net/otlp +OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic " +OTEL_SERVICE_NAME=sqlpage +``` + +Replace: +- `` with your Grafana Cloud region (e.g., `us-east-0`, `eu-west-2`). + Find it in your Grafana Cloud portal under **My Account** > **Tempo**. +- `` with the Base64 encoding of + `:`. Generate a token in your Grafana Cloud + portal under **My Account** > **API Keys**. + + On macOS/Linux, generate the Base64 value with: + ```bash + echo -n "123456:glc_your_token_here" | base64 + ``` + +**Links:** +- [Send data via OTLP to Grafana Cloud](https://grafana.com/docs/grafana-cloud/send-data/otlp/send-data-otlp/) + +### Datadog + +[Datadog](https://www.datadoghq.com/) supports OTLP ingestion through the +Datadog Agent. + +**1. Run the Datadog Agent** with OTLP ingest enabled: + +```bash +docker run -d --name datadog-agent \ + -e DD_API_KEY= \ + -e DD_OTLP_CONFIG_RECEIVER_PROTOCOLS_HTTP_ENDPOINT=0.0.0.0:4318 \ + -e DD_SITE=datadoghq.com \ + -p 4318:4318 \ + gcr.io/datadoghq/agent:latest +``` + +**2. Point SQLPage to the Agent:** + +```bash +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 +OTEL_SERVICE_NAME=sqlpage +``` + +Traces appear in the Datadog **APM > Traces** section. + +**Links:** +- [OTLP ingestion in the Datadog Agent](https://docs.datadoghq.com/opentelemetry/setup/otlp_ingest_in_the_agent/) + +### Honeycomb + +[Honeycomb](https://www.honeycomb.io/) accepts OTLP directly — no collector needed. + +**SQLPage environment variables:** + +```bash +OTEL_EXPORTER_OTLP_ENDPOINT=https://api.honeycomb.io +OTEL_EXPORTER_OTLP_HEADERS="x-honeycomb-team=" +OTEL_SERVICE_NAME=sqlpage +``` + +For the EU region, use `https://api.eu1.honeycomb.io` instead. + +**Links:** +- [Send data with OpenTelemetry — Honeycomb docs](https://docs.honeycomb.io/send-data/opentelemetry/) + +### New Relic + +[New Relic](https://newrelic.com/) accepts OTLP directly. + +**SQLPage environment variables:** + +```bash +OTEL_EXPORTER_OTLP_ENDPOINT=https://otlp.nr-data.net +OTEL_EXPORTER_OTLP_HEADERS="api-key=" +OTEL_SERVICE_NAME=sqlpage +``` + +For the EU region, use `https://otlp.eu01.nr-data.net` instead. + +Find your Ingest License Key in the New Relic UI under +**API Keys** (type: `INGEST - LICENSE`). + +**Links:** +- [New Relic OTLP endpoint configuration](https://docs.newrelic.com/docs/opentelemetry/best-practices/opentelemetry-otlp/) + +### Axiom + +[Axiom](https://axiom.co/) accepts OTLP directly. + +**SQLPage environment variables:** + +```bash +OTEL_EXPORTER_OTLP_ENDPOINT=https://api.axiom.co +OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer ,X-Axiom-Dataset=" +OTEL_SERVICE_NAME=sqlpage +``` + +**Links:** +- [Send OpenTelemetry data to Axiom](https://axiom.co/docs/send-data/opentelemetry) + +--- + +## Environment variable reference + +These are [standard OpenTelemetry variables](https://opentelemetry.io/docs/specs/otel/protocol/exporter/), +not specific to SQLPage. + +| Variable | Required? | Description | Example | +|-----------------------------------|-----------|------------------------------------------------|--------------------------------------| +| `OTEL_EXPORTER_OTLP_ENDPOINT` | Yes | Base URL of the OTLP receiver | `http://localhost:4318` | +| `OTEL_SERVICE_NAME` | No | Service name shown in traces (default: `unknown_service`) | `sqlpage` | +| `OTEL_EXPORTER_OTLP_HEADERS` | No | Comma-separated `key=value` pairs for auth headers | `api-key=abc123` | +| `OTEL_EXPORTER_OTLP_PROTOCOL` | No | Protocol (default: `http/protobuf`) | `http/protobuf` | +| `RUST_LOG` or `LOG_LEVEL` | No | Filter which spans/logs are emitted | `sqlpage=debug,tracing_actix_web=info` | + +When `OTEL_EXPORTER_OTLP_ENDPOINT` is **not set**, SQLPage uses plain text +logging only (same behavior as versions before tracing support was added). + +--- + +## Troubleshooting + +### No traces appear + +1. **Check that SQLPage sees the endpoint.** Look for this line in the startup logs: + ``` + OpenTelemetry tracing enabled (OTEL_EXPORTER_OTLP_ENDPOINT is set) + ``` + If you don't see it, the environment variable is not reaching SQLPage. + +2. **Check that the collector/backend is reachable.** From the SQLPage host, try: + ```bash + curl -v http://:4318/v1/traces + ``` + You should get a response (even if it's an error like "no data"), not a connection refused. + +3. **Check the collector logs** for export errors (e.g., authentication failures). + +### Traces are disconnected (nginx and SQLPage show as separate traces) + +This means the `traceparent` header is not being propagated. Check that: + +- Your reverse proxy is configured to inject/propagate the `traceparent` header. +- For nginx, you need the `ngx_otel_module` with `otel_trace_context propagate` + in the location block. Setting `otel_span_name "$request_method $uri"` also keeps + the nginx span name aligned with the actual request path. See the `nginx/nginx.conf` + in this example. + +### Spans are missing (e.g., no `db.query` spans) + +The `RUST_LOG` filter might be too restrictive. +SQLPage emits spans at the `INFO` level by default. Make sure your filter +includes `sqlpage=info`: + +```bash +RUST_LOG="sqlpage=info,actix_web=info,tracing_actix_web=info" +``` + +If you filter individual targets instead of the broader `sqlpage` target, include +the access-log target too: + +```bash +RUST_LOG="sqlpage::access=info,sqlpage::webserver::http=info,actix_web=info,tracing_actix_web=info" +``` diff --git a/examples/telemetry/docker-compose.yml b/examples/telemetry/docker-compose.yml new file mode 100644 index 0000000..40898db --- /dev/null +++ b/examples/telemetry/docker-compose.yml @@ -0,0 +1,161 @@ +services: + nginx: + image: nginx:otel + ports: + - "8080:80" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + depends_on: + - sqlpage + - otel-collector + logging: + driver: syslog + options: + syslog-address: "udp://localhost:1516" + syslog-format: "rfc5424micro" + tag: "nginx" + + sqlpage: + image: lovasoa/sqlpage:main + environment: + - DATABASE_URL=postgres://sqlpage:sqlpage@postgres:5432/sqlpage + - OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 + - OTEL_METRIC_EXPORT_INTERVAL=1000 + - OTEL_SERVICE_NAME=sqlpage + volumes: + - ./website:/var/www + - ./sqlpage:/etc/sqlpage + depends_on: + otel-collector: + condition: service_started + postgres: + condition: service_healthy + logging: + driver: syslog + options: + syslog-address: "udp://localhost:1514" + syslog-format: "rfc5424micro" + tag: "sqlpage" + + postgres: + image: postgres:18 + command: + - postgres + - -c + - shared_preload_libraries=pg_stat_statements,auto_explain + - -c + - pg_stat_statements.track_utility=off + - -c + - logging_collector=on + - -c + - log_destination=jsonlog + - -c + - log_directory=/var/log/postgresql + - -c + - log_filename=postgresql + - -c + - log_file_mode=0644 + - -c + - log_rotation_age=0 + - -c + - log_rotation_size=0 + - -c + - log_line_prefix=ts=%m pid=%p db=%d user=%u app=[%a] + - -c + - log_min_messages=info + - -c + - log_error_verbosity=verbose + - -c + - log_connections=on + - -c + - log_disconnections=on + - -c + - log_duration=on + - -c + - log_statement=all + - -c + - log_min_duration_statement=25 + - -c + - auto_explain.log_min_duration=25ms + - -c + - auto_explain.log_analyze=on + - -c + - auto_explain.log_buffers=on + - -c + - auto_explain.log_timing=on + - -c + - auto_explain.log_verbose=on + - -c + - auto_explain.log_format=json + environment: + POSTGRES_USER: sqlpage + POSTGRES_PASSWORD: sqlpage + POSTGRES_DB: sqlpage + volumes: + - ./postgres-init:/docker-entrypoint-initdb.d:ro + - ./postgres-logs:/var/log/postgresql:z + depends_on: + postgres-logs: + condition: service_completed_successfully + healthcheck: + test: ["CMD-SHELL", "pg_isready -U sqlpage"] + interval: 2s + timeout: 5s + retries: 5 + + postgres-logs: + image: alpine + command: ["sh", "-c", "touch /var/log/postgresql/postgresql /var/log/postgresql/postgresql.json && chmod 777 /var/log/postgresql && chmod 666 /var/log/postgresql/postgresql*"] + volumes: + - ./postgres-logs:/var/log/postgresql:z + + otel-collector: + image: otel/opentelemetry-collector-contrib:latest + user: "0:0" + volumes: + - ./otel-collector.yaml:/etc/otelcol-contrib/config.yaml:ro + - ./postgres-logs:/var/log/postgresql:ro,z + depends_on: + tempo: + condition: service_started + postgres: + condition: service_started + loki: + condition: service_started + + prometheus: + image: prom/prometheus:v3.2.1 + command: + - --config.file=/etc/prometheus/prometheus.yml + - --storage.tsdb.path=/prometheus + - --web.enable-otlp-receiver + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro + + tempo: + image: grafana/tempo:3.0.0 + ports: + - "3200:3200" + volumes: + - ./tempo.yaml:/etc/tempo/config.yaml:ro + command: ["-config.file=/etc/tempo/config.yaml"] + + loki: + image: grafana/loki:3.7.2 + command: ["-config.file=/etc/loki/local-config.yaml"] + + grafana: + image: grafana/grafana:latest + environment: + - GF_AUTH_ANONYMOUS_ENABLED=true + - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin + - GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH=/etc/grafana/provisioning/dashboards/sqlpage/sqlpage-home.json + - GF_NEWS_NEWS_FEED_ENABLED=false + volumes: + - ./grafana/datasources.yaml:/etc/grafana/provisioning/datasources/datasources.yaml:ro,z + - ./grafana/dashboards.yaml:/etc/grafana/provisioning/dashboards/dashboards.yaml:ro,z + - ./grafana:/etc/grafana/provisioning/dashboards/sqlpage:ro,z + depends_on: + - tempo + - loki + - prometheus diff --git a/examples/telemetry/grafana/dashboards.yaml b/examples/telemetry/grafana/dashboards.yaml new file mode 100644 index 0000000..9c6d001 --- /dev/null +++ b/examples/telemetry/grafana/dashboards.yaml @@ -0,0 +1,12 @@ +apiVersion: 1 + +providers: + - name: SQLPage Tracing + orgId: 1 + folder: "" + type: file + disableDeletion: true + updateIntervalSeconds: 30 + allowUiUpdates: false + options: + path: /etc/grafana/provisioning/dashboards/sqlpage diff --git a/examples/telemetry/grafana/datasources.yaml b/examples/telemetry/grafana/datasources.yaml new file mode 100644 index 0000000..6b7fcba --- /dev/null +++ b/examples/telemetry/grafana/datasources.yaml @@ -0,0 +1,34 @@ +apiVersion: 1 + +datasources: + - name: Tempo + type: tempo + uid: tempo + access: proxy + url: http://tempo:3200 + isDefault: true + jsonData: + nodeGraph: + enabled: true + tracesToLogsV2: + datasourceUid: loki + spanStartTimeShift: "-5m" + spanEndTimeShift: "5m" + customQuery: true + query: '{service_name=~"nginx|sqlpage|postgresql"} | trace_id="$${__span.traceId}"' + - name: Loki + type: loki + uid: loki + access: proxy + url: http://loki:3100 + jsonData: + derivedFields: + - name: trace_id + matcherRegex: '(?:trace_id=|00-)([0-9a-f]{32})' + datasourceUid: tempo + url: '$${__value.raw}' + - name: Prometheus + type: prometheus + uid: prometheus + access: proxy + url: http://prometheus:9090 diff --git a/examples/telemetry/grafana/sqlpage-home.json b/examples/telemetry/grafana/sqlpage-home.json new file mode 100644 index 0000000..4b05949 --- /dev/null +++ b/examples/telemetry/grafana/sqlpage-home.json @@ -0,0 +1,531 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "links": [], + "panels": [ + { + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "

SQLPage Observability

Open http://localhost and interact with the app. New requests will appear here automatically.

This dashboard shows traces, logs, and application metrics exported by SQLPage. Trace waterfalls link to PostgreSQL logs via trace IDs. Metrics include HTTP durations, DB query latencies, and connection pool states.

", + "mode": "html" + }, + "pluginVersion": "12.4.1", + "title": "Overview", + "type": "text" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 4, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 5 + }, + "id": 10, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.4.1", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "histogram_quantile(0.95, sum(rate(http_server_request_duration_seconds_bucket[5m])) by (le, http_route))", + "legendFormat": "HTTP P95 {{http_route}}", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "histogram_quantile(0.95, sum(rate(db_client_operation_duration_seconds_bucket[5m])) by (le, db_operation_name))", + "legendFormat": "DB P95 {{db_operation_name}}", + "refId": "B" + } + ], + "title": "Request & Query Latency (P95)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 4, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 5 + }, + "id": 11, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.4.1", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "sum(db_client_connection_count) by (db_client_connection_state)", + "legendFormat": "{{db_client_connection_state}}", + "refId": "A" + } + ], + "title": "SQLPage DB Connection Pool", + "type": "timeseries" + }, + { + "datasource": { + "type": "tempo", + "uid": "tempo" + }, + "fieldConfig": { + "defaults": { + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "footer": { + "reducers": [] + }, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "traceID" + }, + "properties": [ + { + "id": "custom.hideFrom.viz", + "value": true + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "traceName" + }, + "properties": [ + { + "id": "custom.cellOptions", + "value": { + "type": "data-links" + } + }, + { + "id": "links", + "value": [ + { + "targetBlank": false, + "title": "${__value.text}", + "url": "/a/grafana-exploretraces-app/explore?traceId=${__data.fields.traceID}" + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "traceDuration" + }, + "properties": [ + { + "id": "custom.width", + "value": 120 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Service" + }, + "properties": [ + { + "id": "custom.width", + "value": 58 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Start time" + }, + "properties": [ + { + "id": "custom.width", + "value": 204 + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 13 + }, + "id": 2, + "options": { + "cellHeight": "sm", + "frameIndex": 0, + "showHeader": true, + "sortBy": [ + { + "desc": true, + "displayName": "Start time" + } + ] + }, + "pluginVersion": "12.4.1", + "targets": [ + { + "datasource": { + "type": "tempo", + "uid": "tempo" + }, + "filters": [ + { + "id": "57ff7584", + "operator": "=", + "scope": "span" + } + ], + "limit": 50, + "metricsQueryType": "range", + "query": "{resource.service.name != nil}", + "queryType": "traceqlSearch", + "refId": "A", + "serviceMapUseNativeHistograms": false, + "tableType": "traces" + } + ], + "timeFrom": "1h", + "title": "Trace List", + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "nested": true + }, + "indexByName": { + "startTime": 1, + "traceDuration": 4, + "traceID": 0, + "traceName": 3, + "traceService": 2 + }, + "renameByName": { + "startTime": "Start time", + "traceDuration": "Duration", + "traceID": "Trace", + "traceName": "Route", + "traceService": "Service" + } + } + } + ], + "type": "table" + }, + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "gridPos": { + "h": 10, + "w": 12, + "x": 0, + "y": 21 + }, + "id": 3, + "options": { + "dedupStrategy": "none", + "enableInfiniteScrolling": false, + "enableLogDetails": true, + "prettifyLogMessage": false, + "showCommonLabels": false, + "showControls": false, + "showLabels": true, + "showTime": true, + "sortOrder": "Descending", + "unwrappedColumns": false, + "wrapLogMessage": true + }, + "pluginVersion": "12.4.1", + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "direction": "backward", + "editorMode": "builder", + "expr": "{service_name=\"sqlpage\"}", + "queryType": "range", + "refId": "A" + } + ], + "timeFrom": "1h", + "title": "SQLPage Logs", + "type": "logs" + }, + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "gridPos": { + "h": 10, + "w": 12, + "x": 12, + "y": 21 + }, + "id": 6, + "options": { + "dedupStrategy": "none", + "enableInfiniteScrolling": false, + "enableLogDetails": true, + "prettifyLogMessage": false, + "showCommonLabels": false, + "showControls": false, + "showLabels": true, + "showTime": true, + "sortOrder": "Descending", + "unwrappedColumns": false, + "wrapLogMessage": true + }, + "pluginVersion": "12.4.1", + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "direction": "backward", + "editorMode": "builder", + "expr": "{service_name=\"postgresql\"}", + "queryType": "range", + "refId": "A" + } + ], + "timeFrom": "1h", + "title": "PostgreSQL Logs", + "type": "logs" + } + ], + "preload": false, + "refresh": "5s", + "schemaVersion": 42, + "tags": ["sqlpage", "tracing", "logs", "metrics"], + "templating": { + "list": [] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "SQLPage Observability Home", + "uid": "sqlpage-tracing-home", + "version": 1, + "weekStart": "" +} diff --git a/examples/telemetry/nginx/nginx.conf b/examples/telemetry/nginx/nginx.conf new file mode 100644 index 0000000..c7f9aca --- /dev/null +++ b/examples/telemetry/nginx/nginx.conf @@ -0,0 +1,50 @@ +load_module modules/ngx_otel_module.so; + +events {} + +http { + otel_exporter { + endpoint otel-collector:4317; + } + + otel_service_name nginx; + + map $status $log_level { + ~^[45] error; + default info; + } + + map $status $log_target { + ~^[45] nginx.request_error; + default nginx.access; + } + + log_format otel_request 'ts=$time_iso8601 level=$log_level target=$log_target ' + 'client.address=$remote_addr method=$request_method path="$uri" ' + 'status=$status request_time=$request_time ' + 'connection=$connection ' + 'upstream_addr="$upstream_addr" upstream_status="$upstream_status" ' + 'upstream_response_time="$upstream_response_time" ' + 'referer="$http_referer" user_agent="$http_user_agent" ' + 'trace_id=$otel_trace_id span_id=$otel_span_id parent_id=$otel_parent_id'; + + access_log /dev/stdout otel_request; + error_log /dev/stderr warn; + + upstream sqlpage { + server sqlpage:8080; + } + + server { + listen 80; + + location / { + otel_trace on; + otel_span_name "$request_method $uri"; + otel_trace_context propagate; + proxy_pass http://sqlpage; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + } +} diff --git a/examples/telemetry/otel-collector.yaml b/examples/telemetry/otel-collector.yaml new file mode 100644 index 0000000..3632519 --- /dev/null +++ b/examples/telemetry/otel-collector.yaml @@ -0,0 +1,158 @@ +receivers: + otlp: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + http: + endpoint: 0.0.0.0:4318 + syslog/sqlpage: + protocol: rfc5424 + udp: + listen_address: 0.0.0.0:1514 + syslog/nginx: + protocol: rfc5424 + udp: + listen_address: 0.0.0.0:1516 + filelog/postgresql: + include: + - /var/log/postgresql/postgresql.json + start_at: end + include_file_path: true + operators: + - type: json_parser + parse_from: body + parse_to: attributes + postgresql: + endpoint: postgres:5432 + transport: tcp + username: sqlpage + password: sqlpage + tls: + insecure: true + databases: + - sqlpage + collection_interval: 1s + +processors: + transform/sqlpage_logs: + error_mode: ignore + log_statements: + - context: resource + statements: + - set(resource.attributes["service.name"], "sqlpage") + - context: log + statements: + - set(log.body, log.attributes["message"]) where log.attributes["message"] != nil + - merge_maps(log.cache, ExtractPatterns(log.body, "level=(?P[^ ]+)"), "upsert") where IsString(log.body) + - merge_maps(log.cache, ExtractPatterns(log.body, "trace_id=(?P[0-9a-f]+)"), "upsert") where IsString(log.body) + - set(log.attributes["level"], log.cache["level"]) where log.cache["level"] != nil + - set(log.attributes["trace_id"], log.cache["trace_id"]) where log.cache["trace_id"] != nil + - set(log.severity_text, "TRACE") where log.cache["level"] == "trace" + - set(log.severity_number, SEVERITY_NUMBER_TRACE) where log.cache["level"] == "trace" + - set(log.severity_text, "DEBUG") where log.cache["level"] == "debug" + - set(log.severity_number, SEVERITY_NUMBER_DEBUG) where log.cache["level"] == "debug" + - set(log.severity_text, "INFO") where log.cache["level"] == "info" + - set(log.severity_number, SEVERITY_NUMBER_INFO) where log.cache["level"] == "info" + - set(log.severity_text, "WARN") where log.cache["level"] == "warn" + - set(log.severity_number, SEVERITY_NUMBER_WARN) where log.cache["level"] == "warn" + - set(log.severity_text, "ERROR") where log.cache["level"] == "error" + - set(log.severity_number, SEVERITY_NUMBER_ERROR) where log.cache["level"] == "error" + transform/postgresql_logs: + error_mode: ignore + log_statements: + - context: resource + statements: + - set(resource.attributes["service.name"], "postgresql") + - context: log + statements: + - set(log.body, log.attributes["message"]) where log.attributes["message"] != nil + - merge_maps(log.cache, ExtractPatterns(log.attributes["application_name"], "00-(?P[0-9a-f]{32})-[0-9a-f]{16}-[0-9a-f]{2}"), "upsert") where IsString(log.attributes["application_name"]) + - set(log.attributes["level"], log.attributes["error_severity"]) where log.attributes["error_severity"] != nil + - set(log.attributes["trace_id"], log.cache["trace_id"]) where log.cache["trace_id"] != nil + - set(log.severity_text, "DEBUG") where log.attributes["level"] == "DEBUG1" or log.attributes["level"] == "DEBUG2" or log.attributes["level"] == "DEBUG3" or log.attributes["level"] == "DEBUG4" or log.attributes["level"] == "DEBUG5" or log.attributes["level"] == "DEBUG" + - set(log.severity_number, SEVERITY_NUMBER_DEBUG) where log.severity_text == "DEBUG" + - set(log.severity_text, "INFO") where log.attributes["level"] == "LOG" or log.attributes["level"] == "INFO" or log.attributes["level"] == "NOTICE" + - set(log.severity_number, SEVERITY_NUMBER_INFO) where log.severity_text == "INFO" + - set(log.severity_text, "WARN") where log.attributes["level"] == "WARNING" + - set(log.severity_number, SEVERITY_NUMBER_WARN) where log.severity_text == "WARN" + - set(log.severity_text, "ERROR") where log.attributes["level"] == "ERROR" or log.attributes["level"] == "FATAL" or log.attributes["level"] == "PANIC" + - set(log.severity_number, SEVERITY_NUMBER_ERROR) where log.severity_text == "ERROR" + transform/nginx_logs: + error_mode: ignore + log_statements: + - context: resource + statements: + - set(resource.attributes["service.name"], "nginx") + - context: log + statements: + - set(log.body, log.attributes["message"]) where log.attributes["message"] != nil + - merge_maps(log.cache, ExtractPatterns(log.body, "level=(?P[^ ]+)"), "upsert") where IsString(log.body) + - merge_maps(log.cache, ExtractPatterns(log.body, "\\[(?Pinfo|error|warn|notice|debug)\\]"), "upsert") where IsString(log.body) + - merge_maps(log.cache, ExtractPatterns(log.body, "trace_id=(?P[0-9a-f]{32})"), "upsert") where IsString(log.body) + - merge_maps(log.cache, ExtractPatterns(log.body, "span_id=(?P[0-9a-f]{16})"), "upsert") where IsString(log.body) + - set(log.attributes["trace_id"], log.cache["trace_id"]) where log.cache["trace_id"] != nil + - set(log.attributes["span_id"], log.cache["span_id"]) where log.cache["span_id"] != nil + - set(log.attributes["level"], log.cache["level"]) where log.cache["level"] != nil + - set(log.attributes["level"], log.cache["nginx_level"]) where log.cache["level"] == nil and log.cache["nginx_level"] != nil + - set(log.severity_text, "DEBUG") where log.attributes["level"] == "debug" + - set(log.severity_number, SEVERITY_NUMBER_DEBUG) where log.attributes["level"] == "debug" + - set(log.severity_text, "INFO") where log.attributes["level"] == "info" or log.attributes["level"] == "notice" + - set(log.severity_number, SEVERITY_NUMBER_INFO) where log.attributes["level"] == "info" or log.attributes["level"] == "notice" + - set(log.severity_text, "WARN") where log.attributes["level"] == "warn" + - set(log.severity_number, SEVERITY_NUMBER_WARN) where log.attributes["level"] == "warn" + - set(log.severity_text, "ERROR") where log.attributes["level"] == "error" + - set(log.severity_number, SEVERITY_NUMBER_ERROR) where log.attributes["level"] == "error" + batch: + timeout: 1s + +exporters: + otlp_grpc/tempo: + endpoint: tempo:4317 + tls: + insecure: true + otlp_http/loki: + endpoint: http://loki:3100/otlp + prometheus: + endpoint: 0.0.0.0:9464 + +service: + pipelines: + traces: + receivers: + - otlp + processors: + - batch + exporters: + - otlp_grpc/tempo + metrics: + receivers: + - otlp + - postgresql + processors: + - batch + exporters: + - prometheus + logs/sqlpage: + receivers: + - syslog/sqlpage + processors: + - transform/sqlpage_logs + - batch + exporters: + - otlp_http/loki + logs/postgresql: + receivers: + - filelog/postgresql + processors: + - transform/postgresql_logs + - batch + exporters: + - otlp_http/loki + logs/nginx: + receivers: + - syslog/nginx + processors: + - transform/nginx_logs + - batch + exporters: + - otlp_http/loki diff --git a/examples/telemetry/postgres-init/001_pg_stat_statements.sql b/examples/telemetry/postgres-init/001_pg_stat_statements.sql new file mode 100644 index 0000000..c237a50 --- /dev/null +++ b/examples/telemetry/postgres-init/001_pg_stat_statements.sql @@ -0,0 +1,5 @@ +\connect postgres +CREATE EXTENSION IF NOT EXISTS pg_stat_statements; + +\connect sqlpage +CREATE EXTENSION IF NOT EXISTS pg_stat_statements; diff --git a/examples/telemetry/prometheus.yml b/examples/telemetry/prometheus.yml new file mode 100644 index 0000000..29b26bf --- /dev/null +++ b/examples/telemetry/prometheus.yml @@ -0,0 +1,9 @@ +global: + scrape_interval: 10s + evaluation_interval: 10s + +scrape_configs: + - job_name: otel-collector + static_configs: + - targets: ["otel-collector:9464"] + diff --git a/examples/telemetry/sqlpage/migrations/001_init.sql b/examples/telemetry/sqlpage/migrations/001_init.sql new file mode 100644 index 0000000..0ff8684 --- /dev/null +++ b/examples/telemetry/sqlpage/migrations/001_init.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS todos ( + id SERIAL PRIMARY KEY, + title TEXT NOT NULL, + done BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); diff --git a/examples/telemetry/sqlpage/sqlpage.json b/examples/telemetry/sqlpage/sqlpage.json new file mode 100644 index 0000000..5322302 --- /dev/null +++ b/examples/telemetry/sqlpage/sqlpage.json @@ -0,0 +1,3 @@ +{ + "listen_on": "0.0.0.0:8080" +} diff --git a/examples/telemetry/tempo.yaml b/examples/telemetry/tempo.yaml new file mode 100644 index 0000000..18a23ae --- /dev/null +++ b/examples/telemetry/tempo.yaml @@ -0,0 +1,19 @@ +stream_over_http_enabled: true + +server: + http_listen_port: 3200 + +distributor: + receivers: + otlp: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + +storage: + trace: + backend: local + wal: + path: /tmp/tempo/wal + local: + path: /tmp/tempo/blocks diff --git a/examples/telemetry/test.hurl b/examples/telemetry/test.hurl new file mode 100644 index 0000000..ae807c4 --- /dev/null +++ b/examples/telemetry/test.hurl @@ -0,0 +1,47 @@ +GET http://localhost:8080/ +traceparent: 00-00000000000000000000000000000001-0000000000000001-01 +HTTP 200 +[Asserts] +body contains "Todo List" +body contains "Add a todo" +body not contains "An error occurred" + +POST http://localhost:8080/add_todo.sql +traceparent: 00-00000000000000000000000000000002-0000000000000002-01 +[FormParams] +title: Hurl telemetry todo +HTTP 302 +[Asserts] +header "Location" == "/" + +GET http://localhost:8080/ +traceparent: 00-00000000000000000000000000000003-0000000000000003-01 +HTTP 200 +[Asserts] +body contains "Hurl telemetry todo" +body not contains "An error occurred" + +GET http://localhost:3200/api/search?q=%7Bresource.service.name%3D%22sqlpage%22%7D&limit=20 +HTTP 200 +[Asserts] +body contains "sqlpage" +body contains "\"traceID\":\"3\"" + +GET http://localhost:3200/api/search?q=%7Bresource.service.name%3D%22nginx%22%7D&limit=20 +HTTP 200 +[Asserts] +body contains "nginx" +body contains "\"traceID\":\"3\"" + +GET http://localhost:3200/api/traces/3 +HTTP 200 +[Asserts] +body contains "\"service.name\"" +body contains "\"sqlpage\"" +body contains "\"nginx\"" +body contains "\"name\":\"GET /\"" +body contains "\"name\":\"SELECT\"" +body contains "\"kind\":\"SPAN_KIND_CLIENT\"" +body contains "\"db.query.text\"" +body contains "\"db.system.name\"" +body contains "\"db.operation.name\"" diff --git a/examples/telemetry/website/add_todo.sql b/examples/telemetry/website/add_todo.sql new file mode 100644 index 0000000..32c4aa7 --- /dev/null +++ b/examples/telemetry/website/add_todo.sql @@ -0,0 +1,8 @@ +-- Toggle an existing todo +UPDATE todos SET done = $done::boolean WHERE id = $id::int; + +-- Insert a new todo if title is provided via the form (POST) +INSERT INTO todos (title) +SELECT :title WHERE :title IS NOT NULL AND length(:title) > 0; + +SELECT 'redirect' AS component, '/' AS link; diff --git a/examples/telemetry/website/index.sql b/examples/telemetry/website/index.sql new file mode 100644 index 0000000..151ea3a --- /dev/null +++ b/examples/telemetry/website/index.sql @@ -0,0 +1,11 @@ +SELECT 'list' AS component, + 'Todo List' AS title; + +SELECT title, + CASE WHEN done THEN 'complete' ELSE 'pending' END AS description, + 'add_todo.sql?id=' || id || '&done=' || (NOT done)::text AS link +FROM todos +ORDER BY created_at DESC; + +SELECT 'form' AS component, 'Add a todo' AS title, 'add_todo.sql' AS action; +SELECT 'title' AS name, 'What do you need to do?' AS placeholder; diff --git a/examples/telemetry/website/slow.sql b/examples/telemetry/website/slow.sql new file mode 100644 index 0000000..fead830 --- /dev/null +++ b/examples/telemetry/website/slow.sql @@ -0,0 +1,7 @@ +SELECT pg_sleep(15); + +SELECT 'list' AS component, + 'Slow Query Complete' AS title; + +SELECT 'The slow query finished successfully.' AS title, + 'This page exists to make PostgreSQL query-sample events easy to capture.' AS description; diff --git a/examples/tiny_twitter/.gitignore b/examples/tiny_twitter/.gitignore new file mode 100644 index 0000000..1550b48 --- /dev/null +++ b/examples/tiny_twitter/.gitignore @@ -0,0 +1 @@ +sqlpage.bin diff --git a/examples/tiny_twitter/Dockerfile b/examples/tiny_twitter/Dockerfile new file mode 100644 index 0000000..36199a2 --- /dev/null +++ b/examples/tiny_twitter/Dockerfile @@ -0,0 +1,4 @@ +FROM lovasoa/sqlpage:main + +COPY sqlpage /etc/sqlpage +COPY . /var/www/ \ No newline at end of file diff --git a/examples/tiny_twitter/README.md b/examples/tiny_twitter/README.md new file mode 100644 index 0000000..7ae615d --- /dev/null +++ b/examples/tiny_twitter/README.md @@ -0,0 +1,8 @@ +# Tiny Tweeter + +This is a simple example of a very simple Twitter-like application running on top of PostgreSQL. +It is called tweeter because Elon Musk already has the Twitter trademark, even though he doesn't use it. + +It was presented at the [2023 PGConf.EU](https://2023.pgconf.eu/) conference. + +You can find the slides at https://sql-page.com/pgconf/pgconf-2023.html. \ No newline at end of file diff --git a/examples/tiny_twitter/docker-compose.yml b/examples/tiny_twitter/docker-compose.yml new file mode 100644 index 0000000..49f301a --- /dev/null +++ b/examples/tiny_twitter/docker-compose.yml @@ -0,0 +1,14 @@ +services: + sqlpage: + build: . + ports: ["8080:8080"] + volumes: [".:/var/www"] + depends_on: [postgres] + environment: + - DATABASE_URL=postgres://root:root@postgres/sqlpage + postgres: + image: postgres:18 + environment: + - POSTGRES_USER=root + - POSTGRES_PASSWORD=root + - POSTGRES_DB=sqlpage diff --git a/examples/tiny_twitter/index.sql b/examples/tiny_twitter/index.sql new file mode 100644 index 0000000..0a47a9b --- /dev/null +++ b/examples/tiny_twitter/index.sql @@ -0,0 +1,22 @@ +select 'shell' as component, + 'TinyTweeter' as title; + +select 'form' as component, + 'Tweet' as validate; +select 'new_tweet' as name, + 'Your story' as label, + 'textarea' as type, + 'Tell me your story...' as placeholder; +select 'checkbox' as type, + 'Terms and conditions' as label, + true as required; + +insert into tweets (tweet) +select :new_tweet +where :new_tweet is not null; + +select 'card' as component, + 'Tweets' as title, + 1 as columns; +select tweet as description +from tweets; \ No newline at end of file diff --git a/examples/tiny_twitter/sqlpage/migrations/0001_tweets.sql b/examples/tiny_twitter/sqlpage/migrations/0001_tweets.sql new file mode 100644 index 0000000..2b5c9a6 --- /dev/null +++ b/examples/tiny_twitter/sqlpage/migrations/0001_tweets.sql @@ -0,0 +1,4 @@ +CREATE TABLE tweets( + id bigserial, + tweet text +); \ No newline at end of file diff --git a/examples/tiny_twitter/test.hurl b/examples/tiny_twitter/test.hurl new file mode 100644 index 0000000..e017692 --- /dev/null +++ b/examples/tiny_twitter/test.hurl @@ -0,0 +1,16 @@ +GET http://localhost:8080 +HTTP 200 +[Asserts] +body contains "TinyTweeter" +body contains "Tell me your story..." +body contains "Terms and conditions" +body not contains "An error occurred" + +POST http://localhost:8080 +[FormParams] +new_tweet: Hurl tiny twitter post +HTTP 200 +[Asserts] +body contains "TinyTweeter" +body contains "Hurl tiny twitter post" +body not contains "An error occurred" diff --git a/examples/todo application (PostgreSQL)/README.md b/examples/todo application (PostgreSQL)/README.md new file mode 100644 index 0000000..43bbbb9 --- /dev/null +++ b/examples/todo application (PostgreSQL)/README.md @@ -0,0 +1,15 @@ +# Todo app with SQLPage + +This is a simple todo app implemented with SQLPage. It uses a PostgreSQL database to store the todo items. + +![Screenshot](screenshot.png) + +It is meant as an illustrative example of how to use SQLPage to create a simple CRUD application. See [the SQLite version](../todo%20application/README.md) for a more detailed explanation of the structure of the application. + +## Differences from the SQLite version + +- URL parameters that contain numeric identifiers are cast to integers using the [`::int`](https://www.postgresql.org/docs/16/sql-expressions.html#SQL-SYNTAX-TYPE-CASTS) operator +- the `printf` function is replaced with the [`format`](https://www.postgresql.org/docs/current/functions-string.html#FUNCTIONS-STRING-FORMAT) function +- primary keys are generated using the [`serial`](https://www.postgresql.org/docs/current/datatype-numeric.html#DATATYPE-SERIAL) type +- dates and times are formatted using the [`to_char`](https://www.postgresql.org/docs/current/functions-formatting.html#FUNCTIONS-FORMATTING-DATETIME-TABLE) function +- the `INSERT OR REPLACE` statement is replaced with the [`ON CONFLICT`](https://www.postgresql.org/docs/current/sql-insert.html#SQL-ON-CONFLICT) clause diff --git a/examples/todo application (PostgreSQL)/batch.sql b/examples/todo application (PostgreSQL)/batch.sql new file mode 100644 index 0000000..28e6d4b --- /dev/null +++ b/examples/todo application (PostgreSQL)/batch.sql @@ -0,0 +1,98 @@ +-- Include 'shell.sql' to generate the page header and footer +select 'dynamic' as component, sqlpage.run_sql('shell.sql') as properties; + +-- Define a Common Table Expression (CTE) named 'updated' +-- CTEs are temporary named result sets, useful for complex queries +-- Here, it's used to perform the update and capture the results in one step +with updated as ( + -- Update the 'todos' table and return the modified rows + -- This approach allows us to both update the data and use it for reporting + update todos set + -- Modify the title based on user input for labels + -- The CASE statements handle different scenarios for label management + title = case + -- If :remove_label is null, we keep the existing title as is + when :remove_label is null then + title + else + -- Remove any existing labels (text within parentheses) + -- This uses a regular expression to strip out (label) from the end + regexp_replace(title, '\s*\(.*\)', '') + end + -- Concatenate the result with a new label if provided + || + case + -- If no new label is provided, we don't add anything + when :new_label is null or :new_label = '' then + '' + else + -- Add the new label in parentheses at the end + ' (' || :new_label || ')' + end + -- Determine which todos to update based on user selection + where + -- Update specific todos if their IDs are in the :todos parameter + -- :todos is a JSON array of todo string IDs, e.g. ["1", "2", "3"] + -- that optionally includes "all" to update all todos + id in ( + -- Parse the JSON array of todo IDs and convert each to integer + -- This allows for multiple todo selection in the UI + select e::int from jsonb_array_elements_text(:todos::jsonb) e + where e != 'all' + ) + -- If 'all' is the only selected, update every todo (by making the where condition always true) + or :todos = '["all"]' + -- Return all updated rows for counting and potential further use + returning * +) +-- Generate an alert component to inform the user about the update result +-- This provides immediate feedback on the operation's outcome +select 'alert' as component, + 'Batch update' as title, + -- Create a dynamic message with the count of updated todos + format('%s todos updated', (select count(*) from updated)) as description +-- Only display the alert if at least one todo was updated +-- This prevents showing unnecessary alerts for no-op updates +where exists (select * from updated); + +-- Create a form component for the batch update interface +-- This sets up the structure for the user input form +select 'form' as component, + 'Batch update' as title, + 'Update all todos' as contents; + +-- Create a select input for choosing which todos to update +-- This allows users to pick multiple todos or all todos for updating +select + 'select' as type, + 'Update these todos' as label, + 'todos[]' as name, + true as multiple, + true as dropdown, + true as required, + -- Combine a static "all" option with dynamic options for each todo + -- This uses JSON functions to build a complex data structure for the UI + -- The JSON structure is used to set the label, value, and selection state for each option + -- The generated JSON looks like this: + -- [{"label":"Update all todos","value":"all","selected":true},{"label":"Todo 1","value":"1","selected":false}] + jsonb_build_array(jsonb_build_object( -- json_build_object takes a list of key-value pairs and returns a JSON object + 'label', 'Update all todos', -- The label of the option + 'value', 'all', -- The value of the option + 'selected', :todos = '["all"]' or :todos is null -- Pre-select 'all' only if it was previously chosen or if :todos is not set (the page was just loaded) + )) || + -- Generate an option for each todo in the database + jsonb_agg(jsonb_build_object( + 'label', title, + 'value', id, + -- Pre-select this todo if it was in the previous selection + 'selected', (id in (select e::int from jsonb_array_elements_text(:todos::jsonb) e where e != 'all')) + )) as options +from todos; + +-- Create a text input for entering a new label +-- This allows users to specify the label to be added to the selected todos +select 'new_label' as name, 'New label' as label; + +-- Create a checkbox for optionally removing existing labels +-- This gives users the choice to strip old labels before adding a new one +select 'checkbox' as type, 'Remove previous labels' as label, 'remove_label' as name; \ No newline at end of file diff --git a/examples/todo application (PostgreSQL)/delete.sql b/examples/todo application (PostgreSQL)/delete.sql new file mode 100644 index 0000000..c27eea5 --- /dev/null +++ b/examples/todo application (PostgreSQL)/delete.sql @@ -0,0 +1,30 @@ +-- We find the todo item with the id given in the URL (/delete.sql?todo_id=1) +-- and we check that the URL also contains a 'confirm' parameter set to 'yes' (/delete.sql?todo_id=1&confirm=yes) +-- If both conditions are met, we delete the todo item from the database +-- and redirect the user to the home page. +delete from todos +where id = $todo_id::int and $confirm = 'yes' +returning -- returning will return one row if an item was deleted, and zero rows if no item was deleted + 'redirect' as component, -- if one item was deleted, we redirect the user to the home page, and skip the rest of the page + '/' as link; + +-- If we are here, it means that the delete statement above did not delete anything +-- because the confirm parameter was not set to 'yes'. + +-- We display the same header as in other pages, by including the shell.sql file. +select 'dynamic' as component, sqlpage.run_sql('shell.sql') as properties; + +-- When the page is initially loaded, it will contain a todo_id parameter +-- but no confirm parameter, so the delete statement above will not delete anything +-- and the 'redirect' component will not be returned. +-- In this case, we display a confirmation message to the user. +select + 'alert' as component, -- an alert is a message that is displayed to the user + 'red' as color, + 'Confirm deletion' as title, + 'Are you sure you want to delete the following todo item ? + +> ' || title as description_md, -- we include the text of the todo item in the markdown confirmation message + '?todo_id=' || $todo_id || '&confirm=yes' as link, -- When the user clicks on the 'Delete' button, the page will be reloaded with the confirm parameter set to 'yes', so that the delete statement above will delete the todo item + 'Delete' as link_text +from todos where id = $todo_id::int; -- finds the todo item with the id given in the URL diff --git a/examples/todo application (PostgreSQL)/docker-compose.yml b/examples/todo application (PostgreSQL)/docker-compose.yml new file mode 100644 index 0000000..b0230f8 --- /dev/null +++ b/examples/todo application (PostgreSQL)/docker-compose.yml @@ -0,0 +1,18 @@ +services: + web: + image: lovasoa/sqlpage:main # main is cutting edge, use sqlpage/SQLPage:latest for the latest stable version + ports: + - "8080:8080" + volumes: + - .:/var/www + - ./sqlpage:/etc/sqlpage + depends_on: + - db + environment: + DATABASE_URL: postgres://root:secret@db/sqlpage + db: # The DB environment variable can be set to "mariadb" or "postgres" to test the code with different databases + image: postgres + environment: + POSTGRES_USER: root + POSTGRES_DB: sqlpage + POSTGRES_PASSWORD: secret diff --git a/examples/todo application (PostgreSQL)/explanation_diagram.svg b/examples/todo application (PostgreSQL)/explanation_diagram.svg new file mode 100644 index 0000000..734a001 --- /dev/null +++ b/examples/todo application (PostgreSQL)/explanation_diagram.svg @@ -0,0 +1,21 @@ + + + eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1cXGtT28hcdTAwMTL9nl9BsV/Xk3l0zyNVW7dcdTAwMWNcYoE8SHhDli3KXHUwMDBmYYSN5bXlXHUwMDAw2cp/vz1cdTAwMDYsWZKfmNzkLlBFgVx1MDAxZePWTJ8+p6db/PNiZWU1vu1cdTAwMDSrr1ZWg5tapVx1MDAxNda7levV3/3xr0G3XHUwMDE3Rm06JVx1MDAwN3/3on63NrjyXCKOO71XL18md7BadHV3V9Bcbq6Cdtyj6/6kv1dW/lx1MDAxOfykM2Hd37u99XG9VOWfXCJ+WW7yVv9Eq9vy4NbBRVx1MDAwZsZ0g1pcXGk3WkFy6oaOXHUwMDBiwYFcdTAwMTkjhZJKcKHV8OytP1x1MDAwYqCYtcg1XHUwMDAwSKOdXHUwMDFknr5cdTAwMGXr8YW/xEmm0UhlOFx1MDAxN9Jcbje84lwiXGJcdTAwMWJcdTAwMTfxYFx1MDAxNMW08J/BlVHCKVx1MDAxY15zZ9GrXHUwMDE1PjzSi7tRM1iLWlHXm/2bXGL8d2J0tVJrNrpRv11Prjk/XHUwMDBmas4l15yHrdZefDtcdTAwMTiZZplmdDUz/tG99TJzfNxd9IGNi3bQ86sghkejTqVcdTAwMTbGg4niyVx1MDAxM3jrOlv1wYL9ldjUrVxcXHUwMDA1W37F2v1Wa3g4bNdcdTAwMDO/XHUwMDBlq5WjkU9r1+8/7WG1k6VU90e+J7ZcdTAwMDeBXHUwMDFmXHUwMDE4jZDoILVIicfROmePbkftgfdcdO5AXHUwMDAyXHUwMDE3XFwnVvXWye3iwajnlVYvSKbfm/Ym65Jpt0y5pul0I/VOX26q7a/lZrlr3/Fyc/iYI+5Z6Xaj69Xhme/3vyXz1+/UK3dcdTAwMDZcdCNQaCOtlDp5qFbYbmYnt1x1MDAxNdWayTO8SE1aXHUwMDA2Q5Xdo29cdTAwMWI7R2dcdTAwMDelysnh1/C2f2S/VObAkFRcZlx1MDAxMDhoyYVwXHUwMDFhRkFEZ5hxXHUwMDA2nDQokaPOg1xiNVx1MDAwMy24tFZcdTAwMTk/js2jaOmoqcpzWa3+4qg5eTRqaO65ss5gXHUwMDBlIHRcdTAwMTLEONSgXHUwMDEzkuKhsctcdTAwMDPNg5vFwU08ipI7L9173d6RR7VyaWtnq6ld7XW1VX2TwszvxcPe3Vxcss2KO7xt9k56+OV4/bzbLX/sLlx0i1qD0MIuXHUwMDA3i8VPmcPiyCTdw1A7XHUwMDA23CE6imhcXDiXhSFOgaFcdTAwMDaGyDlXXHUwMDFjhUFjRVx1MDAxZYVy+dzlv351XHUwMDE0XHUwMDFlXHUwMDE2w3Dk8lx1MDAwN7yRu5DaXHUwMDAwU4Q3gePwRlx1MDAwYstcdTAwMTVQiFxcXHUwMDAwbyNm5JxXaVx1MDAwM6llne68iS96XHUwMDFmpOev9uOYzE/mK2rHe+E3b7bkI0c3Kldh63ZkIfwg5VbY8I+/WiOLg1x1MDAwNJZ+XHUwMDEy4pA04fCCq7BeT/NPjVx1MDAwNq2E7aC7NVx1MDAwYpFF3bBcdTAwMTG2K639MYbTs1x1MDAwN5tD2cYkTsBp15XfdoKb6FZHjVx1MDAwME7C+pdcbu7OzpncXHUwMDE5prghbamcNlxuklx1MDAwNVx1MDAxOEyPMcCUJjIl0aitMcl8PYBVXHUwMDEy6XKBilx1MDAxY4nWT4iU6yTK01x0XHUwMDA21pH4VFZxJ2VcdTAwMTKjnpXnXHUwMDEwvZUlKE9ntcZCXG5cdTAwMTXK5lx1MDAwNOlQelrUXHUwMDAyrbBLl56PoLuJNPq0ktZYXG6My6HR/tr2XHUwMDA22r2zXm+7XHUwMDE2NT7JrTf8sDNcdTAwMWKNXG5BXHUwMDE5oUcmcajiqbTgLiVcdTAwMTSGceksxXBcdFx1MDAxNJVVXHUwMDBlmcYxy59pdH4gVudgUVx1MDAwNEe+wqUsgtxY1SqNdMpYWFx1MDAwNG/TWFRYkHP4bo5FXHUwMDA388B6f7eWQaSt4DyeQKNx1Fx1MDAxOcehIyZnXHSzwMZ5OPP4unxVPjo6dHhxuffl0+6HJvH1PHmmY9JJXHUwMDAxcPczw5mAllx1MDAxMdmBo2AsOIDJ55nZnZg8Mo1kyknS80pcdTAwMWFDkjlZ0uesc1xi1Nrjs04rXHUwMDExLFx1MDAxOFx1MDAwMWknvcdvKjXJXHUwMDEyplx1MDAwMW1cdTAwMDVKuYhcZl4w7bxUx/2r8vu4Xu1fnOyor7C7sfHuXHUwMDA3pJ3/O1x1MDAxZfaSZVlbS8WzN1x1MDAxM1x1MDAwZltkwkPRcIr0ae16h3YrXHUwMDE5Ui7LLZeU08pcdTAwMWPYlWDWJ1tcdTAwMDZRXHUwMDFiLbUrXHUwMDEwyM88XFxcdTAwMDTv+lx1MDAxYzwsjPZcdTAwMWKoqkj6mnEwln5cdTAwMGLCWP1cdTAwMDTJrFx1MDAwNVx1MDAwZfPsxORouFx1MDAxNfbiZTDwY1PZKVxcmWXmUbPnIeXJISpcdTAwMTNGsoRsXHUwMDE5iTBH6kejyPIxcs2cRVx1MDAxMFx1MDAwMoy0yub52FqGSirg2lx1MDAwMLdcdTAwMDZcdTAwMTIySLZ9taI8WVx1MDAxMZuTmyklU6nSXGaIdUbU5L9cdTAwMDCxwayELMdcdTAwMTMyXGKlfMAsXHUwMDAwslx1MDAwMjlWUlOcptVXmIpcdTAwMDBPTsndqKp2Oju168/bR1x1MDAxYp2ePv9wezMnxWlSf1x1MDAwYsn1Tlx1MDAxNGZt/zNlIU+by4e///V74dWlsVxi8F+kVJkhXHUwMDFkXG5Ajkwkh2rqeIJcdTAwMDBpnOO0KFx1MDAxNF6tkOnx8lhKxstNWavSi9eiq6swpon77Fx1MDAxZjpHXHUwMDAxcaVcdTAwMWK/JidcZtuNUWe7r9nOXHUwMDEyyFx1MDAwNtG01vdcdTAwMTPK6WFJeKBSXHUwMDFhNbG6NKmLXHUwMDFhlc4glFFOLSz3dO6IdVx1MDAxY+b8OWjXp9vUOnpcdTAwMWbu7ux393qHbz7gNy3it+FZkU0lX7rSWjogIeE46GRXfWiUZlxcWkMqRFx1MDAwYomCXHUwMDEykzzG/EyVfVx1MDAwML1cYio5XHUwMDE4k8Xpc2nBNmbrsdD9Z9BUTlx1MDAwMXFcdTAwMDE5huZAzoGZQp1GZEbQcVx1MDAxMkycW5vXVFJwSrHoS/lEy4IqKNTBXFybjP+aXGI9T4lcdTAwMDC8K43Mblx1MDAxMovdWFVFyZHl4MxcIoF4oqoyUqKR8yRcdTAwMDQ5VfX2zf7KyziqR2fnUffK7yCctv8z+Dus/2Hm0ltqZPCl6a0pMiirt2Z9oKxcdTAwMTKbXHUwMDAw68k53UQhRjGfUYyyXHUwMDBlOILJdrFYjkxcdTAwMGJQXHUwMDE0wDRFVSjoYpFkmudcdTAwMDTiXHUwMDE5JX3wLVBinFx1MDAxMipHo1x1MDAxM/hcco5sjD7jfIjz88crMVwieq4pXGJcdTAwMTehn1wi99hcdTAwMTKh5PRtyVx1MDAxNZKw/uRK7Pgj9Fx1MDAwZvY+V1157cPF/pd9RyzZmW/Tn1x1MDAxZXWuTf9ZlZjy2kmS4yGQVFx1MDAxMFx1MDAxMkza/pJcdTAwMDDm6CBcIreUwFx1MDAwMChcdTAwMWOeXHUwMDFkI66EIPkkOYkxXHUwMDBmIyVyXHUwMDAzWlJX0nDKbZ1cdTAwMTRcbmHagCXDrLVOck05NIlcdTAwMWWlR1x1MDAwNlRCM8uNLzNoXHUwMDBiXHUwMDBlUlx1MDAwNj6dWit9Myftw4tcdTAwMTO1/XGzfHMsZPPrx4NitVx1MDAwNlx1MDAxNknukEdcbqVBpVx1MDAxYVxuhspIMZpdS1JCgbHOWLGYWptZQXq1psF4baMsXHUwMDFhXG5meVx1MDAwNfnU4qxcdTAwMThcdTAwMTEziDNifPIv60uJaMnPRGbDi1RcdTAwMWIjZyBcdTAwMDVKj4hcdTAwMWHy6kxcdTAwMDBn2leevDuhQF1cdTAwMTDFn9VZYdSeQ51ZTbmVRVuYKKvswYfgTDCwlOO5RfqlpogzsNol87KAOOtcdTAwMDb10NdcXFbiaOW0/XKxQtRcdTAwMTPJsimiKCvL0o9S9CBzyLGb0sHN0WZj/bLU3uzDzrfPXHUwMDE1c4Kz7Vxcc+O3noGwSolcdTAwMTbl9aNAJi9gXFyA3670lYx8XHUwMDAxmbiDUdKp0SGgdbZoX+x557pcYseN2WHsi4BImawswrFcdTAwMWJfQqasV4JcdTAwMTLyZ2rZmK6yXHUwMDA01+5xXHUwMDE53Eiu81x1MDAxM5eox9g51474RFxyNL1MTdLSSl+g1tqhsZldXHUwMDE20udMUoiguC10vq9LOEV3k7ZcIlFI2ZbAJN9/7oWeXGL+i0dXpUlj+y2Uwn1cdTAwMTeT6qfNbrw4Y6w1Sv3AZuidLfPu+G1dXHUwMDFjXHUwMDA3m1x1MDAwN1x1MDAxMUB4wFx1MDAxYlx1MDAwN5uzVo/hYP1op7PXgN75+7VD2Yh2ttTHJVSlf9FqN+V+Jlx1MDAxZChcdTAwMTavdlx1MDAxN6/KTJrBKKYpnVx1MDAwMu5cdTAwMDRlrDrTdUbeNyloXHUwMDE4zXypW1Oaq5Ckf3L3s2CYXHUwMDE4M8LZXHUwMDA1g1x1MDAxMqiEQ1fUsWJ0rlx1MDAwZu0hODhLy1x1MDAwNiCWvyvLKVx1MDAwYnxcXOP2g1r+XHUwMDE56t1TSHec0F+M4SfXf2ZgeFx1MDAxMujWXHUwMDE5kvjWXHUwMDE4nerwvNtvXHUwMDA1YODLolY5K9O7W0OSt45cdFx1MDAwNGWsXHUwMDAwXHUwMDFmgXiBwlx1MDAxN1x1MDAxY1x1MDAxObdoNGpluFbJhzxz/lx1MDAxML+Xj+9Ek5SZXHUwMDFiibyok9TKXHUwMDFj2lOk71x1MDAwMJRaqNyyIOmX3u7q+i2gi882a7WNd7133zrNX5j0ZyBnSfp5SeRcXDx7M5EzwdWXpSVHRVx1MDAxY2sy5GwtsIfir07r/Vx1MDAwN7iDZNzTM2pwIIA/0/O9TdPg3ZydnunTuPRdXHUwMDEyhZ1oZjyONSknZ+RcdTAwMTOUTblcdTAwMTGAXHUwMDBilTZcdTAwMWVcYtrnsT9cdTAwMDM5T+HLLDmPmj1cdTAwMGYxn7xf2zypb9hcdTAwMTBj0V+r1o559ejDXHUwMDFjxKw44/5cdTAwMWRkR4xJidwoTp2UTIO1VpKIdpggLnlcdTAwMGZcdTAwMTlcdTAwMTjB2FinueBcdTAwMTJ1UUPac+5dXHUwMDAw1PajeVhRiqOks0U0jGqsvFx1MDAxNlxuSWUh/MCG8JPa/vnRerezXHUwMDFkfDhbv13/8vrgarfyXHUwMDAzWHg6W1x1MDAwZTKCJb1AVfyUs6WymmnrX0JGrlx1MDAxMdFlUWgmo1BzZp57te9tmlx1MDAwNrxonl5t1L55o7DFXHUwMDEzx/dcdTAwMTVYv1Ou+Fx1MDAxM5SuXHUwMDFln8H2+tWr8KfIX6cwV5ZcIrOGz0OSk2PI5EYh/1KF/8dcck5x498szmCTMle0KDgpWSTVm69NSaXoXG4hXHUwMDFkcMp+nCx665guccZcdTAwMTCBXHUwMDFhi2ggpb2WU3L+v2DMzqyMOb5RXGKNXHUwMDEwgrtCPEvNx+9Xg3S0eu5cdTAwMDdmrldcdTAwMWLX2+9L4VX1U1WXLzeaZ6Va9/18maDjZq6XkoaHl9qxPdb//VdJI1x1MDAwM2WV/5c30m/DuqnDcaZcdTAwMDU6S1x1MDAxMda/xY8wOl5cdTAwMWVKyYC5KVtaXHUwMDBm0ORQtpLuXHUwMDAx0kY6TdJNW6stiebUReP6bWZq+Zm5XHKpRDZcdTAwMDBwJYxRlILT8uBcZjYsueen2Ltn0UqgSCs5ykGVkH6TOFMrtNwwv0NovctZZVxu/nWSXHUwMDA2xpVS5I5I91x1MDAwYizYW1x1MDAxMPBcdTAwMWOBXHUwMDBiXCLw33NIJ5KqSOSY30bw+mh8oEW/XHUwMDBibFx1MDAxNvonSdOafkjNPUo5ff60t3/aXHUwMDFl38J82j5tv/J//XG6Wo9W4otg5ZRQ27tcYnqnqz9DZ9BcdTAwMTRcdTAwMTWUXHUwMDE1XFxcdTAwMGI8b/5pi9uHXtxHYVwi9c5eTFx1MDAwYjVcZmyrX8Pg+vX43OPFfUTxXHUwMDAwXG5cdTAwMDb0//3F9/9cdTAwMDLBTMouIn0= + + + + + buttonindex.sqllistGET /todo_form.sql?todo_id=7redirect to /index.sqltodo_form.sqlredirectformsubmitPOST/todo_form.sql?todo_id=7:todo="do the dishes" \ No newline at end of file diff --git a/examples/todo application (PostgreSQL)/index.sql b/examples/todo application (PostgreSQL)/index.sql new file mode 100644 index 0000000..699d08d --- /dev/null +++ b/examples/todo application (PostgreSQL)/index.sql @@ -0,0 +1,20 @@ +select 'dynamic' as component, sqlpage.run_sql('shell.sql') as properties; + +select 'list' as component, + 'Todo' as title, + 'No todo yet...' as empty_title; + +select + title, + 'todo_form.sql?todo_id=' || id as edit_link, + 'delete.sql?todo_id=' || id as delete_link +from todos; + +select + 'button' as component, + 'center' as justify; +select + 'todo_form.sql' as link, + 'green' as color, + 'Add new todo' as title, + 'circle-plus' as icon; \ No newline at end of file diff --git a/examples/todo application (PostgreSQL)/screenshot.png b/examples/todo application (PostgreSQL)/screenshot.png new file mode 100644 index 0000000..f464bbe Binary files /dev/null and b/examples/todo application (PostgreSQL)/screenshot.png differ diff --git a/examples/todo application (PostgreSQL)/shell.sql b/examples/todo application (PostgreSQL)/shell.sql new file mode 100644 index 0000000..3a1e9bd --- /dev/null +++ b/examples/todo application (PostgreSQL)/shell.sql @@ -0,0 +1,7 @@ +select + 'shell' as component, + format ('Todo list (%s)', count(*)) as title, + 'batch' as menu_item, + 'timeline' as menu_item +from + todos; \ No newline at end of file diff --git a/examples/todo application (PostgreSQL)/sqlpage/migrations/0000_init.sql b/examples/todo application (PostgreSQL)/sqlpage/migrations/0000_init.sql new file mode 100644 index 0000000..31ef72d --- /dev/null +++ b/examples/todo application (PostgreSQL)/sqlpage/migrations/0000_init.sql @@ -0,0 +1,6 @@ +create table + todos ( + id serial primary key, + title text not null, + created_at timestamp default current_timestamp + ); \ No newline at end of file diff --git a/examples/todo application (PostgreSQL)/sqlpage/migrations/README.md b/examples/todo application (PostgreSQL)/sqlpage/migrations/README.md new file mode 100644 index 0000000..b263393 --- /dev/null +++ b/examples/todo application (PostgreSQL)/sqlpage/migrations/README.md @@ -0,0 +1,41 @@ +# SQLPage migrations + +SQLPage migrations are SQL scripts that you can use to create or update the database schema. +They are entirely optional: you can use SQLPage without them, and manage the database schema yourself with other tools. + +If you are new to SQL migrations, please read our [**introduction to database migrations**](https://sql-page.com/your-first-sql-website/migrations.sql). + +## Creating a migration + +To create a migration, create a file in the `sqlpage/migrations` directory with the following name: + +``` +_.sql +``` + +Where `` is a number that represents the version of the migration, and `` is a name for the migration. +For example, `001_initial.sql` or `002_add_users.sql`. + +When you need to update the database schema, always create a **new** migration file with a new version number +that is greater than the previous one. +Use commands like `ALTER TABLE` to update the schema declaratively instead of modifying the existing `CREATE TABLE` +statements. + +If you try to edit an existing migration, SQLPage will not run it again, will detect + +## Running migrations + +Migrations that need to be applied are run automatically when SQLPage starts. +You need to restart SQLPage each time you create a new migration. + +## How does it work? + +SQLPage keeps track of the migrations that have been applied in a table called `_sqlx_migrations`. +This table is created automatically when SQLPage starts for the first time, if you create migration files. +If you don't create any migration files, SQLPage will never touch the database schema on its own. + +When SQLPage starts, it checks the `_sqlx_migrations` table to see which migrations have been applied. +It checks the `sqlpage/migrations` directory to see which migrations are available. +If the checksum of a migration file is different from the checksum of the migration that has been applied, +SQLPage will return an error and refuse to start. +If you end up in this situation, you can remove the `_sqlx_migrations` table: all your old migrations will be reapplied, and SQLPage will start again. diff --git a/examples/todo application (PostgreSQL)/sqlpage/templates/README.md b/examples/todo application (PostgreSQL)/sqlpage/templates/README.md new file mode 100644 index 0000000..c70a3ac --- /dev/null +++ b/examples/todo application (PostgreSQL)/sqlpage/templates/README.md @@ -0,0 +1,20 @@ +# SQLPage component templates + +SQLPage templates are handlebars[^1] files that are used to render the results of SQL queries. + +[^1]: https://handlebarsjs.com/ + +## Default components + +SQLPage comes with a set of default[^2] components that you can use without having to write any code. +These are documented on https://sql-page.com/components.sql + +## Custom components + +You can [write your own component templates](https://sql-page.com/custom_components.sql) +and place them in the `sqlpage/templates` directory. +To override a default component, create a file with the same name as the default component. +If you want to start from an existing component, you can copy it from the `sqlpage/templates` directory +in the SQLPage source code[^2]. + +[^2]: A simple component to start from: https://github.com/sqlpage/SQLPage/blob/main/sqlpage/templates/code.handlebars \ No newline at end of file diff --git a/examples/todo application (PostgreSQL)/test.hurl b/examples/todo application (PostgreSQL)/test.hurl new file mode 100644 index 0000000..52e4c08 --- /dev/null +++ b/examples/todo application (PostgreSQL)/test.hurl @@ -0,0 +1,49 @@ +GET http://localhost:8080/ +HTTP 200 +[Asserts] +body contains "Todo" +body contains "Add new todo" +body not contains "An error occurred" + +POST http://localhost:8080/todo_form.sql +[FormParams] +todo: Created by Hurl +HTTP 302 +[Asserts] +header "Location" == "/" + +GET http://localhost:8080/ +HTTP 200 +[Captures] +todo_id: xpath "substring-after(string((//a[contains(@href, 'todo_form.sql?todo_id=')])[last()]/@href), 'todo_id=')" +[Asserts] +body contains "Created by Hurl" +body htmlUnescape contains "todo_form.sql?todo_id={{todo_id}}" +body htmlUnescape contains "delete.sql?todo_id={{todo_id}}" +body not contains "An error occurred" + +POST http://localhost:8080/todo_form.sql?todo_id={{todo_id}} +[FormParams] +todo: Edited by Hurl +HTTP 302 +[Asserts] +header "Location" == "/" + +GET http://localhost:8080/delete.sql?todo_id={{todo_id}} +HTTP 200 +[Asserts] +body contains "Confirm deletion" +body contains "Edited by Hurl" +body htmlUnescape contains "?todo_id={{todo_id}}&confirm=yes" +body not contains "An error occurred" + +GET http://localhost:8080/delete.sql?todo_id={{todo_id}}&confirm=yes +HTTP 302 +[Asserts] +header "Location" == "/" + +GET http://localhost:8080/ +HTTP 200 +[Asserts] +body not contains "Edited by Hurl" +body not contains "An error occurred" diff --git a/examples/todo application (PostgreSQL)/timeline.sql b/examples/todo application (PostgreSQL)/timeline.sql new file mode 100644 index 0000000..8f2d49a --- /dev/null +++ b/examples/todo application (PostgreSQL)/timeline.sql @@ -0,0 +1,25 @@ +select + 'dynamic' as component, + sqlpage.run_sql ('shell.sql') as properties; + +select + 'timeline' as component; + +SELECT + title, + 'todo_form.sql?todo_id=' || id AS link, + TO_CHAR (created_at, 'FMMonth DD, YYYY, HH12:MI AM TZ') AS date, + 'calendar' AS icon, + 'green' AS color, + CONCAT ( + EXTRACT( + DAY + FROM + NOW () - created_at + ), + ' days ago' + ) AS description +FROM + todos +ORDER BY + created_at DESC; \ No newline at end of file diff --git a/examples/todo application (PostgreSQL)/todo_form.sql b/examples/todo application (PostgreSQL)/todo_form.sql new file mode 100644 index 0000000..1ec457d --- /dev/null +++ b/examples/todo application (PostgreSQL)/todo_form.sql @@ -0,0 +1,34 @@ + +-- When the form is submitted, we insert the todo item into the database +-- or update it if it already exists +-- and redirect the user to the home page. +-- When the form is initially loaded, :todo is null, +-- nothing is inserted, and the 'redirect' component is not returned. +insert into todos(id, title) +select COALESCE($todo_id::int, nextval('todos_id_seq')), :todo -- $todo_id will be null if the page is accessed via the 'Add new todo' button (without a ?todo_id= parameter) +where :todo is not null -- only insert if the form was submitted +on conflict(id) do update set title = excluded.title +returning + 'redirect' as component, + '/' as link; + +-- The header needs to come before the form, but after the potential redirect +select 'dynamic' as component, sqlpage.run_sql('shell.sql') as properties; + +-- The form needs to come AFTER the insert statement +-- because the insert statement will redirect the user to the home page if the form was submitted +select + 'form' as component, + 'Todo' as title, + ( + case when $todo_id is null then + 'Add new todo' + else + 'Edit todo' + end + ) as validate; +select + 'Todo item' as label, + 'todo' as name, + 'What do you have to do ?' as placeholder, + (select title from todos where id = $todo_id::int) as value; \ No newline at end of file diff --git a/examples/todo application/README.md b/examples/todo application/README.md new file mode 100644 index 0000000..1e1fc20 --- /dev/null +++ b/examples/todo application/README.md @@ -0,0 +1,108 @@ +# Todo app with SQLPage + +This is a simple todo app implemented with SQLPage. It uses a SQLite database to store the todo items. +(See [the PostgreSQL version](<../todo%20application%20(PostgreSQL)/README.md>)) + +![Screenshot](screenshot.png) + +It is meant as an illustrative example of how to use SQLPage to create a simple CRUD application. + +## Structure + +### [`index.sql`](./index.sql) + +This is the main file of the application. +It will be loaded when the user visits the root of the application +(`http://localhost:8080/` when running this example locally). + +In order, it uses: + +- the [`dynamic`](https://sql-page.com/documentation.sql?component=dynamic#component) component to load the [`shell.sql`](#shellsql) file that will be used at the top of every page + in the application to create a consistent layout and top bar. +- the [`list`](https://sql-page.com/documentation.sql?component=list#component) component to display the list of todo items. +- the [`button`](https://sql-page.com/documentation.sql?component=button#component) component to create a button that will redirect the user to the [`todo_form.sql`](#todo_formsql) page to create a new todo item when clicked. + +### [`todo_form.sql`](./todo_form.sql) + +This file is used to create a new todo item or edit an existing one. + +It uses: + +1. the [`redirect`](https://sql-page.com/documentation.sql?component=redirect#component) component to redirect the user back to the [`index.sql`](#indexsql) page after the form is submitted. +1. the [`dynamic`](https://sql-page.com/documentation.sql?component=dynamic#component) component to load [`shell.sql`](#shellsql) to create a consistent layout and top bar. +1. the [`form`](https://sql-page.com/documentation.sql?component=form#component) component to create a form with fields for the title and description of the todo item. + +The order of the components is important, as the `redirect` component cannot be used after the page has been displayed. It is called first to ensure that the user is redirected immediately after submitting the form. It is guarded by a `WHERE :todo_id IS NOT NULL` clause to ensure that it only redirects when +the form was submitted, not when the page is +initially loaded by the user in their browser. + +![diagram explaining the structure of the application](./explanation_diagram.svg) + +### [`delete.sql`](./delete.sql) + +This file is used to delete a todo item. + +It contains a delete statement guarded by a +`WHERE $confirm = 'yes'` clause. +So, the delete is not executed when the page +is initially loaded, but only when the user +clicks the "Yes" button, which contains a link +pointing to the same page with the `confirm=yes` query parameter. + +The detailed step by step explanation of the delete process is as follows: + +- From the `index.sql` page, the user clicks the 'Delete' button on a todo item +- It loads the page `/delete.sql?todo_id=7` (without the `confirm=yes` parameter) + - the delete statement **is** sent to the database and executed. SQLPage has bound the values to URL query parameters, so we have + - `$todo_id` bound to `'7'`, and + - `$confirm` bound to `NULL` (since there was no `confirm` parameter in the url) + - the database evaluates the `where id = $todo_id and $confirm = 'yes'` condition to FALSE + - so it deletes nothing, and returns nothing + - SQLPage receives no row back from the database, it continues processing normally + - it executes the `select 'dynamic' ...` query, which itself requires executing the `shell.sql` file. The result of this is a row that contains `component=dynamic` and `properties={"component": "shell", "title": "My Todo App", ... }` + - it renders the page header with the application header and the top bar following the results of the query + - it sends to the database the last query: `select 'alert' as component, ... from todos where id = $todo_id` it binds the parameters like before + - `$todo_id` bound to `'7'` + - the database returns a single row, containing `component=alert`, `description_md=Are you sure [...] [the title of the todo item with id 7]`, ... + - SQLPage returns the the `alert` component with its contents to the browser +- The user sees the confirmation alert and clicks the 'Delete' button +- The page is reloaded, this time with the URL `/delete.sql?todo_id=7&confirm=yes` + - the delete statement is sent to the database and executed like last time. But this time SQLPage has bound the values to the new URL query parameters, + - `$todo_id` bound to `'7'`, (like before) + - `$confirm` bound to `'yes'` (since there is now a `confirm` parameter in the url) + - the database evaluates the `where id = $todo_id and $confirm = 'yes'` condition to TRUE + - so it deletes the todo item with id 7 and, as instructed by the `returning` clause, returns a single row containing `component=redirect`, `link=/` + - SQLPage receives the row back from the database, and immediately returns sends a 302 redirect response to the browser, redirecting the user to the `/` page. + - The following queries are not executed, as the page is redirected before they are processed. + +### [`shell.sql`](./shell.sql) + +This file is not meant to be accessed directly by the user (it would display an empty page with only the top bar). + +But it is included from all the other pages to +call the [`shell`](https://sql-page.com/documentation.sql?component=shell#component) component with the exact same parameters on every page. + +It is included everywhere using the [`dynamic`](https://sql-page.com/documentation.sql?component=dynamic#component) component and the [`sqlpage.run_sql`](https://sql-page.com/functions.sql?function=run_sql#function) function. + +## Running the example + +To run the example, simply [download the latest SQLPage release](https://github.com/sqlpage/SQLPage/releases) and run it from the root folder of the example. + +## SQLPage features used + +This example is meant to illustrate many +of the common features of SQLPage. + +### Components + +- [list](https://sql-page.com/documentation.sql?component=list#component) +- [button](https://sql-page.com/documentation.sql?component=button#component) +- [form](https://sql-page.com/documentation.sql?component=form#component) +- [redirect](https://sql-page.com/documentation.sql?component=redirect#component) +- [shell](https://sql-page.com/documentation.sql?component=shell#component) +- [timeline](https://sql-page.com/documentation.sql?component=timeline#component) +- [dynamic](https://sql-page.com/documentation.sql?component=timeline#component) + +### Functions + +- [sqlpage.run_sql](https://sql-page.com/functions.sql?function=run_sql#function) diff --git a/examples/todo application/delete.sql b/examples/todo application/delete.sql new file mode 100644 index 0000000..f4243ae --- /dev/null +++ b/examples/todo application/delete.sql @@ -0,0 +1,30 @@ +-- We find the todo item with the id given in the URL (/delete.sql?todo_id=1) +-- and we check that the URL also contains a 'confirm' parameter set to 'yes' (/delete.sql?todo_id=1&confirm=yes) +-- If both conditions are met, we delete the todo item from the database +-- and redirect the user to the home page. +delete from todos +where id = $todo_id and $confirm = 'yes' +returning -- returning will return one row if an item was deleted, and zero rows if no item was deleted + 'redirect' as component, -- if one item was deleted, we redirect the user to the home page, and skip the rest of the page + '/' as link; + +-- If we are here, it means that the delete statement above did not delete anything +-- because the confirm parameter was not set to 'yes'. + +-- We display the same header as in other pages, by including the shell.sql file. +select 'dynamic' as component, sqlpage.run_sql('shell.sql') as properties; + +-- When the page is initially loaded, it will contain a todo_id parameter +-- but no confirm parameter, so the delete statement above will not delete anything +-- and the 'redirect' component will not be returned. +-- In this case, we display a confirmation message to the user. +select + 'alert' as component, -- an alert is a message that is displayed to the user + 'red' as color, + 'Confirm deletion' as title, + 'Are you sure you want to delete the following todo item ? + +> ' || title as description_md, -- we include the text of the todo item in the markdown confirmation message + '?todo_id=' || $todo_id || '&confirm=yes' as link, -- When the user clicks on the 'Delete' button, the page will be reloaded with the confirm parameter set to 'yes', so that the delete statement above will delete the todo item + 'Delete' as link_text +from todos where id = $todo_id; -- finds the todo item with the id given in the URL diff --git a/examples/todo application/docker-compose.yml b/examples/todo application/docker-compose.yml new file mode 100644 index 0000000..a28f684 --- /dev/null +++ b/examples/todo application/docker-compose.yml @@ -0,0 +1,17 @@ +services: + init-db: + image: alpine + command: ["sh", "-c", "mkdir -p /etc/sqlpage && touch /etc/sqlpage/sqlpage.db && chmod 777 /etc/sqlpage && chmod 666 /etc/sqlpage/sqlpage.db"] + volumes: + - ./sqlpage:/etc/sqlpage + + web: + image: lovasoa/sqlpage:main + ports: + - "8080:8080" + volumes: + - .:/var/www + - ./sqlpage:/etc/sqlpage + depends_on: + init-db: + condition: service_completed_successfully diff --git a/examples/todo application/explanation_diagram.svg b/examples/todo application/explanation_diagram.svg new file mode 100644 index 0000000..734a001 --- /dev/null +++ b/examples/todo application/explanation_diagram.svg @@ -0,0 +1,21 @@ + + + eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1cXGtT28hcdTAwMTL9nl9BsV/Xk3l0zyNVW7dcdTAwMWNcYoE8SHhDli3KXHUwMDBmYYSN5bXlXHUwMDAw2cp/vz1cdTAwMDYsWZKfmNzkLlBFgVx1MDAxZePWTJ8+p6db/PNiZWU1vu1cdTAwMDSrr1ZWg5tapVx1MDAxNda7levV3/3xr0G3XHUwMDE3Rm06JVx1MDAwN3/3on63NrjyXCKOO71XL18md7BadHV3V9Bcbq6Cdtyj6/6kv1dW/lx1MDAxOfykM2Hd37u99XG9VOWfXCJ+WW7yVv9Eq9vy4NbBRVx1MDAwZsZ0g1pcXGk3WkFy6oaOXHUwMDBiwYFcdTAwMTkjhZJKcKHV8OytP1x1MDAwYqCYtcg1XHUwMDAwSKOdXHUwMDFknr5cdTAwMGXr8YW/xEmm0UhlOFx1MDAxN9Jcbje84lwiXGJcdTAwMWJcdTAwMTfxYFx1MDAxNMW08J/BlVHCKVx1MDAxY15zZ9GrXHUwMDE1PjzSi7tRM1iLWlHXm/2bXGL8d2J0tVJrNrpRv11Prjk/XHUwMDBmas4l15yHrdZefDtcdTAwMTiZZplmdDUz/tG99TJzfNxd9IGNi3bQ86sghkejTqVcdTAwMTbGg4niyVx1MDAxM3jrOlv1wYL9ldjUrVxcXHUwMDA1W37F2v1Wa3g4bNdcdTAwMDO/XHUwMDBlq5WjkU9r1+8/7WG1k6VU90e+J7ZcdTAwMDeBXHUwMDFmXHUwMDE4jZDoILVIicfROmePbkftgfdcdO5AXHUwMDAyXHUwMDE3XFwnVvXWye3iwajnlVYvSKbfm/Ym65Jpt0y5pul0I/VOX26q7a/lZrlr3/Fyc/iYI+5Z6Xaj69Xhme/3vyXz1+/UK3dcdTAwMDZcdCNQaCOtlDp5qFbYbmYnt1x1MDAxNdWayTO8SE1aXHUwMDA2Q5Xdo29cdTAwMWI7R2dcdTAwMDelysnh1/C2f2S/VObAkFRcZlx1MDAxMDhoyYVwXHUwMDFhRkFEZ5hxXHUwMDA2nDQokaPOg1xiNVx1MDAwMy24tFZcdTAwMTk/js2jaOmoqcpzWa3+4qg5eTRqaO65ss5gXHUwMDBlIHRcdTAwMTLEONSgXHUwMDEzkuKhsctcdTAwMDPNg5vFwU08ipI7L9173d6RR7VyaWtnq6ld7XW1VX2TwszvxcPe3Vxcss2KO7xt9k56+OV4/bzbLX/sLlx0i1qD0MIuXHUwMDA3i8VPmcPiyCTdw1A7XHUwMDA23CE6imhcXDiXhSFOgaFcdTAwMDaGyDlXXHUwMDFjhUFjRVx1MDAxZYVy+dzlv351XHUwMDE0XHUwMDFlXHUwMDE2w3Dk8lx1MDAwN7yRu5DaXHUwMDAwU4Q3gePwRlx1MDAwYstcdTAwMTVQiFxcXHUwMDAwbyNm5JxXaVx1MDAwM6llne68iS96XHUwMDFmpOev9uOYzE/mK2rHe+E3b7bkI0c3Kldh63ZkIfwg5VbY8I+/WiOLg1x1MDAwNJZ+XHUwMDEy4pA04fCCq7BeT/NPjVx1MDAwNq2E7aC7NVx1MDAwYpFF3bBcdTAwMTG2K639MYbTs1x1MDAwN5tD2cYkTsBp15XfdoKb6FZHjVx1MDAwME7C+pdcbu7OzpncXHUwMDE5prghbamcNlxuklx1MDAwNVx1MDAxOEyPMcCUJjIl0aitMcl8PYBVXHUwMDEy6XKBilx1MDAxY4nWT4iU6yTK01x0XHUwMDA21pH4VFZxJ2VcdTAwMTKjnpXnXHUwMDEwvZUlKE9ntcZCXG5cdTAwMTXK5lx1MDAwNOlQelrUXHUwMDAyrbBLl56PoLuJNPq0ktZYXG6My6HR/tr2XHUwMDA22r2zXm+7XHUwMDE2NT7JrTf8sDNcdTAwMWKNXG5BXHUwMDE5oUcmcajiqbTgLiVcdTAwMTSGceksxXBcdFx1MDAxNJVVXHUwMDBlmcYxy59pdH4gVudgUVx1MDAwNEe+wqUsgtxY1SqNdMpYWFx1MDAwNG/TWFRYkHP4bo5FXHUwMDA388B6f7eWQaSt4DyeQKNx1Fx1MDAxOcehIyZnXHSzwMZ5OPP4unxVPjo6dHhxuffl0+6HJvH1PHmmY9JJXHUwMDAxcPczw5mAllx1MDAxMdmBo2AsOIDJ55nZnZg8Mo1kyknS80pcdTAwMWFDkjlZ0uesc1xi1Nrjs04rXHUwMDExLFx1MDAxOFx1MDAwMWknvcdvKjXJXHUwMDEyplx1MDAwMW1cdTAwMDVKuYhcZl4w7bxUx/2r8vu4Xu1fnOyor7C7sfHuXHUwMDA3pJ3/O1x1MDAxZfaSZVlbS8WzN1x1MDAxM1x1MDAwZltkwkPRcIr0ae16h3YrXHUwMDE5Ui7LLZeU08pcdTAwMWPYlWDWJ1tcdTAwMDZRXHUwMDFiLbUrXHUwMDEwyM88XFxcdTAwMDTv+lx1MDAxYzwsjPZcdTAwMWKoqkj6mnEwln5cdTAwMGLCWP1cdTAwMDTJrFx1MDAwNVx1MDAwZfPsxORouFx1MDAxNfbiZTDwY1PZKVxcmWXmUbPnIeXJISpcdTAwMTNGsoRsXHUwMDE5iTBH6kejyPIxcs2cRVx1MDAxMFx1MDAwMoy0yub52FqGSirg2lx1MDAwMLdcdTAwMDZcdTAwMTIySLZ9taI8WVx1MDAxMZuTmyklU6nSXGaIdUbU5L9cdTAwMDCxwayELMdcdTAwMTMyXGKlfMAsXHUwMDAwslx1MDAwMjlWUlOcptVXmIpcdTAwMDBPTsndqKp2Oju168/bR1x1MDAxYp2ePv9wezMnxWlSf1x1MDAwYsn1Tlx1MDAxNGZt/zNlIU+by4e///V74dWlsVxi8F+kVJkhXHUwMDFkXG5Ajkwkh2rqeIJcdTAwMDBpnOO0KFx1MDAxNF6tkOnx8lhKxstNWavSi9eiq6swpon77Fx1MDAxZjpHXHUwMDAxcaVcdTAwMWK/JidcZtuNUWe7r9nOXHUwMDEyyFx1MDAwNtG01vdcdTAwMTPK6WFJeKBSXHUwMDFhNbG6NKmLXHUwMDFhlc4glFFOLSz3dO6IdVx1MDAxY+b8OWjXp9vUOnpcdTAwMWbu7ux393qHbz7gNy3it+FZkU0lX7rSWjogIeE46GRXfWiUZlxcWkMqRFx1MDAwYomCXHUwMDEykzzG/EyVfVx1MDAwML1cYio5XHUwMDE4k8Xpc2nBNmbrsdD9Z9BUTlx1MDAwMXFcdTAwMDE5huZAzoGZQp1GZEbQcVx1MDAxMkycW5vXVFJwSrHoS/lEy4IqKNTBXFybjP+aXGI9T4lcdTAwMDC8K43Mblx1MDAxMovdWFVFyZHl4MxcIoF4oqoyUqKR8yRcdTAwMDQ5VfX2zf7KyziqR2fnUffK7yCctv8z+Dus/2Hm0ltqZPCl6a0pMiirt2Z9oKxcdTAwMTKbXHUwMDAw68k53UQhRjGfUYyyXHUwMDBlOILJdrFYjkxcdTAwMGJQXHUwMDE0wDRFVSjoYpFkmudcdTAwMDTiXHUwMDE5JX3wLVBinFx1MDAxMipHo1x1MDAxM/hcco5sjD7jfIjz88crMVwieq4pXGJcdTAwMTehn1wi99hcdTAwMTKh5PRtyVx1MDAxNZKw/uRK7Pgj9Fx1MDAwZvY+V1157cPF/pd9RyzZmW/Tn1x1MDAxZXWuTf9ZlZjy2kmS4yGQVFx1MDAxMFx1MDAxMkza/pJcdTAwMDDm6CBcIreUwFx1MDAwMChcdTAwMWOeXHUwMDFkI66EIPkkOYkxXHUwMDBmIyVyXHUwMDAzWlJX0nDKbZ1cdTAwMTRcbmHagCXDrLVOck05NIlcdTAwMWWlR1x1MDAwNlRCM8uNLzNoXHUwMDBiXHUwMDBlUlx1MDAwNj6dWit9Myftw4tcdTAwMTO1/XGzfHMsZPPrx4NitVx1MDAwNlx1MDAxNknukEdcbqVBpVx1MDAxYVxuhspIMZpdS1JCgbHOWLGYWptZQXq1psF4baMsXHUwMDFhXG5meVx1MDAwNfnU4qxcdTAwMThcdTAwMTEziDNifPIv60uJaMnPRGbDi1RcdTAwMWIjZyBcdTAwMDVKj4hcdTAwMWHy6kxcdTAwMDBn2leevDuhQF1cdTAwMTDFn9VZYdSeQ51ZTbmVRVuYKKvswYfgTDCwlOO5RfqlpogzsNol87KAOOtcdTAwMDb10NdcXFbiaOW0/XKxQtRcdTAwMTPJsimiKCvL0o9S9CBzyLGb0sHN0WZj/bLU3uzDzrfPXHUwMDE1c4Kz7Vxcc+O3noGwSolcdTAwMTbl9aNAJi9gXFyA3670lYx8XHUwMDAxmbiDUdKp0SGgdbZoX+x557pcYseN2WHsi4BImawswrFcdTAwMWJfQqasV4JcdTAwMTLyZ2rZmK6yXHUwMDA01+5xXHUwMDE53Eiu81x1MDAxM5eox9g51474RFxyNL1MTdLSSl+g1tqhsZldXHUwMDE20udMUoiguC10vq9LOEV3k7ZcIlFI2ZbAJN9/7oWeXGL+i0dXpUlj+y2Uwn1cdTAwMTeT6qfNbrw4Y6w1Sv3AZuidLfPu+G1dXHUwMDFjXHUwMDA3m1x1MDAwN1x1MDAxMUB4wFx1MDAxYlx1MDAwN5uzVo/hYP1op7PXgN75+7VD2Yh2ttTHJVSlf9FqN+V+Jlx1MDAxZChcdTAwMTavdlx1MDAxN6/KTJrBKKYpnVx1MDAwMu5cdTAwMDRlrDrTdUbeNyloXHUwMDE4zXypW1Oaq5Ckf3L3s2CYXHUwMDE4M8LZXHUwMDA1g1x1MDAxMqiEQ1fUsWJ0rlx1MDAwZu0hODhLy1x1MDAwNiCWvyvLKVx1MDAwYnxcXOP2g1r+XHUwMDE56t1TSHec0F+M4SfXf2ZgeFx1MDAxMujWXHUwMDE5kvjWXHUwMDE4nerwvNtvXHUwMDA1YODLolY5K9O7W0OSt45cdFx1MDAwNGWsXHUwMDAwXHUwMDFmgXiBwlx1MDAxN1x1MDAxY1x1MDAxObdoNGpluFbJhzxz/lx1MDAxML+Xj+9Ek5SZXHUwMDFiibyok9TKXHUwMDFj2lOk71x1MDAwMJRaqNyyIOmX3u7q+i2gi882a7WNd7133zrNX5j0ZyBnSfp5SeRcXDx7M5EzwdWXpSVHRVx1MDAxY2sy5GwtsIfir07r/Vx1MDAwN7iDZNzTM2pwIIA/0/O9TdPg3ZydnunTuPRdXHUwMDEyhZ1oZjyONSknZ+RcdTAwMTOUTblcdTAwMTGAXHUwMDBilTZcdTAwMWVcYtrnsT9cdTAwMDM5T+HLLDmPmj1cdTAwMGYxn7xf2zypb9hcdTAwMTBj0V+r1o559ejDXHUwMDFjxKw44/5cdTAwMWRkR4xJidwoTp2UTIO1VpKIdpggLnlcdTAwMGZcdTAwMTlcdTAwMTjB2FinueBcdTAwMTJ1UUPac+5dXHUwMDAw1PajeVhRiqOks0U0jGqsvFx1MDAxNlxuSWUh/MCG8JPa/vnRerezXHUwMDFkfDhbv13/8vrgarfyXHUwMDAzWHg6W1x1MDAwZTKCJb1AVfyUs6WymmnrX0JGrlx1MDAxMdFlUWgmo1BzZp57te9tmlx1MDAwNrxonl5t1L55o7DFXHUwMDEzx/dcdTAwMTVYv1Ou+Fx1MDAxM5SuXHUwMDFln8H2+tWr8KfIX6cwV5ZcIrOGz0OSk2PI5EYh/1KF/8dcck5x498szmCTMle0KDgpWSTVm69NSaXoXG4hXHUwMDFkcMp+nCx665guccZcdTAwMTCBXHUwMDFhi2ggpb2WU3L+v2DMzqyMOb5RXGKNXHUwMDEwgrtCPEvNx+9Xg3S0eu5cdTAwMDdmrldcdTAwMWLX2+9L4VX1U1WXLzeaZ6Va9/18maDjZq6XkoaHl9qxPdb//VdJI1x1MDAwM2WV/5c30m/DuqnDcaZcdTAwMDU6S1x1MDAxMda/xY8wOl5cdTAwMWVKyYC5KVtaXHUwMDBm0ORQtpLuXHUwMDAx0kY6TdJNW6stiebUReP6bWZq+Zm5XHKpRDZcdTAwMDBwJYxRlILT8uBcZjYsueen2Ltn0UqgSCs5ykGVkH6TOFMrtNwwv0NovctZZVxu/nWSXHUwMDA2xpVS5I5I91x1MDAwYizYW1x1MDAxMPBcdTAwMWOBXHUwMDBiXCLw33NIJ5KqSOSY30bw+mh8oEW/XHUwMDBibFx1MDAxNvonSdOafkjNPUo5ff60t3/aXHUwMDFl38J82j5tv/J//XG6Wo9W4otg5ZRQ27tcYnqnqz9DZ9BcdTAwMTRcdTAwMTWUXHUwMDE1XFxcdTAwMGI8b/5pi9uHXtxHYVwi9c5eTFx1MDAwYjVcZmyrX8Pg+vX43OPFfUTxXHUwMDAwXG5cdTAwMDb0//3F9/9cdTAwMDLBTMouIn0= + + + + + buttonindex.sqllistGET /todo_form.sql?todo_id=7redirect to /index.sqltodo_form.sqlredirectformsubmitPOST/todo_form.sql?todo_id=7:todo="do the dishes" \ No newline at end of file diff --git a/examples/todo application/index.sql b/examples/todo application/index.sql new file mode 100644 index 0000000..699d08d --- /dev/null +++ b/examples/todo application/index.sql @@ -0,0 +1,20 @@ +select 'dynamic' as component, sqlpage.run_sql('shell.sql') as properties; + +select 'list' as component, + 'Todo' as title, + 'No todo yet...' as empty_title; + +select + title, + 'todo_form.sql?todo_id=' || id as edit_link, + 'delete.sql?todo_id=' || id as delete_link +from todos; + +select + 'button' as component, + 'center' as justify; +select + 'todo_form.sql' as link, + 'green' as color, + 'Add new todo' as title, + 'circle-plus' as icon; \ No newline at end of file diff --git a/examples/todo application/screenshot.png b/examples/todo application/screenshot.png new file mode 100644 index 0000000..f464bbe Binary files /dev/null and b/examples/todo application/screenshot.png differ diff --git a/examples/todo application/shell.sql b/examples/todo application/shell.sql new file mode 100644 index 0000000..9539c1b --- /dev/null +++ b/examples/todo application/shell.sql @@ -0,0 +1,4 @@ +select 'shell' as component, + printf('Todo list (%d)', count(*)) as title, + 'timeline' as menu_item +from todos; \ No newline at end of file diff --git a/examples/todo application/sqlpage/migrations/0000_init.sql b/examples/todo application/sqlpage/migrations/0000_init.sql new file mode 100644 index 0000000..8ee31a4 --- /dev/null +++ b/examples/todo application/sqlpage/migrations/0000_init.sql @@ -0,0 +1,5 @@ +create table todos( + id integer primary key, + title text not null, + created_at timestamp default current_timestamp +); \ No newline at end of file diff --git a/examples/todo application/sqlpage/migrations/README.md b/examples/todo application/sqlpage/migrations/README.md new file mode 100644 index 0000000..b263393 --- /dev/null +++ b/examples/todo application/sqlpage/migrations/README.md @@ -0,0 +1,41 @@ +# SQLPage migrations + +SQLPage migrations are SQL scripts that you can use to create or update the database schema. +They are entirely optional: you can use SQLPage without them, and manage the database schema yourself with other tools. + +If you are new to SQL migrations, please read our [**introduction to database migrations**](https://sql-page.com/your-first-sql-website/migrations.sql). + +## Creating a migration + +To create a migration, create a file in the `sqlpage/migrations` directory with the following name: + +``` +_.sql +``` + +Where `` is a number that represents the version of the migration, and `` is a name for the migration. +For example, `001_initial.sql` or `002_add_users.sql`. + +When you need to update the database schema, always create a **new** migration file with a new version number +that is greater than the previous one. +Use commands like `ALTER TABLE` to update the schema declaratively instead of modifying the existing `CREATE TABLE` +statements. + +If you try to edit an existing migration, SQLPage will not run it again, will detect + +## Running migrations + +Migrations that need to be applied are run automatically when SQLPage starts. +You need to restart SQLPage each time you create a new migration. + +## How does it work? + +SQLPage keeps track of the migrations that have been applied in a table called `_sqlx_migrations`. +This table is created automatically when SQLPage starts for the first time, if you create migration files. +If you don't create any migration files, SQLPage will never touch the database schema on its own. + +When SQLPage starts, it checks the `_sqlx_migrations` table to see which migrations have been applied. +It checks the `sqlpage/migrations` directory to see which migrations are available. +If the checksum of a migration file is different from the checksum of the migration that has been applied, +SQLPage will return an error and refuse to start. +If you end up in this situation, you can remove the `_sqlx_migrations` table: all your old migrations will be reapplied, and SQLPage will start again. diff --git a/examples/todo application/sqlpage/sqlpage.json b/examples/todo application/sqlpage/sqlpage.json new file mode 100644 index 0000000..086aa29 --- /dev/null +++ b/examples/todo application/sqlpage/sqlpage.json @@ -0,0 +1,3 @@ +{ + "database_url": "sqlite://./sqlpage/sqlpage.db?mode=rwc" +} diff --git a/examples/todo application/sqlpage/templates/README.md b/examples/todo application/sqlpage/templates/README.md new file mode 100644 index 0000000..c70a3ac --- /dev/null +++ b/examples/todo application/sqlpage/templates/README.md @@ -0,0 +1,20 @@ +# SQLPage component templates + +SQLPage templates are handlebars[^1] files that are used to render the results of SQL queries. + +[^1]: https://handlebarsjs.com/ + +## Default components + +SQLPage comes with a set of default[^2] components that you can use without having to write any code. +These are documented on https://sql-page.com/components.sql + +## Custom components + +You can [write your own component templates](https://sql-page.com/custom_components.sql) +and place them in the `sqlpage/templates` directory. +To override a default component, create a file with the same name as the default component. +If you want to start from an existing component, you can copy it from the `sqlpage/templates` directory +in the SQLPage source code[^2]. + +[^2]: A simple component to start from: https://github.com/sqlpage/SQLPage/blob/main/sqlpage/templates/code.handlebars \ No newline at end of file diff --git a/examples/todo application/test.hurl b/examples/todo application/test.hurl new file mode 100644 index 0000000..f2a8a32 --- /dev/null +++ b/examples/todo application/test.hurl @@ -0,0 +1,17 @@ +GET http://localhost:8080 +HTTP 200 +[Asserts] +body contains "Todo" +body contains "Add new todo" + +POST http://localhost:8080/todo_form.sql +[FormParams] +todo: Hurl todo item +HTTP 302 +[Asserts] +header "Location" == "/" + +GET http://localhost:8080 +HTTP 200 +[Asserts] +body contains "Hurl todo item" diff --git a/examples/todo application/timeline.sql b/examples/todo application/timeline.sql new file mode 100644 index 0000000..ddac60a --- /dev/null +++ b/examples/todo application/timeline.sql @@ -0,0 +1,13 @@ +select 'dynamic' as component, sqlpage.run_sql('shell.sql') as properties; + +select + 'timeline' as component; +select + title, + 'todo_form.sql?todo_id=' || id as link, + created_at as date, + 'calendar' as icon, + 'green' as color, + printf('%d days ago', julianday('now') - julianday(created_at)) as description +from todos +order by created_at desc; \ No newline at end of file diff --git a/examples/todo application/todo_form.sql b/examples/todo application/todo_form.sql new file mode 100644 index 0000000..a5c9cba --- /dev/null +++ b/examples/todo application/todo_form.sql @@ -0,0 +1,33 @@ + +-- When the form is submitted, we insert the todo item into the database +-- or update it if it already exists +-- and redirect the user to the home page. +-- When the form is initially loaded, :todo is null, +-- nothing is inserted, and the 'redirect' component is not returned. +insert or replace into todos(id, title) +select $todo_id, :todo -- $todo_id will be null if the page is accessed via the 'Add new todo' button (without a ?todo_id= parameter) +where :todo is not null -- only insert if the form was submitted +returning + 'redirect' as component, + '/' as link; + +-- The header needs to come before the form, but after the potential redirect +select 'dynamic' as component, sqlpage.run_sql('shell.sql') as properties; + +-- The form needs to come AFTER the insert statement +-- because the insert statement will redirect the user to the home page if the form was submitted +select + 'form' as component, + 'Todo' as title, + ( + case when $todo_id is null then + 'Add new todo' + else + 'Edit todo' + end + ) as validate; +select + 'Todo item' as label, + 'todo' as name, + 'What do you have to do ?' as placeholder, + (select title from todos where id = $todo_id) as value; \ No newline at end of file diff --git a/examples/user-authentication/README.md b/examples/user-authentication/README.md new file mode 100644 index 0000000..521ec0a --- /dev/null +++ b/examples/user-authentication/README.md @@ -0,0 +1,86 @@ +# User authentication demo + +This example demonstrates how to handle user authentication with SQLpage. + +It uses a PostgreSQL database to store user information and session ids, +but the same principles can be applied to other databases. + +All the user and password management is done in SQLPage, +which uses [best practices](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#maximum-password-lengths) for password storage. + +This demonstrates how to implement: + - [a signup form](./signup.sql) + - [a login form](./signin.sql) + - [a logout button](./logout.sql) + - [secured pages](./protected_page.sql) that can only be accessed by logged-in users + +User authentication is a complex topic, and you can follow the work on implementing differenet authentication methods in [this issue](https://github.com/sqlpage/SQLPage/issues/12). + +## How to run + +Install [docker](https://docs.docker.com/get-docker/) and [docker-compose](https://docs.docker.com/compose/install/). + +Then run the following command in this directory: + +```bash +docker-compose up +``` + +Then open [http://localhost:8080](http://localhost:8080) in your browser. + +## Caveats + +In this example, we handle user creation and login in SQLpage. + +If you are implementing user authentication in an public application with potentially sensitive data, +you should propably read the [Authentication Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html) from OWASP. + +## Screenshots + +| Signup form | Login form | Protected page | +| --- | --- | --- | +| ![signup form](./screenshots/signup.png) | ![login form](./screenshots/signin.png) | ![protected page](./screenshots/secret.png) | +| ![home](./screenshots/homepage.png) | ![duplicate username](./screenshots/duplicate-user.png) | ![signup success](./screenshots/signup-success.png) | + +## How it works + +### User creation + +The [signup form](./signup.sql) is a simple form that is handled by [`create_user.sql`](./create_user.sql). +You could restrict user creation to existing administrators and create an initial administrator in a database migration. + +### User login + +The [login form](./signin.sql) is a simple form that is handled by [`login.sql`](./login.sql). + +`login.sql` checks that the username exists and that the password is correct using the [authentication component](https://sql-page.com/documentation.sql?component=authentication#component) extension with + +```sql +SELECT 'authentication' AS component, + 'signin.sql' AS link, + (SELECT password_hash FROM user_info WHERE username = :username) AS password_hash, + :password AS password; +``` + +If the login is successful, an entry is added to the [`login_session`](./sqlpage/migrations/0000_init.sql) table with a random session id. + +If it is not, the authentication component will redirect the user to the login page and stop the execution of the page. + +The session id is then stored in a cookie on the user's browser. + +The user is then redirected to [`./protected_page.sql`](./protected_page.sql) which will check that the user is logged in. + +### Protected pages + +Protected pages are pages that can only be accessed by logged-in users. + +There is an example in [`protected_page.sql`](./protected_page.sql) that uses +the [`redirect`](https://sql-page.com/documentation.sql?component=redirect#component) +component to redirect the user to the login page if they are not logged in. + +Checking whether the user is logged in is as simple as checking that session id returned by [`sqlpage.cookie('session')`](https://sql-page.com/functions.sql?function=cookie#function) exists in the [`login_session`](./sqlpage/migrations/0000_init.sql) table. + + +### User logout + +The cookie can be deleted in the browser by navigating to [`./logout.sql`](./logout.sql). \ No newline at end of file diff --git a/examples/user-authentication/create_user.sql b/examples/user-authentication/create_user.sql new file mode 100644 index 0000000..e441ed6 --- /dev/null +++ b/examples/user-authentication/create_user.sql @@ -0,0 +1,10 @@ +INSERT INTO user_info (username, password_hash) +VALUES (:username, sqlpage.hash_password(:password)) +ON CONFLICT (username) DO NOTHING +RETURNING + 'redirect' AS component, + 'create_user_welcome_message.sql?username=' || :username AS link; + +-- If we are still here, it means that the user was not created +-- because the username was already taken. +SELECT 'redirect' AS component, 'create_user_welcome_message.sql?error&username=' || :username AS link; diff --git a/examples/user-authentication/create_user_welcome_message.sql b/examples/user-authentication/create_user_welcome_message.sql new file mode 100644 index 0000000..bfb11e5 --- /dev/null +++ b/examples/user-authentication/create_user_welcome_message.sql @@ -0,0 +1,15 @@ +SELECT 'hero' AS component, + 'Welcome' AS title, + 'Welcome, ' || $username || '! Your user account was successfully created. You can now log in.' AS description, + 'https://upload.wikimedia.org/wikipedia/commons/thumb/e/e1/Community_wp20.png/974px-Community_wp20.png' AS image, + 'signin.sql' AS link, + 'Log in' AS link_text +WHERE $error IS NULL; + +SELECT 'hero' AS component, + 'Sorry' AS title, + 'Sorry, the user name "' || $username || '" is already taken.' AS description, + 'https://upload.wikimedia.org/wikipedia/commons/thumb/f/f0/Sad_face_of_a_Wayuu_Woman.jpg/640px-Sad_face_of_a_Wayuu_Woman.jpg' AS image, + 'signup.sql' AS link, + 'Try again' AS link_text +WHERE $error IS NOT NULL; \ No newline at end of file diff --git a/examples/user-authentication/docker-compose.yml b/examples/user-authentication/docker-compose.yml new file mode 100644 index 0000000..b0230f8 --- /dev/null +++ b/examples/user-authentication/docker-compose.yml @@ -0,0 +1,18 @@ +services: + web: + image: lovasoa/sqlpage:main # main is cutting edge, use sqlpage/SQLPage:latest for the latest stable version + ports: + - "8080:8080" + volumes: + - .:/var/www + - ./sqlpage:/etc/sqlpage + depends_on: + - db + environment: + DATABASE_URL: postgres://root:secret@db/sqlpage + db: # The DB environment variable can be set to "mariadb" or "postgres" to test the code with different databases + image: postgres + environment: + POSTGRES_USER: root + POSTGRES_DB: sqlpage + POSTGRES_PASSWORD: secret diff --git a/examples/user-authentication/generate_password_hash.sql b/examples/user-authentication/generate_password_hash.sql new file mode 100644 index 0000000..506cfa9 --- /dev/null +++ b/examples/user-authentication/generate_password_hash.sql @@ -0,0 +1,5 @@ +SELECT 'form' AS component; +SELECT 'password' AS name, 'Password to create a hash for' AS label, :password AS value; + +SELECT 'code' AS component; +SELECT sqlpage.hash_password(:password) AS contents; \ No newline at end of file diff --git a/examples/user-authentication/index.sql b/examples/user-authentication/index.sql new file mode 100644 index 0000000..ced639d --- /dev/null +++ b/examples/user-authentication/index.sql @@ -0,0 +1,15 @@ +SELECT 'shell' AS component, + 'User Management App' AS title, + 'user' AS icon, + '/' AS link, + CASE COALESCE(sqlpage.cookie('session'), '') + WHEN '' THEN '["signin", "signup"]'::json + ELSE '["logout"]'::json + END AS menu_item; + +SELECT 'hero' AS component, + 'SQLPage Authentication Demo' AS title, + 'This application requires signing up to view the protected page.' AS description_md, + 'https://upload.wikimedia.org/wikipedia/commons/thumb/e/e1/Community_wp20.png/974px-Community_wp20.png' AS image, + 'protected_page.sql' AS link, + 'Access protected page' AS link_text; \ No newline at end of file diff --git a/examples/user-authentication/login.sql b/examples/user-authentication/login.sql new file mode 100644 index 0000000..a6b6686 --- /dev/null +++ b/examples/user-authentication/login.sql @@ -0,0 +1,19 @@ +-- The authentication component will stop the execution of the page and redirect the user to the login page if +-- the password is incorrect or if the user does not exist. +SELECT 'authentication' AS component, + 'signin.sql?error' AS link, + (SELECT password_hash FROM user_info WHERE username = :username) AS password_hash, + :password AS password; + +-- Generate a random 32 characters session ID, insert it into the database, +-- and save it in a cookie on the user's browser. +INSERT INTO login_session (id, username) +VALUES (sqlpage.random_string(32), :username) +RETURNING + 'cookie' AS component, + 'session' AS name, + id AS value, + FALSE AS secure; -- You can remove this if the site is served over HTTPS. + +-- Redirect the user to the protected page. +SELECT 'redirect' AS component, 'protected_page.sql' AS link; diff --git a/examples/user-authentication/logout.sql b/examples/user-authentication/logout.sql new file mode 100644 index 0000000..6928565 --- /dev/null +++ b/examples/user-authentication/logout.sql @@ -0,0 +1,4 @@ +DELETE FROM login_session WHERE id = sqlpage.cookie('session'); +SELECT 'cookie' AS component, 'session' AS name, TRUE AS remove; + +SELECT 'redirect' AS component, '/' AS link; \ No newline at end of file diff --git a/examples/user-authentication/protected_page.sql b/examples/user-authentication/protected_page.sql new file mode 100644 index 0000000..31f2a41 --- /dev/null +++ b/examples/user-authentication/protected_page.sql @@ -0,0 +1,12 @@ +SET username = (SELECT username FROM login_session WHERE id = sqlpage.cookie('session')); + +SELECT 'redirect' AS component, + 'signin.sql?error' AS link +WHERE $username IS NULL; + +SELECT 'shell' AS component, 'Protected page' AS title, 'lock' AS icon, '/' AS link, 'logout' AS menu_item; + +SELECT 'text' AS component, + 'Welcome, ' || $username || ' !' AS title, + 'This content is [top secret](https://youtu.be/dQw4w9WgXcQ). + You cannot view it if you are not connected.' AS contents_md; \ No newline at end of file diff --git a/examples/user-authentication/screenshots/duplicate-user.png b/examples/user-authentication/screenshots/duplicate-user.png new file mode 100644 index 0000000..a9180ff Binary files /dev/null and b/examples/user-authentication/screenshots/duplicate-user.png differ diff --git a/examples/user-authentication/screenshots/homepage.png b/examples/user-authentication/screenshots/homepage.png new file mode 100644 index 0000000..e840cac Binary files /dev/null and b/examples/user-authentication/screenshots/homepage.png differ diff --git a/examples/user-authentication/screenshots/secret.png b/examples/user-authentication/screenshots/secret.png new file mode 100644 index 0000000..9e14d99 Binary files /dev/null and b/examples/user-authentication/screenshots/secret.png differ diff --git a/examples/user-authentication/screenshots/signin.png b/examples/user-authentication/screenshots/signin.png new file mode 100644 index 0000000..9e96dc2 Binary files /dev/null and b/examples/user-authentication/screenshots/signin.png differ diff --git a/examples/user-authentication/screenshots/signup-success.png b/examples/user-authentication/screenshots/signup-success.png new file mode 100644 index 0000000..a584103 Binary files /dev/null and b/examples/user-authentication/screenshots/signup-success.png differ diff --git a/examples/user-authentication/screenshots/signup.png b/examples/user-authentication/screenshots/signup.png new file mode 100644 index 0000000..f1f90e6 Binary files /dev/null and b/examples/user-authentication/screenshots/signup.png differ diff --git a/examples/user-authentication/signin.sql b/examples/user-authentication/signin.sql new file mode 100644 index 0000000..bab0e88 --- /dev/null +++ b/examples/user-authentication/signin.sql @@ -0,0 +1,9 @@ +SELECT 'login' AS component, + 'login.sql' AS action, + 'Sign in' AS title, + 'Username' AS username, + 'Password' AS password, + 'user' AS username_icon, + 'lock' AS password_icon, + case when $error is not null then 'We could not authenticate you. Please log in or [create an account](signup.sql).' end as error_message_md, + 'Sign in' AS validate; \ No newline at end of file diff --git a/examples/user-authentication/signup.sql b/examples/user-authentication/signup.sql new file mode 100644 index 0000000..cc05f9e --- /dev/null +++ b/examples/user-authentication/signup.sql @@ -0,0 +1,8 @@ +SELECT 'form' AS component, + 'Create a new user account' AS title, + 'Sign up' AS validate, + 'create_user.sql' AS action; + +SELECT 'username' AS name; +SELECT 'password' AS name, 'password' AS type, '^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$' AS pattern, 'Password must be at least 8 characters long and contain at least one letter and one number.' AS description; +SELECT 'terms' AS name, 'I accept the terms and conditions' AS label, TRUE AS required, FALSE AS value, 'checkbox' AS type; \ No newline at end of file diff --git a/examples/user-authentication/sqlpage/migrations/0000_init.sql b/examples/user-authentication/sqlpage/migrations/0000_init.sql new file mode 100644 index 0000000..34c0f0d --- /dev/null +++ b/examples/user-authentication/sqlpage/migrations/0000_init.sql @@ -0,0 +1,10 @@ +CREATE TABLE user_info ( + username TEXT PRIMARY KEY, + password_hash TEXT NOT NULL +); + +CREATE TABLE login_session ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL REFERENCES user_info(username), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/examples/user-authentication/sqlpage/migrations/0001_create_admin_user.sql b/examples/user-authentication/sqlpage/migrations/0001_create_admin_user.sql new file mode 100644 index 0000000..872cc85 --- /dev/null +++ b/examples/user-authentication/sqlpage/migrations/0001_create_admin_user.sql @@ -0,0 +1,4 @@ +-- Creates an initial user called 'admin' +-- with a password hash that was generated using the 'generate_password_hash.sql' page. +INSERT INTO user_info (username, password_hash) +VALUES ('admin', '$argon2id$v=19$m=19456,t=2,p=1$IiReWDP0ocWvia+fTdozJw$53EozOKX7HkpvOdoWHjsh9yKvRN2TmQm/PjYBeaOqqc'); \ No newline at end of file diff --git a/examples/user-authentication/test.hurl b/examples/user-authentication/test.hurl new file mode 100644 index 0000000..d546018 --- /dev/null +++ b/examples/user-authentication/test.hurl @@ -0,0 +1,59 @@ +GET http://localhost:8080 +HTTP 200 +[Asserts] +body contains "SQLPage Authentication Demo" +body contains "Access protected page" +body not contains "An error occurred" + +GET http://localhost:8080/signin.sql +HTTP 200 +[Asserts] +body contains "Sign in" +body contains "Username" +body contains "Password" + +GET http://localhost:8080/protected_page.sql +HTTP 302 +[Asserts] +header "Location" contains "signin.sql?error" + +GET http://localhost:8080/signin.sql?error +HTTP 200 +[Asserts] +body contains "We could not authenticate you" +body htmlUnescape contains "href=\"signup.sql\"" + +POST http://localhost:8080/create_user.sql +[FormParams] +username: hurl_user +password: Password123 +terms: on +HTTP 302 +[Asserts] +header "Location" contains "create_user_welcome_message.sql?username=hurl_user" + +POST http://localhost:8080/login.sql +[FormParams] +username: hurl_user +password: Password123 +HTTP 302 +[Asserts] +cookie "session" exists +header "Location" contains "protected_page.sql" + +GET http://localhost:8080/protected_page.sql +HTTP 200 +[Asserts] +body contains "Welcome, hurl_user" +body contains "top secret" +body not contains "An error occurred" + +GET http://localhost:8080/logout.sql +HTTP 302 +[Asserts] +header "Location" == "/" + +GET http://localhost:8080/protected_page.sql +HTTP 302 +[Asserts] +header "Location" contains "signin.sql?error" diff --git a/examples/using react and other custom scripts and styles/README.md b/examples/using react and other custom scripts and styles/README.md new file mode 100644 index 0000000..7cda84c --- /dev/null +++ b/examples/using react and other custom scripts and styles/README.md @@ -0,0 +1,23 @@ +# Custom javascript and CSS + +This example shows how to use custom javascript and CSS in your application. +It is compatible with SQLPage v0.7.3 and above. + +It integrates a simple [react](https://reactjs.org/) component and loads it with properties coming from a SQL query. + +## Screenshots + +![example SQLPage application with a custom style](screenshot-css.png) + +![example client-side reactive SQLPage application with React](screenshot-react.png) + +![example physics equations](screenshot-latex-math-equations.png) + + +## Notes + +This example relies on a CDN to load the react library, and the example component is written in plain Javscript, not JSX. + +You can also use include a local copy of react, and write your components in JSX/TSX, +you will simply need to add a build step to your application to compile the JSX/TSX files into plain JS, +and then include that build result in your application. \ No newline at end of file diff --git a/examples/using react and other custom scripts and styles/api.sql b/examples/using react and other custom scripts and styles/api.sql new file mode 100644 index 0000000..2fa8133 --- /dev/null +++ b/examples/using react and other custom scripts and styles/api.sql @@ -0,0 +1,7 @@ +-- Adds a new entry in the clicks table +INSERT INTO clicks(click_time) VALUES (datetime('now')); + +SELECT 'json' AS component, + JSON_OBJECT( + 'total_clicks', (SELECT count(*) FROM clicks) + ) AS contents; \ No newline at end of file diff --git a/examples/using react and other custom scripts and styles/docker-compose.yml b/examples/using react and other custom scripts and styles/docker-compose.yml new file mode 100644 index 0000000..7305eda --- /dev/null +++ b/examples/using react and other custom scripts and styles/docker-compose.yml @@ -0,0 +1,8 @@ +services: + web: + image: lovasoa/sqlpage:main + ports: + - "8080:8080" + volumes: + - .:/var/www + - ./sqlpage:/etc/sqlpage diff --git a/examples/using react and other custom scripts and styles/equations.sql b/examples/using react and other custom scripts and styles/equations.sql new file mode 100644 index 0000000..575faf7 --- /dev/null +++ b/examples/using react and other custom scripts and styles/equations.sql @@ -0,0 +1,32 @@ +select 'shell' as component, + 'Equations' as title, + 'style.css' as css, + 'settings' as icon, + 'https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js' as javascript; + +select 'text' as component, ' +Newton''s laws of motion are three physical laws that describe the relationship between the forces \( \overrightarrow{F} \) acting on a body, +the resulting motion \( \overrightarrow{a} \) of the body, and the body''s mass \( m \). +' as contents; +select + 'card' as component, + 3 as columns; +select + 'Inertia' as title, + 'The natural behavior of a body is to move in a straight line at constant speed \( \overrightarrow{v} \) unless acted upon by a force \( \overrightarrow{F} \).' as description, + TRUE as active, + 'arrow-right' as icon; +select + 'Force' as title, + 'The acceleration \( \overrightarrow{a} \) of a body is directly proportional to the net force \( \overrightarrow{F_{\text{net}}} \) acting on the it, and inversely proportional to its mass \( m \): +\( \overrightarrow{F_{\text{net}}} = m \overrightarrow{a} \), or +\( \sum \overrightarrow F = m \frac{\mathrm d \overrightarrow v }{\mathrm d t} \).' as description, + 'rocket' as icon, + 'red' as color; +select + 'Action and reaction' as title, + 'For every action, there is an equal and opposite reaction. +If body A exerts a force \( \overrightarrow{F_{\text{A on B}}} \) on body B, +then body B exerts a force \( \overrightarrow{F_{\text{B on A}}} = -\overrightarrow{F_{\text{A on B}}} \) on body A.' as description, + 'arrows-exchange' as icon, + 'orange' as color; \ No newline at end of file diff --git a/examples/using react and other custom scripts and styles/index.sql b/examples/using react and other custom scripts and styles/index.sql new file mode 100644 index 0000000..c7159f5 --- /dev/null +++ b/examples/using react and other custom scripts and styles/index.sql @@ -0,0 +1,8 @@ +SELECT 'shell' AS component, + 'SQLPage with a frontend component' as title, + 'style.css' as css, + 'settings' as icon, + 'equations' as menu_item; + +SELECT 'button' AS component, 'center' as justify; +SELECT 'Try my react component !' AS title, 'react.sql' AS link, 'funky_text' AS id; diff --git a/examples/using react and other custom scripts and styles/my_react_component.js b/examples/using react and other custom scripts and styles/my_react_component.js new file mode 100644 index 0000000..5c85f1c --- /dev/null +++ b/examples/using react and other custom scripts and styles/my_react_component.js @@ -0,0 +1,29 @@ +// Here we are using React and ReactDOM directly, but this file could be a compiled +// version of a React component written in JSX. + +function _MyComponent({ greeting_name }) { + const [count, setCount] = React.useState(0); + return React.createElement( + "button", + { + type: "button", + onClick: async () => { + const r = await fetch("/api.sql"); + const { total_clicks } = await r.json(); + setCount(total_clicks); + }, + className: "btn btn-primary", + }, + count === 0 + ? `Hello, ${greeting_name}. Click me !` + : `You clicked me ${count} times!`, + ); +} + +for (const container of document.getElementsByClassName("react_component")) { + const root = ReactDOM.createRoot(container); + const props = JSON.parse(container.dataset.props); + root.render( + React.createElement(window[props.react_component_name], props, null), + ); +} diff --git a/examples/using react and other custom scripts and styles/react.sql b/examples/using react and other custom scripts and styles/react.sql new file mode 100644 index 0000000..6e1da8c --- /dev/null +++ b/examples/using react and other custom scripts and styles/react.sql @@ -0,0 +1,13 @@ +SELECT 'shell' AS component, + 'SQLPage with a frontend component' AS title, + 'settings' AS icon, + + -- Including react from a CDN like that is quick and easy, but if your project grows larger, + -- you might want to use a bundler like webpack, and include your javascript file here instead + 'https://cdn.jsdelivr.net/npm/react@18.2.0/umd/react.production.min.js' AS javascript, + 'https://cdn.jsdelivr.net/npm/react-dom@18.2.0/umd/react-dom.production.min.js' AS javascript, + 'my_react_component.js' AS javascript; + +SELECT 'react_component' AS component, + 'MyComponent' AS react_component_name, + 'World' AS greeting_name; \ No newline at end of file diff --git a/examples/using react and other custom scripts and styles/screenshot-css.png b/examples/using react and other custom scripts and styles/screenshot-css.png new file mode 100644 index 0000000..27bd51f Binary files /dev/null and b/examples/using react and other custom scripts and styles/screenshot-css.png differ diff --git a/examples/using react and other custom scripts and styles/screenshot-latex-math-equations.png b/examples/using react and other custom scripts and styles/screenshot-latex-math-equations.png new file mode 100644 index 0000000..500485c Binary files /dev/null and b/examples/using react and other custom scripts and styles/screenshot-latex-math-equations.png differ diff --git a/examples/using react and other custom scripts and styles/screenshot-math-equations.png b/examples/using react and other custom scripts and styles/screenshot-math-equations.png new file mode 100644 index 0000000..13c772d Binary files /dev/null and b/examples/using react and other custom scripts and styles/screenshot-math-equations.png differ diff --git a/examples/using react and other custom scripts and styles/screenshot-react.png b/examples/using react and other custom scripts and styles/screenshot-react.png new file mode 100644 index 0000000..e89f816 Binary files /dev/null and b/examples/using react and other custom scripts and styles/screenshot-react.png differ diff --git a/examples/using react and other custom scripts and styles/sqlpage/migrations/0001_clicks.sql b/examples/using react and other custom scripts and styles/sqlpage/migrations/0001_clicks.sql new file mode 100644 index 0000000..72ef281 --- /dev/null +++ b/examples/using react and other custom scripts and styles/sqlpage/migrations/0001_clicks.sql @@ -0,0 +1,3 @@ +CREATE TABLE clicks( + click_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); \ No newline at end of file diff --git a/examples/using react and other custom scripts and styles/sqlpage/templates/react_component.handlebars b/examples/using react and other custom scripts and styles/sqlpage/templates/react_component.handlebars new file mode 100644 index 0000000..6e510b7 --- /dev/null +++ b/examples/using react and other custom scripts and styles/sqlpage/templates/react_component.handlebars @@ -0,0 +1,3 @@ +
+ Dynamic component is loading... +
\ No newline at end of file diff --git a/examples/using react and other custom scripts and styles/style.css b/examples/using react and other custom scripts and styles/style.css new file mode 100644 index 0000000..d40294e --- /dev/null +++ b/examples/using react and other custom scripts and styles/style.css @@ -0,0 +1,40 @@ +#funky_text { + font-family: Arial, sans-serif; + font-size: 36px; + color: white; + background: linear-gradient(45deg, #ff4e00, #ec9f05); + padding: 10px; + border-radius: 10px; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3); + transition: transform 0.3s ease-in-out; +} + +#funky_text:hover { + transform: scale(1.1); + background: linear-gradient(45deg, #e5004d, #7a0180); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.5); +} + +@keyframes neon-glow { + 0%, + 100% { + text-shadow: + 0 0 10px #fa2dd1, + 0 0 20px #b30890, + 2px 3px 30px #ff00cc; + } + 50% { + text-shadow: + 0 0 1px #e48fd3, + 0 0 2px #ca28aa, + 0 0 8px #ff00cc; + } +} + +#funky_text:hover { + animation: neon-glow 0.5s ease-in-out infinite; +} + +#funky_text * { + color: inherit; +} diff --git a/examples/using react and other custom scripts and styles/test.hurl b/examples/using react and other custom scripts and styles/test.hurl new file mode 100644 index 0000000..6df8aed --- /dev/null +++ b/examples/using react and other custom scripts and styles/test.hurl @@ -0,0 +1,27 @@ +GET http://localhost:8080/ +HTTP 200 +[Asserts] +body contains "SQLPage with a frontend component" +body contains "Try my react component !" +body contains "style.css" +body contains "funky_text" + +GET http://localhost:8080/style.css +HTTP 200 +[Asserts] +body contains "#funky_text" +body contains "neon-glow" + +GET http://localhost:8080/react.sql +HTTP 200 +[Asserts] +body contains "my_react_component.js" +body contains "react_component" +body contains "Dynamic component is loading..." +body contains "World" + +GET http://localhost:8080/my_react_component.js +HTTP 200 +[Asserts] +body contains "fetch(\"/api.sql\")" +body contains "ReactDOM.createRoot" diff --git a/examples/web servers - apache/README.md b/examples/web servers - apache/README.md new file mode 100644 index 0000000..146c0cf --- /dev/null +++ b/examples/web servers - apache/README.md @@ -0,0 +1,61 @@ +# SQLPage with Apache Reverse Proxy + +This example demonstrates how to run SQLPage behind the popular Apache HTTP Server. +This is particularly useful when you already have a server running Apache (with a PHP application for example) +and you want to add a SQLPage application. + +This setup allows you to: +- Host multiple websites/applications on a single server +- Serve static files directly through Apache +- Route specific paths to SQLPage + +## How it Works + +Apache acts as a reverse proxy, forwarding requests for `/my_website` to the SQLPage +application while serving static content directly. The configuration uses: + +- [`mod_proxy`](https://httpd.apache.org/docs/current/mod/mod_proxy.html) and [`mod_proxy_http`](https://httpd.apache.org/docs/current/mod/mod_proxy_http.html) for reverse proxy functionality +- [Virtual hosts](https://httpd.apache.org/docs/current/vhosts/) for domain-based routing +- [`ProxyPass`](https://httpd.apache.org/docs/current/mod/mod_proxy.html#proxypass) directives to forward specific paths + +## Docker Setup + +The `docker-compose.yml` defines three services: +- `apache`: Serves static content and routes requests +- `sqlpage`: Handles dynamic content generation +- `mysql`: Provides database storage + +## Native Apache Setup + +To use this with a native Apache installation instead of Docker: + +1. Install Apache and required modules: +```bash +sudo apt install apache2 +sudo a2enmod proxy proxy_http +``` + +2. Configuration changes: +- Place the `httpd.conf` content in `/etc/apache2/sites-available/my-site.conf` +- Adjust paths: + - Change `/var/www` to your static files location + - Update SQLPage URL to match your actual SQLPage server address (`http://localhost:8080/my_website` if you are running sqlpage locally) + - Modify log paths to standard Apache locations (`/var/log/apache2/`) + +3. SQLPage setup: +- Install SQLPage on your server +- Configure it with the same `site_prefix` in `sqlpage.json` +- Ensure MySQL is accessible from the SQLPage instance + +4. Enable the site: +```bash +sudo a2ensite my-site +sudo systemctl reload apache2 +``` + +## Files Overview + +- `httpd.conf`: Apache configuration with proxy rules +- `sqlpage_config/sqlpage.json`: SQLPage configuration with URL prefix +- `static/`: Static files served directly by Apache +- `website/`: SQLPage SQL files for dynamic content diff --git a/examples/web servers - apache/apache/httpd.conf b/examples/web servers - apache/apache/httpd.conf new file mode 100644 index 0000000..fa659b6 --- /dev/null +++ b/examples/web servers - apache/apache/httpd.conf @@ -0,0 +1,39 @@ +LoadModule mpm_prefork_module modules/mod_mpm_prefork.so +LoadModule proxy_module modules/mod_proxy.so +LoadModule proxy_http_module modules/mod_proxy_http.so +LoadModule unixd_module modules/mod_unixd.so +LoadModule log_config_module modules/mod_log_config.so +LoadModule authz_core_module modules/mod_authz_core.so +LoadModule dir_module modules/mod_dir.so + + + User daemon + Group daemon + + +ServerName localhost +Listen 80 + +DirectoryIndex index.html + +ErrorLog /proc/self/fd/2 +LogLevel warn +CustomLog /proc/self/fd/1 combined + + + ServerName my_website + DocumentRoot "/var/www" + + ProxyPreserveHost On + + + Require all granted + Options Indexes FollowSymLinks + AllowOverride None + + + + ProxyPass "http://sqlpage:8080/my_website" + ProxyPassReverse "http://sqlpage:8080/my_website" + + \ No newline at end of file diff --git a/examples/web servers - apache/docker-compose.yml b/examples/web servers - apache/docker-compose.yml new file mode 100644 index 0000000..94d3712 --- /dev/null +++ b/examples/web servers - apache/docker-compose.yml @@ -0,0 +1,33 @@ +services: + sqlpage: + image: lovasoa/sqlpage:main + volumes: + - ./sqlpage_config:/etc/sqlpage:ro + - ./website:/var/www:ro + environment: + - DATABASE_URL=mysql://sqlpage:sqlpage_password@mysql:3306/sqlpage_db + depends_on: + - mysql + + apache: + image: httpd:2.4 + ports: + - "8080:80" + volumes: + - ./apache/httpd.conf:/usr/local/apache2/conf/httpd.conf:ro + - ./static:/var/www:ro + depends_on: + - sqlpage + + mysql: + image: mysql:8 + environment: + - MYSQL_ROOT_PASSWORD=root_password + - MYSQL_DATABASE=sqlpage_db + - MYSQL_USER=sqlpage + - MYSQL_PASSWORD=sqlpage_password + volumes: + - mysql_data:/var/lib/mysql + +volumes: + mysql_data: diff --git a/examples/web servers - apache/sqlpage_config/sqlpage.json b/examples/web servers - apache/sqlpage_config/sqlpage.json new file mode 100644 index 0000000..76f13c2 --- /dev/null +++ b/examples/web servers - apache/sqlpage_config/sqlpage.json @@ -0,0 +1,3 @@ +{ + "site_prefix": "/my_website" +} diff --git a/examples/web servers - apache/static/index.html b/examples/web servers - apache/static/index.html new file mode 100644 index 0000000..3197077 --- /dev/null +++ b/examples/web servers - apache/static/index.html @@ -0,0 +1,42 @@ + + + + + + Welcome + + + +

Welcome to Our Site

+

+ This page is served by Apache web server, which acts as a reverse proxy. When you click the button below, + you'll be redirected to a SQLPage application running in a separate container. The setup includes three + services: Apache for static content and routing, SQLPage for dynamic content, and MySQL for data storage. +

+ Enter Site + + diff --git a/examples/web servers - apache/test.hurl b/examples/web servers - apache/test.hurl new file mode 100644 index 0000000..5029dc0 --- /dev/null +++ b/examples/web servers - apache/test.hurl @@ -0,0 +1,20 @@ +GET http://localhost:8080/ +HTTP 200 +[Asserts] +body contains "Welcome to Our Site" +body contains "served by Apache web server" +body contains "href=\"/my_website\"" + +GET http://localhost:8080/my_website +HTTP 301 +[Asserts] +header "Location" == "/my_website/" + +GET http://localhost:8080/my_website/ +HTTP 200 +[Asserts] +header "Content-Type" contains "text/html" +body contains "Welcome to my website" +body contains "Using SQLPage v" +body contains "Connected to" +body contains "MySQL" diff --git a/examples/web servers - apache/website/index.sql b/examples/web servers - apache/website/index.sql new file mode 100644 index 0000000..d3d9438 --- /dev/null +++ b/examples/web servers - apache/website/index.sql @@ -0,0 +1,9 @@ +select + 'text' as component, + true as article, + ' +# Welcome to my website + +Using SQLPage v' || sqlpage.version() || ' + +Connected to **MySQL** v' || version () as contents_md; diff --git a/lambda.Dockerfile b/lambda.Dockerfile new file mode 100644 index 0000000..da5c502 --- /dev/null +++ b/lambda.Dockerfile @@ -0,0 +1,18 @@ +FROM rust:1.95-alpine AS builder +RUN rustup component add clippy rustfmt +RUN apk add --no-cache musl-dev zip +WORKDIR /usr/src/sqlpage +RUN cargo init . +COPY Cargo.toml Cargo.lock ./ +RUN cargo build --release +COPY . . +RUN cargo build --release --features lambda-web +RUN mv target/release/sqlpage bootstrap && \ + strip --strip-all bootstrap && \ + size bootstrap && \ + zip -9 -r deploy.zip bootstrap index.sql + +FROM public.ecr.aws/lambda/provided:al2 AS runner +COPY --from=builder /usr/src/sqlpage/bootstrap /main +COPY --from=builder /usr/src/sqlpage/index.sql ./index.sql +ENTRYPOINT ["/main"] diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..1826ec9 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,191 @@ +{ + "name": "sqlpage", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sqlpage", + "version": "1.0.0", + "license": "MIT", + "devDependencies": { + "@biomejs/biome": "^2.4.16" + } + }, + "node_modules/@biomejs/biome": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.16.tgz", + "integrity": "sha512-x9ajFh1zChVybCiM3TN6OD4phAqLgtPZjFrZF+aTMYCPjwBO+k529TX7PPsAqtGNLeV4UgzwQnowEgS7bGmzcA==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.4.16", + "@biomejs/cli-darwin-x64": "2.4.16", + "@biomejs/cli-linux-arm64": "2.4.16", + "@biomejs/cli-linux-arm64-musl": "2.4.16", + "@biomejs/cli-linux-x64": "2.4.16", + "@biomejs/cli-linux-x64-musl": "2.4.16", + "@biomejs/cli-win32-arm64": "2.4.16", + "@biomejs/cli-win32-x64": "2.4.16" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.16.tgz", + "integrity": "sha512-wxPvu4XOA85YJk9ixSWUmq/QBHbid85BISbOAqqBM/5xQpPk9ayjk5375tOlSC0BeCwNSbPFafQBm+vBumXq0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.16.tgz", + "integrity": "sha512-xFCqGPwYusQJp4N4NJLi1XJiZqjwFdjhT+KqtNy+Ug3qgfczqnTa6MSDvxJF6TkuDLoYJItMapz6tAf7kCekFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.16.tgz", + "integrity": "sha512-2kFb4//jxfZaP6D+Rj5VkHkxgyD9EoRAVBEQb8PKRv+s4NO2zYNJKXFaJmK1CmhufJOWEfpHKaRbOja7qjmdhQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.16.tgz", + "integrity": "sha512-oYxnW0ARfJkr72ezzF2OR8N/rtkgLUQeYtF8cFhVswbknHxtTcmzSsanVJP8yQKnGpGpc2ck6c5zLvHahL6Cbg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.16.tgz", + "integrity": "sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.16.tgz", + "integrity": "sha512-iHDS+MCM65DPqWGu+ECC3uoALyj2H7F4nVUPxIPjz/PIl94EUu+EDfGZDzFP+NY1EOPVt9NQvwFqq7HdMmowdg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.16.tgz", + "integrity": "sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.16.tgz", + "integrity": "sha512-Kp85jgoBHa05gix6UIRjfCDiUV3w/8VIdZ247VyyO2gEjaw12WEVhdIjlxp/AMzXxqxQwbxNTDVZ3Mwd2RG5rw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..af5caef --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "sqlpage", + "version": "1.0.0", + "scripts": { + "test": "biome check .", + "format": "biome format --write .", + "fix": "biome check --fix --unsafe ." + }, + "repository": { + "type": "git", + "url": "git+https://github.com/sqlpage/SQLPage.git" + }, + "license": "MIT", + "devDependencies": { + "@biomejs/biome": "^2.4.16" + } +} diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..59986f4 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,20 @@ +# Docker Build Scripts + +This directory contains scripts used by the Dockerfile to build SQLPage with cross-compilation support. + +## Scripts + +- **`setup-cross-compilation.sh`**: Sets up the cross-compilation environment based on target and build architectures. Handles system dependencies, cross-compiler installation, and libgcc extraction for runtime. +- **`build-dependencies.sh`**: Builds only the project dependencies for Docker layer caching +- **`build-project.sh`**: Builds the final SQLPage binary + +## Usage + +These scripts are automatically copied and executed by the Dockerfile during the build process. They handle: + +- Cross-compilation setup for different architectures (amd64, arm64, arm) +- System dependencies installation +- Cargo build configuration with appropriate linkers +- Library extraction for runtime + +The scripts use temporary files in `/tmp/` to pass configuration between stages and export environment variables for use in subsequent build steps. diff --git a/scripts/build-dependencies.sh b/scripts/build-dependencies.sh new file mode 100755 index 0000000..af84543 --- /dev/null +++ b/scripts/build-dependencies.sh @@ -0,0 +1,13 @@ +#!/bin/bash +set -euo pipefail + +source /tmp/build-env.sh + +PROFILE="${CARGO_PROFILE:-superoptimized}" +echo "Building dependencies for target: $TARGET (profile: $PROFILE)" + +cargo build \ + --target "$TARGET" \ + --config "target.$TARGET.linker=\"$LINKER\"" \ + --features odbc-static \ + --profile "$PROFILE" diff --git a/scripts/build-project.sh b/scripts/build-project.sh new file mode 100755 index 0000000..f9a8781 --- /dev/null +++ b/scripts/build-project.sh @@ -0,0 +1,15 @@ +#!/bin/bash +set -euo pipefail + +source /tmp/build-env.sh + +PROFILE="${CARGO_PROFILE:-superoptimized}" +echo "Building project for target: $TARGET (profile: $PROFILE)" + +cargo build \ + --target "$TARGET" \ + --config "target.$TARGET.linker=\"$LINKER\"" \ + --features odbc-static \ + --profile "$PROFILE" + +mv "target/$TARGET/$PROFILE/sqlpage" sqlpage.bin diff --git a/scripts/install-duckdb-odbc.sh b/scripts/install-duckdb-odbc.sh new file mode 100755 index 0000000..81fc29b --- /dev/null +++ b/scripts/install-duckdb-odbc.sh @@ -0,0 +1,36 @@ +#!/bin/bash +set -eux + +TARGETARCH="${1:-amd64}" +DUCKDB_VERSION="${2:-v1.5.3.0}" + +# Determine the correct DuckDB ODBC package for the architecture +case "$TARGETARCH" in + amd64) odbc_zip="duckdb_odbc-linux-amd64.zip" ;; + arm64) odbc_zip="duckdb_odbc-linux-arm64.zip" ;; + *) echo "Unsupported TARGETARCH: $TARGETARCH" >&2; exit 1 ;; +esac + +# Download and install DuckDB ODBC driver +curl -fsSL -o /tmp/duckdb_odbc.zip "https://github.com/duckdb/duckdb-odbc/releases/download/${DUCKDB_VERSION}/${odbc_zip}" +mkdir -p /opt/duckdb_odbc +unzip /tmp/duckdb_odbc.zip -d /opt/duckdb_odbc +rm /tmp/duckdb_odbc.zip + +# Configure ODBC driver in odbcinst.ini +cat >> /etc/odbcinst.ini << EOF + +[DuckDB] +Description=DuckDB ODBC Driver +Driver=/opt/duckdb_odbc/libduckdb_odbc.so +Setup=/opt/duckdb_odbc/libduckdb_odbc.so +UsageCount=1 +EOF + +# Configure default DuckDB data source in odbc.ini +cat >> /etc/odbc.ini << EOF + +[DuckDB] +Driver=DuckDB +Database=/var/lib/sqlpage/duckdb.db +EOF diff --git a/scripts/predownload-offline-deps.sh b/scripts/predownload-offline-deps.sh new file mode 100755 index 0000000..a74ebd3 --- /dev/null +++ b/scripts/predownload-offline-deps.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/.." + +cache_dir="target/sqlpage_artefacts" +mkdir -p "$cache_dir" + +echo "[1/3] Prefetching Cargo dependencies" +cargo fetch --locked + +echo "[2/3] Prefetching npm dependencies" +npm ci --ignore-scripts --no-audit +if [ -f tests/end-to-end/package-lock.json ]; then + ( + cd tests/end-to-end + npm ci --ignore-scripts --no-audit + ) +fi + +echo "[3/3] Prefetching raw HTTP assets used by build.rs" +mapfile -t urls < <( + { + rg -o --no-filename 'https://[[:alnum:]][[:alnum:].-]*/[^" )]+' build.rs + rg -o --no-filename '^/\* !include https://[^ ]+' sqlpage/*.css sqlpage/*.js sqlpage/*.svg \ + | sed 's#^/\* !include ##' + } | sort -u +) + +for url in "${urls[@]}"; do + file_name=$(printf '%s' "$url" | sed -E 's/[^[:alnum:].-]/_/g') + cache_file="$cache_dir/$file_name" + + if [ -s "$cache_file" ]; then + echo " - Cached: $url" + continue + fi + + echo " - Downloading: $url" + curl -fsSL "$url" -o "$cache_file" +done + +echo "Done. Offline caches are ready." diff --git a/scripts/setup-cross-compilation.sh b/scripts/setup-cross-compilation.sh new file mode 100755 index 0000000..83531ab --- /dev/null +++ b/scripts/setup-cross-compilation.sh @@ -0,0 +1,43 @@ +#!/bin/bash +set -euo pipefail + +TARGETARCH="$1" +BUILDARCH="$2" +BINDGEN_EXTRA_CLANG_ARGS="" + +apt-get update + +if [ "$TARGETARCH" = "$BUILDARCH" ]; then + TARGET="$(rustup target list --installed | head -n1)" + LINKER="gcc" + apt-get install -y gcc libgcc-s1 make + LIBDIR="/lib/$(gcc -print-multiarch)" +elif [ "$TARGETARCH" = "arm64" ]; then + TARGET="aarch64-unknown-linux-gnu" + LINKER="aarch64-linux-gnu-gcc" + apt-get install -y gcc-aarch64-linux-gnu libgcc-s1-arm64-cross make + LIBDIR="/usr/aarch64-linux-gnu/lib" +elif [ "$TARGETARCH" = "arm" ]; then + TARGET="armv7-unknown-linux-gnueabihf" + LINKER="arm-linux-gnueabihf-gcc" + apt-get install -y gcc-arm-linux-gnueabihf libgcc-s1-armhf-cross make cmake libclang-dev + cargo install --force --locked bindgen-cli + SYSROOT=$(arm-linux-gnueabihf-gcc -print-sysroot) + BINDGEN_EXTRA_CLANG_ARGS="--sysroot=$SYSROOT -I$SYSROOT/usr/include -I$SYSROOT/usr/include/arm-linux-gnueabihf" + LIBDIR="/usr/arm-linux-gnueabihf/lib" +else + echo "Unsupported cross compilation target: $TARGETARCH" + exit 1 +fi + +mkdir -p /tmp/sqlpage-libs +cp "$LIBDIR/libgcc_s.so.1" /tmp/sqlpage-libs/ +rustup target add "$TARGET" + +{ + echo "export TARGET='$TARGET'" + echo "export LINKER='$LINKER'" + if [ -n "$BINDGEN_EXTRA_CLANG_ARGS" ]; then + printf "export BINDGEN_EXTRA_CLANG_ARGS=%q\n" "$BINDGEN_EXTRA_CLANG_ARGS" + fi +} > /tmp/build-env.sh diff --git a/scripts/setup-sqlpage-user.sh b/scripts/setup-sqlpage-user.sh new file mode 100755 index 0000000..2977adf --- /dev/null +++ b/scripts/setup-sqlpage-user.sh @@ -0,0 +1,10 @@ +#!/bin/bash +set -eux + +# Create sqlpage user and group +addgroup --gid 1000 --system sqlpage +adduser --uid 1000 --system --no-create-home --ingroup sqlpage sqlpage + +# Create and configure directories +mkdir -p /etc/sqlpage /var/lib/sqlpage /var/www +chown -R sqlpage:sqlpage /etc/sqlpage /var/lib/sqlpage /var/www diff --git a/scripts/test-examples-hurl.sh b/scripts/test-examples-hurl.sh new file mode 100755 index 0000000..90c1f20 --- /dev/null +++ b/scripts/test-examples-hurl.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +FILTER="${1:-}" + +if ! command -v hurl >/dev/null 2>&1; then + echo "hurl is required. See https://hurl.dev/docs/installation.html" >&2 + exit 1 +fi + +current_project="" +current_compose="" + +cleanup() { + if [[ -n "$current_project" && -n "$current_compose" ]]; then + docker compose -p "$current_project" -f "$current_compose" down --volumes --remove-orphans >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT + +run_index=0 +tested=0 +while IFS= read -r -d "" test_file; do + dir="$(dirname "$test_file")" + rel_dir="${dir#"$ROOT_DIR/"}" + + if [[ -n "$FILTER" ]]; then + if [[ "$FILTER" == */* ]]; then + [[ "$rel_dir" == "$FILTER" ]] || continue + else + [[ "$rel_dir" == *"$FILTER"* ]] || continue + fi + fi + + run_index=$((run_index + 1)) + tested=$((tested + 1)) + current_project="sqlpage_example_${run_index}" + current_compose="$dir/docker-compose.yml" + + echo "::group::Testing $rel_dir" + if ! docker compose -p "$current_project" -f "$current_compose" up -d --quiet-pull --build --remove-orphans; then + echo "::error file=$rel_dir/docker-compose.yml,title=Example compose startup failed::$rel_dir failed to start" + echo "::group::docker compose ps for $rel_dir" + docker compose -p "$current_project" -f "$current_compose" ps -a + echo "::endgroup::" + echo "::group::docker compose logs for $rel_dir" + docker compose -p "$current_project" -f "$current_compose" logs + echo "::endgroup::" + echo "::endgroup::" + exit 1 + fi + if ! hurl --test \ + --retry 60 \ + --retry-interval 1s \ + --connect-timeout 2s \ + --error-format long \ + "$test_file"; then + echo "::error file=$rel_dir/test.hurl,title=Hurl example test failed::$rel_dir failed" + echo "::group::docker compose ps for $rel_dir" + docker compose -p "$current_project" -f "$current_compose" ps + echo "::endgroup::" + echo "::group::docker compose logs for $rel_dir" + docker compose -p "$current_project" -f "$current_compose" logs + echo "::endgroup::" + echo "::endgroup::" + exit 1 + fi + docker compose -p "$current_project" -f "$current_compose" down --volumes --remove-orphans + echo "::endgroup::" + + current_project="" + current_compose="" +done < <(find "$ROOT_DIR/examples" -mindepth 2 -maxdepth 2 -name test.hurl -print0 | sort -z) + +if [[ "$tested" -eq 0 ]]; then + echo "No examples matched filter: $FILTER" >&2 + exit 1 +fi diff --git a/sqlpage.service b/sqlpage.service new file mode 100644 index 0000000..ea65deb --- /dev/null +++ b/sqlpage.service @@ -0,0 +1,51 @@ +# This is a basic systemd service file for SQLPage +# For more information about systemd service files, see https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html +# Put this file in /etc/systemd/system/sqlpage.service + +[Unit] +Description=SQLPage website +Documentation=https://sql-page.com +After=network.target + +[Service] +# Define the user and group to run the service +User=sqlpage +Group=sqlpage + +# Set the working directory and the executable path +WorkingDirectory=/var/www/sqlpage +ExecStart=/usr/local/bin/sqlpage.bin + +# Environment variables +Environment="RUST_LOG=info" +Environment="LISTEN_ON=0.0.0.0:80" + +# Allow binding to port 80 +AmbientCapabilities=CAP_NET_BIND_SERVICE + +# Restart options +Restart=always +RestartSec=10 + +# Logging options +#StandardOutput=syslog +#StandardError=syslog +SyslogIdentifier=sqlpage + +# Security options +NoNewPrivileges=true +ProtectSystem=full +PrivateTmp=true +ProtectControlGroups=true +ProtectKernelModules=true +ProtectKernelTunables=true +ProtectClock=true +ProtectHostname=true +ProtectProc=invisible +ProtectClock=true + +# Resource limits +LimitNOFILE=65536 + +[Install] +WantedBy=multi-user.target diff --git a/sqlpage/apexcharts.js b/sqlpage/apexcharts.js new file mode 100644 index 0000000..f4fc1bc --- /dev/null +++ b/sqlpage/apexcharts.js @@ -0,0 +1,306 @@ +/* !include https://cdn.jsdelivr.net/npm/apexcharts@5.13.0/dist/apexcharts.min.js */ + +sqlpage_chart = (() => { + function sqlpage_chart() { + for (const c of document.querySelectorAll("[data-pre-init=chart]")) { + try { + build_sqlpage_chart(c); + } catch (e) { + console.error(e); + } + } + } + + const tblrColors = [ + ["blue", "#1c7ed6", "#339af0"], + ["red", "#f03e3e", "#ff6b6b"], + ["green", "#37b24d", "#51cf66"], + ["pink", "#d6336c", "#f06595"], + ["purple", "#ae3ec9", "#cc5de8"], + ["orange", "#f76707", "#ff922b"], + ["cyan", "#1098ad", "#22b8cf"], + ["teal", "#0ca678", "#20c997"], + ["yellow", "#f59f00", "#fcc419"], + ["indigo", "#4263eb", "#5c7cfa"], + ["lime", "#74b816", "#94d82d"], + ["azure", "#339af0", "#339af0"], + ["gray", "#495057", "#adb5bd"], + ["black", "#000000", "#000000"], + ["white", "#ffffff", "#f8f9fa"], + ]; + const colorNames = Object.fromEntries( + tblrColors.flatMap(([name, dark, light]) => [ + [name, dark], + [`${name}-lt`, light], + ]), + ); + const isDarkTheme = document.body?.dataset?.bsTheme === "dark"; + + /** @typedef { { [name:string]: {data:{x:number|string|Date,y:number}[], name:string} } } Series */ + + /** + * Aligns series data points by their x-axis categories, ensuring all series have data points + * for each unique category. Missing values are filled with zeros. + * Categories are ordered by their name. + * + * @example + * // Input series: + * const series = [ + * { name: "A", data: [{x: "X2", y: 10}, {x: "X3", y: 30}] }, + * { name: "B", data: [{x: "X1", y: 25}, {x: "X2", y: 20}] } + * ]; + * + * // Output after align_categories (orderedCategories will be ["X1","X2", "X3"]): + * // [ + * // { name: "A", data: [{x: "X1", y: 0}, {x: "X2", y: 10}, {x: "X3", y: 30}] }, + * // { name: "B", data: [{x: "X1", y: 25}, {x: "X2", y: 20}, {x: "X3", y: 0}] } + * // ] + * + * @param {(Series[string])[]} series - Array of series objects, each containing name and data points + * @returns {Series[string][]} Aligned series with consistent categories across all series + */ + function align_categories(series) { + const categoriesSet = new Set(); + const pointers = series.map((_) => 0); // Index of current data point in each series + const x_at = (series_idx) => + series[series_idx].data[pointers[series_idx]].x; + const series_idxs = series.flatMap((s, i) => (s.data.length ? i : [])); + while (series_idxs.length > 0) { + let idx_of_xmin = series_idxs[0]; + for (const series_idx of series_idxs) { + if (x_at(series_idx) < x_at(idx_of_xmin)) idx_of_xmin = series_idx; + } + + const new_category = x_at(idx_of_xmin); + if (!categoriesSet.has(new_category)) categoriesSet.add(new_category); + pointers[idx_of_xmin]++; + if (pointers[idx_of_xmin] >= series[idx_of_xmin].data.length) { + series_idxs.splice(series_idxs.indexOf(idx_of_xmin), 1); + } + } + // Create a map of category -> value for each series and rebuild + return series.map((s) => { + const valueMap = new Map(s.data.map((point) => [point.x, point.y])); + return { + name: s.name, + data: Array.from(categoriesSet, (category) => ({ + x: category, + y: valueMap.get(category) || 0, + })), + }; + }); + } + + /** @param {HTMLElement} c */ + function build_sqlpage_chart(c) { + const [data_element] = c.getElementsByTagName("data"); + const data = JSON.parse(data_element.textContent); + const chartContainer = c.querySelector(".chart"); + chartContainer.innerHTML = ""; + const is_timeseries = !!data.time; + /** @type { Series } */ + const series_map = {}; + for (const [name, old_x, old_y, z] of data.points) { + series_map[name] = series_map[name] || { name, data: [] }; + let x = old_x; + let y = old_y; + if (is_timeseries) { + if (typeof x === "number") x = new Date(x * 1000); + else if (data.type === "rangeBar" && Array.isArray(y)) + y = y.map((y) => new Date(y).getTime()); + else x = new Date(x); + } + series_map[name].data.push({ x, y, z }); + } + if (data.xmin == null) data.xmin = undefined; + if (data.xmax == null) data.xmax = undefined; + if (data.ymin == null) data.ymin = undefined; + if (data.ymax == null) data.ymax = undefined; + + const colors = [ + ...data.colors.filter((c) => c).map((c) => colorNames[c]), + ...tblrColors.map(([_, dark, light]) => (isDarkTheme ? dark : light)), + ...tblrColors.map(([_, dark, light]) => (isDarkTheme ? light : dark)), + ]; + + let series = Object.values(series_map); + + let labels; + const categories = + series.length > 0 && typeof series[0].data[0].x === "string"; + if (data.type === "pie") { + labels = data.points.map(([name, x, _y]) => x || name); + series = data.points.map(([_name, _x, y]) => Number.parseFloat(y)); + } else if (categories && data.type === "bar" && series.length > 1) + series = align_categories(series); + + const chart_type = data.type || "line"; + const options = { + chart: { + type: chart_type, + fontFamily: "inherit", + background: "transparent", + parentHeightOffset: 0, + height: chartContainer.style.height, + stacked: !!data.stacked, + toolbar: { + show: !!data.toolbar, + }, + animations: { + enabled: false, + }, + zoom: { + enabled: false, + }, + }, + theme: { + mode: isDarkTheme ? "dark" : "light", + palette: "palette4", + }, + legend: { + show: data.show_legend === null || !!data.show_legend, + }, + dataLabels: { + enabled: !!data.labels, + dropShadow: { + enabled: true, + color: "var(--tblr-primary-bg-subtle)", + }, + formatter: + data.type === "rangeBar" + ? (_val, { seriesIndex, w }) => w.config.series[seriesIndex].name + : data.type === "pie" + ? (value, { seriesIndex, w }) => + `${w.config.labels[seriesIndex]}: ${value.toFixed()}%` + : (value) => value?.toLocaleString?.() || value, + }, + fill: { + type: data.type === "area" ? "gradient" : "solid", + }, + stroke: { + width: + { + area: 3, + line: 2, + }[chart_type] || 0, + lineCap: "round", + curve: "smooth", + }, + xaxis: { + tooltip: { + enabled: false, + }, + min: data.xmin, + max: data.xmax, + title: { + text: data.xtitle || undefined, + }, + type: is_timeseries ? "datetime" : categories ? "category" : undefined, + labels: { + datetimeUTC: false, + }, + }, + yaxis: { + logarithmic: !!data.logarithmic, + min: data.ymin, + max: data.ymax, + stepSize: data.ystep, + tickAmount: data.yticks, + title: { + text: data.ytitle || undefined, + }, + }, + zaxis: { + title: { + text: data.ztitle || undefined, + }, + }, + markers: { + size: data.marker || 0, + strokeWidth: 0, + hover: { + sizeOffset: 5, + }, + }, + tooltip: { + fillSeriesColor: false, + custom: + data.type === "bubble" || data.type === "scatter" + ? bubbleTooltip + : undefined, + y: { + formatter: (value) => { + if (value == null) return ""; + if (is_timeseries && data.type === "rangeBar") { + const d = new Date(value); + if (d.getHours() === 0 && d.getMinutes() === 0) + return d.toLocaleDateString(); + return d.toLocaleString(); + } + const str_val = value.toLocaleString(); + if (str_val.length > 10 && Number.isNaN(value)) + return value.toFixed(2); + return str_val; + }, + }, + }, + plotOptions: { + bar: { + horizontal: !!data.horizontal || data.type === "rangeBar", + borderRadius: 5, + }, + bubble: { minBubbleRadius: 5 }, + }, + colors, + series, + }; + if (labels) options.labels = labels; + // tickamount is the number of intervals, not the number of ticks + if (data.xticks) options.xaxis.tickAmount = data.xticks; + console.log("Rendering chart", options); + const chart = new ApexCharts(chartContainer, options); + chart.render(); + if (window.charts) window.charts.push(chart); + else window.charts = [chart]; + c.removeAttribute("data-pre-init"); + } + + function bubbleTooltip({ seriesIndex, dataPointIndex, w }) { + const { name, data } = w.config.series[seriesIndex]; + const point = data[dataPointIndex]; + + const tooltip = document.createElement("div"); + tooltip.className = "apexcharts-tooltip-text"; + tooltip.style.fontFamily = "inherit"; + + const seriesName = document.createElement("div"); + seriesName.className = "apexcharts-tooltip-y-group"; + seriesName.style.fontWeight = "bold"; + seriesName.innerText = name; + tooltip.appendChild(seriesName); + + for (const axis of ["x", "y", "z"]) { + const value = point[axis]; + if (value == null) continue; + const axisValue = document.createElement("div"); + axisValue.className = "apexcharts-tooltip-y-group"; + let axis_conf = w.config[`${axis}axis`]; + if (axis_conf.length) axis_conf = axis_conf[0]; + const title = axis_conf.title.text || axis; + const labelSpan = document.createElement("span"); + labelSpan.className = "apexcharts-tooltip-text-y-label"; + labelSpan.innerText = `${title}: `; + axisValue.appendChild(labelSpan); + const valueSpan = document.createElement("span"); + valueSpan.className = "apexcharts-tooltip-text-y-value"; + valueSpan.innerText = value; + axisValue.appendChild(valueSpan); + tooltip.appendChild(axisValue); + } + return tooltip.outerHTML; + } + + return sqlpage_chart; +})(); + +add_init_fn(sqlpage_chart); diff --git a/sqlpage/favicon.svg b/sqlpage/favicon.svg new file mode 100644 index 0000000..9c67faf --- /dev/null +++ b/sqlpage/favicon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/sqlpage/migrations/README.md b/sqlpage/migrations/README.md new file mode 100644 index 0000000..9d2765f --- /dev/null +++ b/sqlpage/migrations/README.md @@ -0,0 +1,47 @@ +# SQLPage migrations + +SQLPage migrations are SQL scripts that you can use to create or update the database schema. +They are entirely optional: you can use SQLPage without them, and manage the database schema yourself with other tools. + +If you are new to SQL migrations, please read our [**introduction to database migrations**](https://sql-page.com/your-first-sql-website/migrations.sql). + +## Creating a migration + +To create a migration, create a file in the `sqlpage/migrations` directory with the following name: + +``` +_.sql +``` + +Where `` is a number that represents the version of the migration, and `` is a name for the migration. +For example, `001_initial.sql` or `002_add_users.sql`. + +When you need to update the database schema, always create a **new** migration file with a new version number +that is greater than the previous one. +Use commands like `ALTER TABLE` to update the schema declaratively instead of modifying the existing `CREATE TABLE` +statements. + +If you try to edit an existing migration, SQLPage will not run it again, it will detect that the migration has already executed. Also, if the migration is different than the one that was executed, SQLPage will throw an error as the database structure must match. + +## Creating migrations on the command line + +You can create a migration directly with sqlpage by running the command `sqlpage create-migration [migration_name]` + +For example if you run `sqlpage create-migration "Example Migration 1"` on the command line, you will find a new file under the `sqlpage/migrations` folder called `[timestamp]_example_migration_1.sql` where timestamp is the current time when you ran the command. + +## Running migrations + +Migrations that need to be applied are run automatically when SQLPage starts. +You need to restart SQLPage each time you create a new migration. + +## How does it work? + +SQLPage keeps track of the migrations that have been applied in a table called `_sqlx_migrations`. +This table is created automatically when SQLPage starts for the first time, if you create migration files. +If you don't create any migration files, SQLPage will never touch the database schema on its own. + +When SQLPage starts, it checks the `_sqlx_migrations` table to see which migrations have been applied. +It checks the `sqlpage/migrations` directory to see which migrations are available. +If the checksum of a migration file is different from the checksum of the migration that has been applied, +SQLPage will return an error and refuse to start. +If you end up in this situation, you can remove the `_sqlx_migrations` table: all your old migrations will be reapplied, and SQLPage will start again. diff --git a/sqlpage/private_cache_bypass_test.sql b/sqlpage/private_cache_bypass_test.sql new file mode 100644 index 0000000..603f25f --- /dev/null +++ b/sqlpage/private_cache_bypass_test.sql @@ -0,0 +1 @@ +select 'text' as component, 'private cache bypass secret' as contents; diff --git a/sqlpage/sqlpage.css b/sqlpage/sqlpage.css new file mode 100644 index 0000000..f0aabf0 --- /dev/null +++ b/sqlpage/sqlpage.css @@ -0,0 +1,193 @@ +/* !include https://cdn.jsdelivr.net/npm/@tabler/core@1.4.0/dist/css/tabler.min.css */ +/* !include https://cdn.jsdelivr.net/npm/tom-select@2.6.1/dist/css/tom-select.bootstrap5.css */ +/* !include https://cdn.jsdelivr.net/npm/@tabler/core@1.4.0/dist/css/tabler-vendors.min.css */ + +.navbar { + /* https://github.com/sqlpage/SQLPage/issues/822 */ + --tblr-navbar-color: rgba(var(--tblr-body-color-rgb), 0.8); +} + +[data-bs-theme="dark"] .alert:not(.alert-important) { + /* See https://github.com/tabler/tabler/issues/1607 */ + background-color: var(--tblr-bg-surface); + color: inherit; +} + +td > p { + margin: 0; +} + +/** Removes the margin-bottom from the last element */ +.remove-bottom-margin > :last-child { + margin-bottom: 0 !important; +} + +.text-secondary a { + color: inherit; + text-decoration: underline; +} + +/* orchidjs/tom-select#712 */ +.ts-wrapper.multi .ts-control > div.active { + border: 1px solid transparent !important; +} + +/* remove the ugly text highlight in the default tom-select */ +.ts-dropdown [data-selectable] .highlight { + background: inherit; + color: inherit; + text-decoration: underline; +} + +.page { + /* Leave space for the footer */ + min-height: calc(100% - 3rem); +} + +.datagrid { + --tblr-datagrid-padding: 1.25rem; + --tblr-datagrid-item-width: 6rem; +} + +code { + font-size: 100%; +} + +.apexcharts-text, +.apexcharts-datalabel { + fill: var(--tblr-body-color) !important; + font-weight: var(--tblr-body-font-weight); +} + +/** table **/ +.table-freeze-headers thead { + position: sticky; + top: 0; + z-index: 2; +} + +.table-freeze-footers tfoot { + position: sticky; + bottom: 0; + z-index: 2; +} + +.table-freeze-headers { + max-height: 50vh; +} + +.table-freeze-footers { + max-height: 50vh; +} + +.table-freeze-columns th:first-child { + position: sticky; + left: 0; + z-index: 2; +} + +.table-freeze-columns td:first-child { + position: sticky; + left: 0; + background: var(--tblr-bg-surface-secondary); + box-shadow: 3px 0 3px var(--tblr-border-color); +} + +/* Prevent the fixed headers from hiding the selected target row */ +.table-freeze-headers tr[id] { + scroll-margin-top: 2.1rem; +} + +.article-text { + font-size: 1.2em; + line-height: 1.5em; + font-family: "Times New Roman", serif; + width: 65ch; + max-width: 100%; + margin: 1.5em auto; +} + +.article-text p::first-letter { + font-size: 1.2em; + font-weight: 500; +} + +li p { + margin: 0; +} + +.leaflet-container { + background: var(--tblr-active-bg) !important; +} + +/* +See https://github.com/tabler/tabler/issues/2404 +*/ +.status-x { + --tblr-status-color: #000000; + --tblr-status-color-rgb: 0, 0, 0; +} +.status-facebook { + --tblr-status-color: #1877f2; + --tblr-status-color-rgb: 24, 119, 242; +} +.status-twitter { + --tblr-status-color: #1da1f2; + --tblr-status-color-rgb: 29, 161, 242; +} +.status-linkedin { + --tblr-status-color: #0a66c2; + --tblr-status-color-rgb: 10, 102, 194; +} +.status-google { + --tblr-status-color: #dc4e41; + --tblr-status-color-rgb: 220, 78, 65; +} +.status-youtube { + --tblr-status-color: #ff0000; + --tblr-status-color-rgb: 255, 0, 0; +} +.status-vimeo { + --tblr-status-color: #1ab7ea; + --tblr-status-color-rgb: 26, 183, 234; +} +.status-dribbble { + --tblr-status-color: #ea4c89; + --tblr-status-color-rgb: 234, 76, 137; +} +.status-github { + --tblr-status-color: #181717; + --tblr-status-color-rgb: 24, 23, 23; +} +.status-instagram { + --tblr-status-color: #e4405f; + --tblr-status-color-rgb: 228, 64, 95; +} +.status-pinterest { + --tblr-status-color: #bd081c; + --tblr-status-color-rgb: 189, 8, 28; +} +.status-vk { + --tblr-status-color: #6383a8; + --tblr-status-color-rgb: 99, 131, 168; +} +.status-rss { + --tblr-status-color: #ffa500; + --tblr-status-color-rgb: 255, 165, 0; +} +.status-flickr { + --tblr-status-color: #0063dc; + --tblr-status-color-rgb: 0, 99, 220; +} +.status-bitbucket { + --tblr-status-color: #0052cc; + --tblr-status-color-rgb: 0, 82, 204; +} +.status-tabler { + --tblr-status-color: #066fd1; + --tblr-status-color-rgb: 6, 111, 209; +} + +.text-black-fg { + color: var(--tblr-dark-fg) !important; +} diff --git a/sqlpage/sqlpage.js b/sqlpage/sqlpage.js new file mode 100644 index 0000000..07ccd34 --- /dev/null +++ b/sqlpage/sqlpage.js @@ -0,0 +1,367 @@ +/* !include https://cdn.jsdelivr.net/npm/@tabler/core@1.4.0/dist/js/tabler.min.js */ +const nonce = document.currentScript.nonce; + +function sqlpage_card() { + for (const c of document.querySelectorAll("[data-pre-init=card]")) { + c.removeAttribute("data-pre-init"); + const url = new URL(c.dataset.embed, window.location.href); + url.searchParams.set("_sqlpage_embed", "1"); + fetch(url) + .then((res) => res.text()) + .then((html) => { + const body = c.querySelector(".card-content"); + body.innerHTML = html; + const spinner = c.querySelector(".card-loading-placeholder"); + if (spinner) { + spinner.parentNode.removeChild(spinner); + } + const fragLoadedEvt = new CustomEvent("fragment-loaded", { + bubbles: true, + }); + c.dispatchEvent(fragLoadedEvt); + }); + } +} + +/** @param {HTMLElement} root_el */ +function setup_table(root_el) { + /** @type {HTMLInputElement | null} */ + const search_input = root_el.querySelector("input.search"); + const table_el = root_el.querySelector("table"); + const sort_buttons = [...table_el.querySelectorAll("button.sort[data-sort]")]; + const item_parent = table_el.querySelector("tbody"); + const has_sort = sort_buttons.length > 0; + + if (search_input || has_sort) { + const items = table_parse_data(table_el, sort_buttons); + if (search_input) setup_table_search_behavior(search_input, items); + if (has_sort) setup_sort_behavior(sort_buttons, items, item_parent); + } + + // Change number format AFTER parsing and storing the sort keys + apply_number_formatting(table_el); +} + +/** + * @param {HTMLInputElement} search_input + * @param {Array<{el: HTMLElement, sort_keys: Array<{num: number, str: string}>}>} items + */ +function setup_table_search_behavior(search_input, items) { + function onSearch() { + const lower_search = search_input.value + .toLowerCase() + .split(/\s+/) + .filter((s) => s); + for (const item of items) { + const show = lower_search.every((s) => + item.el.textContent.toLowerCase().includes(s), + ); + item.el.style.display = show ? "" : "none"; + } + } + + search_input.addEventListener("input", onSearch); + onSearch(); +} + +/**@param {HTMLElement} table_el */ +function apply_number_formatting(table_el) { + const header_els = table_el.querySelectorAll("thead > tr > th"); + const col_types = [...header_els].map((el) => el.dataset.column_type); + const col_rawnums = [...header_els].map((el) => !!el.dataset.raw_number); + const col_money = [...header_els].map((el) => !!el.dataset.money); + const number_format_locale = table_el.dataset.number_format_locale; + const number_format_digits = table_el.dataset.number_format_digits; + const currency = table_el.dataset.currency; + + for (const tr_el of table_el.querySelectorAll("tbody tr, tfoot tr")) { + const cells = tr_el.getElementsByTagName("td"); + for (let idx = 0; idx < cells.length; idx++) { + const column_type = col_types[idx]; + const is_raw_number = col_rawnums[idx]; + const cell_el = cells[idx]; + const text = cell_el.textContent; + + if (column_type === "number" && !is_raw_number && text) { + const num = Number.parseFloat(text); + const is_money = col_money[idx]; + cell_el.textContent = num.toLocaleString(number_format_locale, { + maximumFractionDigits: number_format_digits, + currency, + style: is_money ? "currency" : undefined, + }); + } + } + } +} + +/** Prepare the table rows for sorting. + * @param {HTMLElement} table_el + * @param {HTMLElement[]} sort_buttons + */ +function table_parse_data(table_el, sort_buttons) { + const is_num = [...sort_buttons].map( + (btn_el) => btn_el.parentElement.dataset.column_type === "number", + ); + return [...table_el.querySelectorAll("tbody tr")].map((tr_el) => { + const cells = tr_el.getElementsByTagName("td"); + return { + el: tr_el, + sort_keys: sort_buttons.map((_btn_el, idx) => { + const str = cells[idx]?.textContent; + const num = is_num[idx] ? Number.parseFloat(str) : Number.NaN; + return { num, str }; + }), + }; + }); +} + +/** + * Adds event listeners to the sort buttons to sort the table rows. + * @param {HTMLElement[]} sort_buttons + * @param {HTMLElement[]} items + * @param {HTMLElement} item_parent + */ +function setup_sort_behavior(sort_buttons, items, item_parent) { + sort_buttons.forEach((button, button_index) => { + button.addEventListener("click", function sort_items() { + const sort_desc = button.classList.contains("asc"); + for (const b of sort_buttons) { + b.classList.remove("asc", "desc"); + } + button.classList.add(sort_desc ? "desc" : "asc"); + const multiplier = sort_desc ? -1 : 1; + items.sort((a, b) => { + const a_key = a.sort_keys[button_index]; + const b_key = b.sort_keys[button_index]; + return ( + multiplier * + (Number.isNaN(a_key.num) || Number.isNaN(b_key.num) + ? a_key.str.localeCompare(b_key.str) + : a_key.num - b_key.num) + ); + }); + item_parent.append(...items.map((item) => item.el)); + }); + }); +} + +function sqlpage_table() { + for (const r of document.querySelectorAll("[data-pre-init=table]")) { + r.removeAttribute("data-pre-init"); + setup_table(r); + } +} + +let is_leaflet_injected = false; +let is_leaflet_loaded = false; + +function sqlpage_map() { + const first_map = document.querySelector("[data-pre-init=map]"); + const leaflet_base_url = "https://cdn.jsdelivr.net/npm/leaflet@1.9.4"; + if (first_map && !is_leaflet_injected) { + // Add the leaflet js and css to the page + const leaflet_css = document.createElement("link"); + leaflet_css.rel = "stylesheet"; + leaflet_css.href = `${leaflet_base_url}/dist/leaflet.css`; + leaflet_css.integrity = + "sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="; + leaflet_css.crossOrigin = "anonymous"; + document.head.appendChild(leaflet_css); + const leaflet_js = document.createElement("script"); + leaflet_js.src = `${leaflet_base_url}/dist/leaflet.js`; + leaflet_js.integrity = + "sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="; + leaflet_js.crossOrigin = "anonymous"; + leaflet_js.nonce = nonce; + leaflet_js.onload = onLeafletLoad; + document.head.appendChild(leaflet_js); + is_leaflet_injected = true; + } + if (first_map && is_leaflet_loaded) { + onLeafletLoad(); + } + /** + * + * @param {string|undefined} coords + * @returns {[number, number] | undefined} + */ + function parseCoords(coords) { + return coords?.split(",", 2).map((c) => Number.parseFloat(c)); + } + function onLeafletLoad() { + is_leaflet_loaded = true; + const maps = document.querySelectorAll("[data-pre-init=map]"); + for (const m of maps) { + const tile_source = m.dataset.tile_source; + const maxZoom = +m.dataset.max_zoom; + const attribution = m.dataset.attribution; + const map = L.map(m, { attributionControl: !!attribution }); + const zoom = m.dataset.zoom; + const center = parseCoords(m.dataset.center); + if (tile_source) + L.tileLayer(tile_source, { attribution, maxZoom }).addTo(map); + map._sqlpage_markers = []; + for (const marker_elem of m.getElementsByClassName("marker")) { + setTimeout(addMarker, 0, marker_elem, map); + } + setTimeout(() => { + if (center) map.setView(center, +zoom); + else { + const markerBounds = (m) => + m.getLatLng ? m.getLatLng() : m.getBounds(); + const bounds = map._sqlpage_markers.map(markerBounds); + if (bounds.length > 0) map.fitBounds(bounds); + else map.setView([51.505, 10], +zoom); + if (zoom != null) map.setZoom(+zoom); + } + }, 100); + m.removeAttribute("data-pre-init"); + m.getElementsByClassName("spinner-border")[0]?.remove(); + } + } + + function addMarker(marker_elem, map) { + const { dataset } = marker_elem; + const options = { + color: marker_elem.dataset.color, + title: marker_elem.getElementsByTagName("h3")[0].textContent.trim(), + }; + const marker = dataset.coords + ? createMarker(marker_elem, options) + : createGeoJSONMarker(marker_elem, options); + marker.addTo(map); + map._sqlpage_markers.push(marker); + if (marker_elem.textContent.trim()) marker.bindPopup(marker_elem); + else if (marker_elem.dataset.link) { + marker.on("click", () => { + window.location.href = marker_elem.dataset.link; + }); + } + } + function createMarker(marker_elem, options) { + const coords = parseCoords(marker_elem.dataset.coords); + const icon_obj = marker_elem.getElementsByClassName("mapicon")[0]; + if (icon_obj) { + const size = + 1.5 * + +(options.size || icon_obj.firstChild?.getAttribute("width") || 24); + options.icon = L.divIcon({ + html: icon_obj, + className: `border-0 bg-${options.color || "primary"} bg-gradient text-white rounded-circle shadow d-flex justify-content-center align-items-center`, + iconSize: [size, size], + iconAnchor: [size / 2, size / 2], + }); + } + return L.marker(coords, options); + } + function createGeoJSONMarker(marker_elem, options) { + const geojson = JSON.parse(marker_elem.dataset.geojson); + if (options.color) { + options.color = get_tabler_color(options.color) || options.color; + } + function style({ properties }) { + if (typeof properties !== "object") return options; + return { ...options, ...properties }; + } + function pointToLayer(feature, latlng) { + marker_elem.dataset.coords = `${latlng.lat},${latlng.lng}`; + return createMarker(marker_elem, { ...options, ...feature.properties }); + } + return L.geoJSON(geojson, { style, pointToLayer }); + } +} + +function sqlpage_form() { + const file_inputs = document.querySelectorAll( + "input[type=file][data-max-size]", + ); + for (const input of file_inputs) { + const max_size = +input.dataset.maxSize; + input.addEventListener("change", function () { + input.classList.remove("is-invalid"); + input.setCustomValidity(""); + for (const { size } of this.files) { + if (size > max_size) { + input.classList.add("is-invalid"); + return input.setCustomValidity( + `File size must be less than ${max_size / 1000} kB.`, + ); + } + } + }); + } + + const auto_submit_forms = document.querySelectorAll("form[data-auto-submit]"); + for (const form of auto_submit_forms) { + form.addEventListener("change", () => form.submit()); + } +} + +function get_tabler_color(name) { + return getComputedStyle(document.documentElement).getPropertyValue( + `--tblr-${name}`, + ); +} + +function load_scripts() { + const addjs = document.querySelectorAll("[data-sqlpage-js]"); + const existing_scripts = new Set( + [...document.querySelectorAll("script")].map((s) => s.src), + ); + for (const el of addjs) { + const js = new URL(el.dataset.sqlpageJs, window.location.href).href; + if (existing_scripts.has(js)) continue; + existing_scripts.add(js); + const script = document.createElement("script"); + script.src = js; + document.head.appendChild(script); + } +} + +function add_init_fn(f) { + document.addEventListener("DOMContentLoaded", f); + document.addEventListener("fragment-loaded", f); + if (document.readyState !== "loading") setTimeout(f, 0); +} + +add_init_fn(sqlpage_table); +add_init_fn(sqlpage_map); +add_init_fn(sqlpage_card); +add_init_fn(sqlpage_form); +add_init_fn(load_scripts); + +function init_bootstrap_components(event) { + const bootstrap = window.bootstrap || window.tabler.bootstrap; + const fragment = event.target; + for (const el of fragment.querySelectorAll('[data-bs-toggle="tooltip"]')) { + new bootstrap.Tooltip(el); + } + for (const el of fragment.querySelectorAll('[data-bs-toggle="popover"]')) { + new bootstrap.Popover(el); + } + for (const el of fragment.querySelectorAll('[data-bs-toggle="dropdown"]')) { + new bootstrap.Dropdown(el); + } + for (const el of fragment.querySelectorAll('[data-bs-ride="carousel"]')) { + new bootstrap.Carousel(el); + } +} + +document.addEventListener("fragment-loaded", init_bootstrap_components); + +function open_modal_for_hash() { + const hash = window.location.hash.substring(1); + if (!hash) return; + const modal = document.getElementById(hash); + if (!modal || !modal.classList.contains("modal")) return; + const bootstrap_modal = + window.tabler.bootstrap.Modal.getOrCreateInstance(modal); + bootstrap_modal.show(); + modal.addEventListener("hidden.bs.modal", () => { + window.history.replaceState(null, "", "#"); + }); +} + +window.addEventListener("hashchange", open_modal_for_hash); +window.addEventListener("DOMContentLoaded", open_modal_for_hash); diff --git a/sqlpage/sqlpage.json b/sqlpage/sqlpage.json new file mode 100644 index 0000000..086aa29 --- /dev/null +++ b/sqlpage/sqlpage.json @@ -0,0 +1,3 @@ +{ + "database_url": "sqlite://./sqlpage/sqlpage.db?mode=rwc" +} diff --git a/sqlpage/tabler-icons.svg b/sqlpage/tabler-icons.svg new file mode 100644 index 0000000..ccf4a71 --- /dev/null +++ b/sqlpage/tabler-icons.svg @@ -0,0 +1 @@ +/* !include https://cdn.jsdelivr.net/npm/@tabler/icons-sprite@3.44.0/dist/tabler-sprite.svg */ diff --git a/sqlpage/templates/README.md b/sqlpage/templates/README.md new file mode 100644 index 0000000..c70a3ac --- /dev/null +++ b/sqlpage/templates/README.md @@ -0,0 +1,20 @@ +# SQLPage component templates + +SQLPage templates are handlebars[^1] files that are used to render the results of SQL queries. + +[^1]: https://handlebarsjs.com/ + +## Default components + +SQLPage comes with a set of default[^2] components that you can use without having to write any code. +These are documented on https://sql-page.com/components.sql + +## Custom components + +You can [write your own component templates](https://sql-page.com/custom_components.sql) +and place them in the `sqlpage/templates` directory. +To override a default component, create a file with the same name as the default component. +If you want to start from an existing component, you can copy it from the `sqlpage/templates` directory +in the SQLPage source code[^2]. + +[^2]: A simple component to start from: https://github.com/sqlpage/SQLPage/blob/main/sqlpage/templates/code.handlebars \ No newline at end of file diff --git a/sqlpage/templates/alert.handlebars b/sqlpage/templates/alert.handlebars new file mode 100644 index 0000000..cf39f13 --- /dev/null +++ b/sqlpage/templates/alert.handlebars @@ -0,0 +1,41 @@ + \ No newline at end of file diff --git a/sqlpage/templates/big_number.handlebars b/sqlpage/templates/big_number.handlebars new file mode 100644 index 0000000..31eb05e --- /dev/null +++ b/sqlpage/templates/big_number.handlebars @@ -0,0 +1,109 @@ +
+{{#each_row}} +
+
+
+ + {{#if title}} +
+
+ {{#if title_link}} + + {{title}} + + {{else}} + {{title}} + {{/if}} +
+ + {{#if dropdown_item}} +
+ +
+ {{/if}} +
+ {{/if}} + +
+
+ {{#if value_link}} + + {{value}}{{#if unit}} {{unit}}{{/if}} + + {{else}} + {{value}}{{#if unit}} {{unit}}{{/if}} + {{/if}} +
+ + {{#if (and change_percent (not description))}} +
+ {{#if change_percent}} + + {{change_percent}}% + {{#if (gte change_percent 0)}} + {{~icon_img 'trending-up'~}} + {{else}} + {{~icon_img 'trending-down'~}} + {{/if}} + + {{/if}} +
+ {{/if}} +
+ + {{#if description}} +
+
{{description}}
+ {{#if change_percent}} +
+ + {{change_percent}}% + {{#if (gte change_percent 0)}} + {{~icon_img 'trending-up'~}} + {{else}} + {{~icon_img 'trending-down'~}} + {{/if}} + +
+ {{/if}} +
+ {{/if}} + + {{#if progress_percent}} +
+
+ {{progress_percent}}% Complete +
+
+ {{/if}} +
+
+
+{{/each_row}} +
diff --git a/sqlpage/templates/breadcrumb.handlebars b/sqlpage/templates/breadcrumb.handlebars new file mode 100644 index 0000000..d3bc28d --- /dev/null +++ b/sqlpage/templates/breadcrumb.handlebars @@ -0,0 +1,16 @@ + diff --git a/sqlpage/templates/button.handlebars b/sqlpage/templates/button.handlebars new file mode 100644 index 0000000..5a5ddfe --- /dev/null +++ b/sqlpage/templates/button.handlebars @@ -0,0 +1,44 @@ +
+{{#each_row}} + {{#if form}} + + {{else}} + + {{/if}} +{{/each_row}} +
diff --git a/sqlpage/templates/card.handlebars b/sqlpage/templates/card.handlebars new file mode 100644 index 0000000..aa64fc4 --- /dev/null +++ b/sqlpage/templates/card.handlebars @@ -0,0 +1,100 @@ +{{#if title}} +

{{title}}

+{{/if}} +{{#if description}} +

{{description}}

+{{/if}} +{{#if description_md}} + {{{markdown description_md}}} +{{/if}} +
+ {{#each_row}} + + {{/each_row}} +
diff --git a/sqlpage/templates/carousel.handlebars b/sqlpage/templates/carousel.handlebars new file mode 100644 index 0000000..d65762e --- /dev/null +++ b/sqlpage/templates/carousel.handlebars @@ -0,0 +1,40 @@ +
+
+ {{#if title}} +
+
{{title}}
+
+ {{/if}} + +
+ {{#if controls}} + + + Previous + + + + Next + + {{/if}} +
diff --git a/sqlpage/templates/chart.handlebars b/sqlpage/templates/chart.handlebars new file mode 100644 index 0000000..34d3256 --- /dev/null +++ b/sqlpage/templates/chart.handlebars @@ -0,0 +1,55 @@ +
+
+
+

{{title}}

+
+
+
+
+ Loading... +
+
+ +
+
+
diff --git a/sqlpage/templates/code.handlebars b/sqlpage/templates/code.handlebars new file mode 100644 index 0000000..d31ba4f --- /dev/null +++ b/sqlpage/templates/code.handlebars @@ -0,0 +1,7 @@ +
+ {{#each_row}} + {{#if title}}

{{title}}

{{/if}} + {{#if description}}

{{description}}

{{else}}{{#if description_md}}{{{markdown description_md}}}{{/if}}{{/if}} +
{{contents}}
+ {{/each_row}} +
diff --git a/sqlpage/templates/columns.handlebars b/sqlpage/templates/columns.handlebars new file mode 100644 index 0000000..2d02dc5 --- /dev/null +++ b/sqlpage/templates/columns.handlebars @@ -0,0 +1,43 @@ +
+ {{#each_row}} +
+
+ {{#if icon}} +
+ {{~icon_img icon~}} +
+ {{/if}} +
+
+
{{title}}
+
{{value}} {{#if small_text}}{{small_text}}{{/if}}
+
    +
  • {{description}} {{#if description_md}}{{{markdown description_md}}}{{/if}}
  • + {{#each item}} + {{#if (eq (typeof this) 'string')}} +
  • {{icon_img 'arrow-narrow-right'}} {{this}}
  • + {{/if}} + {{#if (eq (typeof this) 'object')}} +
  • + {{~icon_img icon~}} + {{#if link}} +

    {{description}}

    + {{else}} +

    {{description}}

    + {{/if}} + {{#if description_md}}{{{markdown description_md}}}{{/if}} +
  • + {{/if}} + {{/each}} +
+
+ {{#if button_text}} + + {{/if}} +
+
+
+ {{/each_row}} +
\ No newline at end of file diff --git a/sqlpage/templates/csv.handlebars b/sqlpage/templates/csv.handlebars new file mode 100644 index 0000000..1fe8fae --- /dev/null +++ b/sqlpage/templates/csv.handlebars @@ -0,0 +1,30 @@ + diff --git a/sqlpage/templates/datagrid.handlebars b/sqlpage/templates/datagrid.handlebars new file mode 100644 index 0000000..65e9489 --- /dev/null +++ b/sqlpage/templates/datagrid.handlebars @@ -0,0 +1,64 @@ +
+ {{#if title}} +
+ {{#if image_url}} + {{title}} + {{/if}} + {{#if icon}} + {{icon_img icon}} + {{/if}} +

+ {{~title~}} + {{#if description}} + {{description}} + {{/if}} + {{#if description_md}} + {{{markdown description_md}}} + {{/if}} +

+
+ {{/if}} + +
diff --git a/sqlpage/templates/debug.handlebars b/sqlpage/templates/debug.handlebars new file mode 100644 index 0000000..9b7d1cb --- /dev/null +++ b/sqlpage/templates/debug.handlebars @@ -0,0 +1,5 @@ +

Debug output

+
{{stringify this}}
+{{#each_row}}{{stringify this}}
+{{/each_row}}
+
\ No newline at end of file diff --git a/sqlpage/templates/default.handlebars b/sqlpage/templates/default.handlebars new file mode 100644 index 0000000..104676d --- /dev/null +++ b/sqlpage/templates/default.handlebars @@ -0,0 +1,17 @@ +{{#each_row}} + {{#if tag}} + <{{{tag}}}> + {{~#each (entries this)~}} + {{#unless (eq key "tag")}} + {{~value~}} + {{/unless}} + {{~/each~}} + + {{else}} +

+ {{~#each (entries this)}} + {{value}} + {{/each~}} +

+ {{/if}} +{{/each_row}} \ No newline at end of file diff --git a/sqlpage/templates/divider.handlebars b/sqlpage/templates/divider.handlebars new file mode 100644 index 0000000..984b8e8 --- /dev/null +++ b/sqlpage/templates/divider.handlebars @@ -0,0 +1,25 @@ +{{#if contents}} +
+ {{#if link}} + + {{contents}} + + {{else}} + {{contents}} + {{/if}} +
+{{else}} +
+{{/if}} diff --git a/sqlpage/templates/empty_state.handlebars b/sqlpage/templates/empty_state.handlebars new file mode 100644 index 0000000..13b1a16 --- /dev/null +++ b/sqlpage/templates/empty_state.handlebars @@ -0,0 +1,29 @@ +
+ {{#if header}} +
{{header}}
+ {{else}} + {{#if icon}} +
+ {{icon_img icon }} +
+ {{else}} + {{#if image}} +
{{image}}
+ {{/if}} + {{/if}} + {{/if}} +

{{title}}

+
+ {{~#if description}}

{{description}}

{{/if~}} + {{~#if description_md~}} + {{{markdown description_md}}} + {{~/if~}} +
+ + +
diff --git a/sqlpage/templates/error.handlebars b/sqlpage/templates/error.handlebars new file mode 100644 index 0000000..fdccc66 --- /dev/null +++ b/sqlpage/templates/error.handlebars @@ -0,0 +1,32 @@ + \ No newline at end of file diff --git a/sqlpage/templates/foldable.handlebars b/sqlpage/templates/foldable.handlebars new file mode 100644 index 0000000..6d89667 --- /dev/null +++ b/sqlpage/templates/foldable.handlebars @@ -0,0 +1,29 @@ +
+ {{#each_row}} +
+

+ +

+
+
+ {{~#if description~}} +

{{description}}

+ {{/if}} + {{~#if description_md~}} + {{{markdown description_md}}} + {{/if}} +
+
+
+ {{/each_row}} +
\ No newline at end of file diff --git a/sqlpage/templates/form.handlebars b/sqlpage/templates/form.handlebars new file mode 100644 index 0000000..a8851b8 --- /dev/null +++ b/sqlpage/templates/form.handlebars @@ -0,0 +1,194 @@ +
+
+ {{#if title}} +

{{title}}

+ {{/if}} +
+ {{#each_row}} + {{#if (eq type "header")}} +

{{label}}

+ {{else}} + {{#if (or (eq type "radio") (eq type "checkbox"))}} +
+ +
+ {{else}} + {{~#if (eq type "switch")}} +
+ +
+ {{else}} + {{~#if (eq type "hidden")}} + + {{else}} + + {{~/if~}} + {{/if}} + {{/if}} + {{/if}} + {{#if (eq type "file")}} + + {{#delay}}formenctype="multipart/form-data"{{/delay}} + {{/if}} + {{/each_row}} +
+ {{#if (and (ne validate '') (not auto_submit))}} + + + {{/if}} + {{#if reset}} + + {{/if}} +
+
diff --git a/sqlpage/templates/hero.handlebars b/sqlpage/templates/hero.handlebars new file mode 100644 index 0000000..a80f6f6 --- /dev/null +++ b/sqlpage/templates/hero.handlebars @@ -0,0 +1,61 @@ +
+
+

{{title}}

+
+ {{~description~}} + {{~#if description_md~}} + {{{markdown description_md}}} + {{~/if~}} +
+ {{#if link}} + {{default link_text "Go"}} + {{/if}} +
+ {{#if image}} + {{title}} + {{/if}} + {{#if video}} + + {{/if}} +
+ +
+ +
+ {{~#each_row~}} +
+
+
+ {{#if icon}} +
+ {{~icon_img icon 30~}} +
+ {{/if}} +

+ {{#if link}} + + {{/if}} + {{title}} + {{#if link}} + + {{/if}} +

+
+ {{~description~}} + {{~#if description_md~}} + {{{markdown description_md}}} + {{~/if~}} +
+
+
+
+ {{~/each_row~}} +
diff --git a/sqlpage/templates/html.handlebars b/sqlpage/templates/html.handlebars new file mode 100644 index 0000000..1260b93 --- /dev/null +++ b/sqlpage/templates/html.handlebars @@ -0,0 +1,6 @@ +{{{~html~}}} +{{~#each_row~}} + {{{~html~}}} + {{~text~}} + {{{~post_html~}}} +{{~/each_row~}} diff --git a/sqlpage/templates/list.handlebars b/sqlpage/templates/list.handlebars new file mode 100644 index 0000000..2837f9e --- /dev/null +++ b/sqlpage/templates/list.handlebars @@ -0,0 +1,83 @@ + +
diff --git a/sqlpage/templates/login.handlebars b/sqlpage/templates/login.handlebars new file mode 100644 index 0000000..46014bf --- /dev/null +++ b/sqlpage/templates/login.handlebars @@ -0,0 +1,82 @@ +
+
+
+
+ {{#if image}} +
+ +
+ {{/if}} + {{#if title}} +

{{title}}

+ {{/if}} + {{#if (or error_message error_message_md)}} + + {{/if}} + +
+ {{icon_img (default username_icon 'user-circle')}} + +
+ +
+ {{~icon_img (default password_icon 'key')~}} + +
+ {{#if remember_me_text}} + + {{/if}} +
+ +
+ {{#if (or footer footer_md)}} +
+
+ + {{#if footer}} + {{footer}} + {{else}} + {{#if footer_md}} + {{{markdown footer_md}}} + {{/if}} + {{/if}} + +
+ {{/if}} +
+
+
+
diff --git a/sqlpage/templates/map.handlebars b/sqlpage/templates/map.handlebars new file mode 100644 index 0000000..90691aa --- /dev/null +++ b/sqlpage/templates/map.handlebars @@ -0,0 +1,57 @@ +
+
+

{{title}}

+
+
+
+ Loading map... +
+ +
+
+
+
diff --git a/sqlpage/templates/modal.handlebars b/sqlpage/templates/modal.handlebars new file mode 100644 index 0000000..8b13ba2 --- /dev/null +++ b/sqlpage/templates/modal.handlebars @@ -0,0 +1,53 @@ + diff --git a/sqlpage/templates/pagination.handlebars b/sqlpage/templates/pagination.handlebars new file mode 100644 index 0000000..c3cf29a --- /dev/null +++ b/sqlpage/templates/pagination.handlebars @@ -0,0 +1,51 @@ +
+
+ +
+
diff --git a/sqlpage/templates/rss.handlebars b/sqlpage/templates/rss.handlebars new file mode 100644 index 0000000..779e911 --- /dev/null +++ b/sqlpage/templates/rss.handlebars @@ -0,0 +1,42 @@ + + + + {{title}} + {{link}} + {{description}} + {{~#if language}}{{language}}{{/if}} + {{~#if category}}{{sub_category}}{{/if}} + {{#if explicit}}true{{else}}false{{/if}} + {{~#if image_url}}{{/if}} + {{~#if author}}{{author}}{{/if}} + {{~#if copyright}}{{copyright}}{{/if}} + {{~#if funding_url}}{{funding_text}}{{/if}} + {{~#if type}}{{type}}{{/if}} + {{~#if complete}}yes{{/if}} + {{~#if locked}}yes{{/if}} + {{~#if guid}}{{guid}}{{/if}} + {{~#if self_link}}{{/if}} + {{#each_row}} + + {{title}} + {{link}} + {{description}} + {{~#if date}}{{rfc2822_date date}}{{/if}} + {{~#if enclosure_url}}{{/if}} + {{~#if guid}}{{guid}}{{/if}} + {{~#if episode}}{{episode}}{{/if}} + {{~#if season}}{{season}}{{/if}} + {{~#if episode_type}}{{episode_type}}{{/if}} + {{~#if block}}yes{{/if}} + {{~#if (not (eq explicit NULL))}}{{#if explicit}}true{{else}}false{{/if}}{{/if}} + {{~#if image_url}}{{/if}} + {{~#if duration}}{{duration}}{{/if}} + {{~#if transcript_url}}{{/if}} + + {{/each_row}} + + diff --git a/sqlpage/templates/shell-empty.handlebars b/sqlpage/templates/shell-empty.handlebars new file mode 100644 index 0000000..5614a83 --- /dev/null +++ b/sqlpage/templates/shell-empty.handlebars @@ -0,0 +1,3 @@ +{{{~html~}}} +{{{~contents~}}} +{{~#each_row~}}{{~/each_row~}} diff --git a/sqlpage/templates/shell.handlebars b/sqlpage/templates/shell.handlebars new file mode 100644 index 0000000..0e1c7a8 --- /dev/null +++ b/sqlpage/templates/shell.handlebars @@ -0,0 +1,241 @@ + + + + + {{default title "SQLPage"}} + + {{#if manifest}} + + {{/if}} + + {{#each (to_array css)}} + {{#if this}} + + {{/if}} + {{/each}} + + {{#if font}} + {{#if (starts_with font "/")}} + + {{else}} + + + + + {{/if}} + {{/if}} + + + {{#each (to_array javascript)}} + {{#if this}} + + {{/if}} + {{/each}} + {{#each (to_array javascript_module)}} + {{#if this}} + + {{/if}} + {{/each}} + + + {{#if title}} + + {{/if}} + {{#if description}} + + + {{/if}} + {{#if preview_image}} + + + {{/if}} + + {{#if norobot}} + + {{/if}} + + {{#if refresh}} + + {{/if}} + {{#if rss}} + + {{/if}} + + {{#if social_image}} + + {{/if}} + + +{{!-- Partial for menu_items to not duplicate logic --}} +{{#*inline "menu-items"}} + + {{#if search_target}} + + {{/if}} +{{/inline}} + + +
+ {{#if (or (or title (or icon image)) (or menu_item search_target))}} +
+ {{#if sidebar}} + + {{else}} + +
+ {{/if}} +{{/if}} +
+
+ {{~#each_row~}}{{~/each_row~}} +
+ + {{#unless (eq footer '')}} +
+ {{#if footer}} + {{{markdown footer}}} + {{else}} + + Built with SQLPage + {{/if}} +
+ {{/unless}} +
+
+ + diff --git a/sqlpage/templates/steps.handlebars b/sqlpage/templates/steps.handlebars new file mode 100644 index 0000000..5764069 --- /dev/null +++ b/sqlpage/templates/steps.handlebars @@ -0,0 +1,24 @@ +{{#if id}}{{/if}} +{{#if title}} +

{{title}}

+{{/if}} +{{#if description}} +

{{description}}

+{{/if}} +
diff --git a/sqlpage/templates/tab.handlebars b/sqlpage/templates/tab.handlebars new file mode 100644 index 0000000..eb6980c --- /dev/null +++ b/sqlpage/templates/tab.handlebars @@ -0,0 +1,32 @@ + diff --git a/sqlpage/templates/table.handlebars b/sqlpage/templates/table.handlebars new file mode 100644 index 0000000..e20b084 --- /dev/null +++ b/sqlpage/templates/table.handlebars @@ -0,0 +1,140 @@ +
+
+ {{#if (or search initial_search_value)}} +
+ +
+ {{/if}} +
+ + {{#if description}}{{/if}} + {{#each_row}} + {{#if (eq @row_index 0)}} + {{! Since we are inside the first data row, render the header }} + + + {{#each this}} + {{#if (not (starts_with @key '_sqlpage_'))}} + + {{/if}} + {{/each}} + {{#if ../edit_url}}{{/if}} + {{#if ../delete_url}}{{/if}} + {{#each (to_array ../custom_actions)}} + + {{/each}} + + {{#each (to_array _sqlpage_actions)}} + + {{/each}} + + + {{#delay}}{{/delay}} + {{~/if~}} + {{!~ If this data row should go into the footer, close the , open the ~}} + {{~#if _sqlpage_footer~}} {{/if~}} + + {{~#each this~}} + {{~#if (not (starts_with @key '_sqlpage_'))~}} + + {{/if~}} + {{~/each~}} + {{#if ../edit_url}} + + {{/if}} + {{#if ../delete_url}} + + {{/if}} + {{#each (to_array ../custom_actions)}} + + {{/each}} + + {{#each (to_array _sqlpage_actions)}} + + {{/each}} + + {{!~ + After this has been rendered, if this was a footer, we need to reopen a new + No need for another delayed closure since the previous one still applies + ~}} + {{~#if _sqlpage_footer}} {{/if~}} + {{/each_row}} + {{flush_delayed}} + + {{! If not enough rows were rendered, we need to place a 'No data' cell. "Not enough rows" depends on the footer settings }} + {{#if (eq @row_index 0)}} + + + + + + {{/if}} +
{{description}}
+ {{~#if ../../sort~}} + + {{~else~}} + {{~@key~}} + {{~/if~}} + EditDelete{{this.name}}{{this.name}}
+ {{~#if (array_contains_case_insensitive ../../markdown @key)~}} + {{{markdown this}}} + {{~else~}} + {{~#if (array_contains_case_insensitive ../../icon @key)~}} + {{~icon_img this~}} + {{~else~}} + {{this}} + {{~/if~}} + {{~/if~}} + + + {{~icon_img 'edit'~}} + + + + {{~icon_img 'trash'~}} + + + {{!Title property sets the tooltip text}} + {{~icon_img this.icon~}} + + + + {{~icon_img this.icon~}} + +
{{default empty_description 'No data'}}
+
+
+
diff --git a/sqlpage/templates/text.handlebars b/sqlpage/templates/text.handlebars new file mode 100644 index 0000000..5b28a44 --- /dev/null +++ b/sqlpage/templates/text.handlebars @@ -0,0 +1,44 @@ +{{{~html~}}} +{{~#if title~}} +

{{title}}

+{{~else~}} + {{#if id}} + + {{/if}} +{{~/if~}} +{{~#if contents_md~}} +
+ {{{~markdown contents_md~}}} +
+{{~/if~}} +{{~#if unsafe_contents_md~}} +
+ {{{~markdown unsafe_contents_md 'allow_unsafe'~}}} +
+{{~/if~}} +

+ {{contents}} + {{~#each_row~}} + {{~#if break~}}

{{~/if~}} + {{~#if link~}} + {{#delay}}{{/delay}} + {{~/if~}} + {{~#if code~}} + {{#delay}}{{/delay}} + {{~/if~}} + {{contents}} + {{~flush_delayed~}} + {{~#if contents_md~}} + {{{markdown contents_md}}} + {{~/if~}} + {{~#if unsafe_contents_md~}} + {{{markdown unsafe_contents_md 'allow_unsafe'}}} + {{~/if~}} + {{~/each_row~}} +

diff --git a/sqlpage/templates/timeline.handlebars b/sqlpage/templates/timeline.handlebars new file mode 100644 index 0000000..137cf10 --- /dev/null +++ b/sqlpage/templates/timeline.handlebars @@ -0,0 +1,31 @@ + diff --git a/sqlpage/templates/title.handlebars b/sqlpage/templates/title.handlebars new file mode 100644 index 0000000..b06e45c --- /dev/null +++ b/sqlpage/templates/title.handlebars @@ -0,0 +1 @@ +{{contents}} diff --git a/sqlpage/templates/tracking.handlebars b/sqlpage/templates/tracking.handlebars new file mode 100644 index 0000000..8e1fb2d --- /dev/null +++ b/sqlpage/templates/tracking.handlebars @@ -0,0 +1,26 @@ +
+
+
+
{{title}}
+
+
+
{{#if information}}{{information}}{{/if}}
+
+
+
+ {{#if description_md}} + {{{markdown description_md}}} + {{else}} + {{#if description}}{{description}}{{/if}} + {{/if}} +
+
+
+
+ {{#each_row}} +
+ {{/each_row}} +
+
+
+
diff --git a/sqlpage/tomselect.js b/sqlpage/tomselect.js new file mode 100644 index 0000000..90dd15d --- /dev/null +++ b/sqlpage/tomselect.js @@ -0,0 +1,94 @@ +/* !include https://cdn.jsdelivr.net/npm/tom-select@2.6.1/dist/js/tom-select.popular.min.js */ + +function sqlpage_select_dropdown() { + for (const s of document.querySelectorAll( + "[data-pre-init=select-dropdown]", + )) { + try { + sqlpage_select_dropdown_individual(s); + } catch (e) { + console.error(e); + } + } +} + +/** + * Initialize a select dropdown for a single element + * @param {HTMLSelectElement} s - The select element to initialize + */ +function sqlpage_select_dropdown_individual(s) { + s.removeAttribute("data-pre-init"); + // See: https://github.com/orchidjs/tom-select/issues/716 + // By default, TomSelect will not retain the focus if s is already focused + // This is a workaround to fix that + const is_focused = s === document.activeElement; + + const tom = new TomSelect(s, { + load: sqlpage_load_options_source(s.dataset.options_source), + valueField: "value", + labelField: "label", + searchField: "label", + create: s.dataset.create_new, + maxOptions: null, + onItemAdd: function () { + this.setTextboxValue(""); + this.refreshOptions(); + }, + }); + if (is_focused) tom.focus(); + s.form?.addEventListener("reset", async () => { + // The reset event is fired before the form is reset, so we need to wait for the next event loop + await new Promise((resolve) => setTimeout(resolve, 0)); + // Sync the options with the new reset value + tom.sync(); + // Wait for the options to be updated + await new Promise((resolve) => setTimeout(resolve, 0)); + // "sync" also focuses the input, so we need to blur it to remove the focus + tom.blur(); + tom.close(); + }); +} + +function sqlpage_load_options_source(options_source) { + if (!options_source) return; + return async (query, callback) => { + const err = (label) => callback([{ label, value: "" }]); + const resp = await fetch( + `${options_source}?search=${encodeURIComponent(query)}`, + ); + if (!resp.ok) { + return err( + `Error loading options from "${options_source}": ${resp.status} ${resp.statusText}`, + ); + } + const resp_type = resp.headers.get("content-type"); + if (resp_type !== "application/json") { + return err( + `Invalid response type: ${resp_type} from "${options_source}". Make sure to use the 'json' component in the SQL file that generates the options.`, + ); + } + const results = await resp.json(); + if (!Array.isArray(results)) { + return err( + `Invalid response from "${options_source}". The response must be an array of objects with a 'label' and a 'value' property.`, + ); + } + if (results.length === 1 && results[0].error) { + return err(results[0].error); + } + if (results.length > 0) { + const keys = Object.keys(results[0]); + if ( + keys.length !== 2 || + !keys.includes("label") || + !keys.includes("value") + ) { + return err( + `Invalid response from "${options_source}". The response must be an array of objects with a 'label' and a 'value' property. Got: ${JSON.stringify(results[0])} in the first object instead.`, + ); + } + } + callback(results); + }; +} +add_init_fn(sqlpage_select_dropdown); diff --git a/src/app_config.rs b/src/app_config.rs new file mode 100644 index 0000000..42760e7 --- /dev/null +++ b/src/app_config.rs @@ -0,0 +1,961 @@ +use crate::cli::arguments::{Cli, parse_cli}; +use crate::webserver::content_security_policy::ContentSecurityPolicyTemplate; +use crate::webserver::routing::RoutingConfig; +use actix_web::http::Uri; +use anyhow::Context; +use config::Config; +use openidconnect::IssuerUrl; +use percent_encoding::AsciiSet; +use serde::de::Error; +use serde::{Deserialize, Deserializer, Serialize}; +use std::net::{SocketAddr, ToSocketAddrs}; +use std::path::{Path, PathBuf}; +use std::str::FromStr; +use std::time::Duration; + +#[cfg(not(feature = "lambda-web"))] +const DEFAULT_DATABASE_FILE: &str = "sqlpage.db"; + +impl AppConfig { + pub fn from_cli(cli: &Cli) -> anyhow::Result { + let mut config = if let Some(config_file) = &cli.config_file { + if !config_file.is_file() { + return Err(anyhow::anyhow!( + "Configuration file does not exist: {}", + config_file.display() + )); + } + log::debug!("Loading configuration from file: {}", config_file.display()); + load_from_file(config_file)? + } else if let Some(config_dir) = &cli.config_dir { + log::debug!( + "Loading configuration from directory: {}", + config_dir.display() + ); + load_from_directory(config_dir)? + } else { + log::debug!("Loading configuration from environment"); + load_from_env()? + }; + if let Some(web_root) = &cli.web_root { + log::debug!( + "Setting web root to value from the command line: {}", + web_root.display() + ); + config.web_root.clone_from(web_root); + } + if let Some(config_dir) = &cli.config_dir { + config.configuration_directory.clone_from(config_dir); + } + + config.configuration_directory = std::fs::canonicalize(&config.configuration_directory) + .unwrap_or_else(|_| config.configuration_directory.clone()); + + if !config.configuration_directory.exists() { + log::info!( + "Configuration directory does not exist, creating it: {}", + config.configuration_directory.display() + ); + std::fs::create_dir_all(&config.configuration_directory).with_context(|| { + format!( + "Failed to create configuration directory in {}", + config.configuration_directory.display() + ) + })?; + } + + if config.database_url.is_empty() { + log::debug!( + "Creating default database in {}", + config.configuration_directory.display() + ); + config.database_url = create_default_database(&config.configuration_directory); + } + + config + .validate() + .context("The provided configuration is invalid")?; + + config.resolve_timeouts(); + + log::debug!("Loaded configuration: {config:#?}"); + log::info!( + "Configuration loaded from {}", + config.configuration_directory.display() + ); + + Ok(config) + } + + fn resolve_timeouts(&mut self) { + let is_sqlite = self.database_url.starts_with("sqlite:"); + self.database_connection_idle_timeout = resolve_timeout( + self.database_connection_idle_timeout, + if is_sqlite { + None + } else { + Some(Duration::from_mins(30)) + }, + ); + self.database_connection_max_lifetime = resolve_timeout( + self.database_connection_max_lifetime, + if is_sqlite { + None + } else { + Some(Duration::from_hours(1)) + }, + ); + } + + fn validate(&self) -> anyhow::Result<()> { + if !self.web_root.is_dir() { + return Err(anyhow::anyhow!( + "Web root is not a valid directory: {}", + self.web_root.display() + )); + } + if !self.configuration_directory.is_dir() { + return Err(anyhow::anyhow!( + "Configuration directory is not a valid directory: {}", + self.configuration_directory.display() + )); + } + if self.database_connection_acquire_timeout_seconds <= 0.0 { + return Err(anyhow::anyhow!( + "Database connection acquire timeout must be positive" + )); + } + if let Some(max_connections) = self.max_database_pool_connections + && max_connections == 0 + { + return Err(anyhow::anyhow!( + "Maximum database pool connections must be greater than 0" + )); + } + anyhow::ensure!(self.max_pending_rows > 0, "max_pending_rows cannot be null"); + + for path in &self.oidc_protected_paths { + if !path.starts_with('/') { + return Err(anyhow::anyhow!( + "All protected paths must start with '/', but found: '{path}'" + )); + } + } + + for path in &self.oidc_public_paths { + if !path.starts_with('/') { + return Err(anyhow::anyhow!( + "All public paths must start with '/', but found: '{path}'" + )); + } + } + + Ok(()) + } +} + +pub fn load_config() -> anyhow::Result { + let cli = parse_cli()?; + AppConfig::from_cli(&cli) +} + +pub fn load_from_env() -> anyhow::Result { + let config_dir = configuration_directory(); + load_from_directory(&config_dir) + .with_context(|| format!("Unable to load configuration from {}", config_dir.display())) +} + +#[derive(Debug, Deserialize, PartialEq, Clone)] +#[allow(clippy::struct_excessive_bools)] +pub struct AppConfig { + #[serde(default = "default_database_url")] + pub database_url: String, + /// A separate field for the database password. If set, this will override any password specified in the `database_url`. + #[serde(default)] + pub database_password: Option, + pub max_database_pool_connections: Option, + #[serde( + default, + deserialize_with = "deserialize_duration_seconds", + rename = "database_connection_idle_timeout_seconds" + )] + pub database_connection_idle_timeout: Option, + #[serde( + default, + deserialize_with = "deserialize_duration_seconds", + rename = "database_connection_max_lifetime_seconds" + )] + pub database_connection_max_lifetime: Option, + + #[serde(default)] + pub sqlite_extensions: Vec, + + #[serde(default, deserialize_with = "deserialize_socket_addr")] + pub listen_on: Option, + #[serde(default, deserialize_with = "deserialize_port")] + pub port: Option, + pub unix_socket: Option, + + /// Number of times to retry connecting to the database after a failure when the server starts + /// up. Retries will happen every 5 seconds. The default is 6 retries, which means the server + /// will wait up to 30 seconds for the database to become available. + #[serde(default = "default_database_connection_retries")] + pub database_connection_retries: u32, + + /// Maximum number of seconds to wait before giving up when acquiring a database connection from the + /// pool. The default is 10 seconds. + #[serde(default = "default_database_connection_acquire_timeout_seconds")] + pub database_connection_acquire_timeout_seconds: f64, + + /// The directory where the .sql files are located. Defaults to the current directory. + #[serde(default = "default_web_root")] + pub web_root: PathBuf, + + /// The directory where the sqlpage configuration file is located. Defaults to `./sqlpage`. + #[serde(default = "configuration_directory")] + pub configuration_directory: PathBuf, + + /// Set to true to allow the `sqlpage.exec` function to be used in SQL queries. + /// This should be enabled only if you trust the users writing SQL queries, since it gives + /// them the ability to execute arbitrary shell commands on the server. + #[serde(default)] + pub allow_exec: bool, + + /// Maximum size of uploaded files in bytes. The default is 10MiB (10 * 1024 * 1024 bytes) + #[serde(default = "default_max_file_size")] + pub max_uploaded_file_size: usize, + + /// The base URL of the `OpenID` Connect provider. + /// Required when enabling Single Sign-On through an OIDC provider. + pub oidc_issuer_url: Option, + /// The client ID assigned to `SQLPage` when registering with the OIDC provider. + /// Defaults to `sqlpage`. + #[serde(default = "default_oidc_client_id")] + pub oidc_client_id: String, + /// The client secret for authenticating `SQLPage` to the OIDC provider. + /// Required when enabling Single Sign-On through an OIDC provider. + pub oidc_client_secret: Option, + /// Space-separated list of [scopes](https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims) to request during OIDC authentication. + /// Defaults to "openid email profile" + #[serde(default = "default_oidc_scopes")] + pub oidc_scopes: String, + + /// Defines a list of path prefixes that should be protected by OIDC authentication. + /// By default, all paths are protected. + /// If you specify a list of prefixes, only requests whose path starts with one of the prefixes will require authentication. + /// For example, if you set this to `["/private"]`, then requests to `/private/some_page.sql` will require authentication, + /// but requests to `/index.sql` will not. + /// NOTE: `OIDC_PUBLIC_PATHS` takes precedence over `OIDC_PROTECTED_PATHS`. + /// For example, if you have `["/private"]` on the `protected_paths` like before, but also `["/private/public"]` on the `public_paths`, then `/private` requires authentication, but `/private/public` requires not authentication. + /// You cannot make a path inside a public path private again. So expanding the previous example, if you now add `/private/public/private_again`, then this path will still be accessible. + #[serde(default = "default_oidc_protected_paths")] + pub oidc_protected_paths: Vec, + + /// Defines path prefixes to exclude from OIDC authentication. + /// By default, no paths are excluded. + /// Paths matching these prefixes will not require authentication. + /// For example, if set to `["/public"]`, requests to `/public/some_page.sql` will not require authentication, + /// but requests to `/index.sql` will still require it. + /// To make `/protected/public.sql` public while protecting its containing directory, + /// set `oidc_public_paths` to `["/protected/public.sql"]` and `oidc_protected_paths` to `["/protected"]`. + /// Be aware that any path starting with `/protected/public.sql` (e.g., `/protected/public.sql.backup`) will also become public. + #[serde(default)] + pub oidc_public_paths: Vec, + + /// Additional trusted audiences for OIDC JWT tokens, beyond the client ID. + /// By default (when None), all additional audiences are trusted for compatibility + /// with providers that include multiple audience values (like ZITADEL, Azure AD, etc.). + /// Set to an empty list to only allow the client ID as audience. + /// Set to a specific list to only allow those specific additional audiences. + #[serde(default)] + pub oidc_additional_trusted_audiences: Option>, + + /// A domain name to use for the HTTPS server. If this is set, the server will perform all the necessary + /// steps to set up an HTTPS server automatically. All you need to do is point your domain name to the + /// server's IP address. + /// + /// It will listen on port 443 for HTTPS connections, + /// and will automatically request a certificate from Let's Encrypt + /// using the ACME protocol (requesting a TLS-ALPN-01 challenge). + pub https_domain: Option, + + /// The hostname where your application is publicly accessible (e.g., "myapp.example.com"). + /// This is used for OIDC redirect URLs. If not set, `https_domain` will be used instead. + pub host: Option, + + /// The email address to use when requesting a certificate from Let's Encrypt. + /// Defaults to `contact@`. + pub https_certificate_email: Option, + + /// The directory to store the Let's Encrypt certificate in. Defaults to `./sqlpage/https`. + #[serde(default = "default_https_certificate_cache_dir")] + pub https_certificate_cache_dir: PathBuf, + + /// URL to the ACME directory. Defaults to the Let's Encrypt production directory. + #[serde(default = "default_https_acme_directory_url")] + pub https_acme_directory_url: String, + + /// Whether we should run in development or production mode. Used to determine + /// whether to show error messages to the user. + #[serde(default)] + pub environment: DevOrProd, + + /// Serve the website from a sub path. For example, if you set this to `/sqlpage/`, the website will be + /// served from `https://yourdomain.com/sqlpage/`. Defaults to `/`. + /// This is useful if you want to serve the website on the same domain as other content, and + /// you are using a reverse proxy to route requests to the correct server. + #[serde( + deserialize_with = "deserialize_site_prefix", + default = "default_site_prefix" + )] + pub site_prefix: String, + + /// Maximum number of messages that can be stored in memory before sending them to the client. + /// This prevents a single request from using up all available memory. + #[serde(default = "default_max_pending_rows")] + pub max_pending_rows: usize, + + /// Whether to compress the http response body when the client supports it. + /// Enabling response compression hinders the ability of `SQLPage` to stream every single byte + /// of data as soon as it is produced. + /// As a rule of thub, enable response compression when your app is fast. + #[serde(default = "default_compress_responses")] + pub compress_responses: bool, + + /// Content-Security-Policy header to send to the client. + /// If not set, a default policy allowing + /// - scripts from the same origin, + /// - script elements with the `nonce="{{@csp_nonce}}"` attribute, + #[serde(default)] + pub content_security_policy: ContentSecurityPolicyTemplate, + + /// Whether `sqlpage.fetch` should load trusted certificates from the operating system's certificate store + /// By default, it loads Mozilla's root certificates that are embedded in the `SQLPage` binary, or the ones pointed to by the + /// `SSL_CERT_FILE` and `SSL_CERT_DIR` environment variables. + #[serde(default = "default_system_root_ca_certificates")] + pub system_root_ca_certificates: bool, + + /// Maximum depth of recursion allowed in the `run_sql` function. + #[serde(default = "default_max_recursion_depth")] + pub max_recursion_depth: u8, + + #[serde(default = "default_markdown_allow_dangerous_html")] + pub markdown_allow_dangerous_html: bool, + + #[serde(default = "default_markdown_allow_dangerous_protocol")] + pub markdown_allow_dangerous_protocol: bool, + + pub cache_stale_duration_ms: Option, +} + +impl AppConfig { + #[must_use] + pub fn cache_stale_duration_ms(&self) -> u64 { + self.cache_stale_duration_ms + .unwrap_or_else(|| if self.environment.is_prod() { 1000 } else { 0 }) + } + + #[must_use] + pub fn listen_on(&self) -> SocketAddr { + let mut addr = self.listen_on.unwrap_or_else(|| { + if self.https_domain.is_some() { + SocketAddr::from(([0, 0, 0, 0], 443)) + } else { + SocketAddr::from(([0, 0, 0, 0], 8080)) + } + }); + if let Some(port) = self.port { + addr.set_port(port); + } + addr + } +} + +impl RoutingConfig for AppConfig { + fn prefix(&self) -> &str { + &self.site_prefix + } +} + +/// The directory where the `sqlpage.json` file is located. +/// Determined by the `SQLPAGE_CONFIGURATION_DIRECTORY` environment variable +fn configuration_directory() -> PathBuf { + let env_var_name = "CONFIGURATION_DIRECTORY"; + // uppercase or lowercase, with or without the "SQLPAGE_" prefix + for prefix in &["", "SQLPAGE_"] { + let var = format!("{prefix}{env_var_name}"); + for t in [str::to_lowercase, str::to_uppercase] { + let dir = t(&var); + if let Ok(dir) = std::env::var(dir) { + return PathBuf::from(dir); + } + } + } + PathBuf::from("./sqlpage") +} + +fn cannonicalize_if_possible(path: &std::path::Path) -> PathBuf { + path.canonicalize().unwrap_or_else(|_| path.to_owned()) +} + +/// Parses and loads the configuration from the `sqlpage.json` file in the current directory. +/// This should be called only once at the start of the program. +pub fn load_from_directory(directory: &Path) -> anyhow::Result { + let cannonical = cannonicalize_if_possible(directory); + log::debug!("Loading configuration from {}", cannonical.display()); + let config_file = directory.join("sqlpage"); + let mut app_config = load_from_file(&config_file)?; + app_config.configuration_directory = directory.into(); + Ok(app_config) +} + +/// Parses and loads the configuration from the given file. +pub fn load_from_file(config_file: &Path) -> anyhow::Result { + log::debug!("Loading configuration from file: {}", config_file.display()); + let config = Config::builder() + .add_source(config::File::from(config_file).required(false)) + .add_source(env_config()) + .add_source(env_config().prefix("SQLPAGE")) + .build() + .with_context(|| { + format!( + "Unable to build configuration loader for {}", + config_file.display() + ) + })?; + log::trace!("Configuration sources: {:#?}", config.cache); + let app_config = config + .try_deserialize::() + .context("Failed to load the configuration")?; + Ok(app_config) +} + +fn env_config() -> config::Environment { + config::Environment::default() + .try_parsing(true) + .list_separator(" ") + .with_list_parse_key("sqlite_extensions") +} + +fn deserialize_port<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + // deserializes both 8080 and "tcp://1.1.1.1:9090" + #[derive(Deserialize)] + #[serde(untagged)] + enum PortOrUrl { + Port(u16), + Url(String), + } + let port_or_url: Option = Deserialize::deserialize(deserializer)?; + match port_or_url { + Some(PortOrUrl::Port(p)) => Ok(Some(p)), + Some(PortOrUrl::Url(u)) => { + if let Ok(u) = Uri::from_str(&u) { + log::warn!( + "{u} is not a valid value for the SQLPage port number. Ignoring this error since kubernetes may set the SQLPAGE_PORT env variable to a service URI when there is a service named sqlpage. Rename your service to avoid this warning." + ); + Ok(None) + } else { + Err(D::Error::custom(format!( + "Invalid port number: {u}. Expected a number between {} and {}", + u16::MIN, + u16::MAX + ))) + } + } + None => Ok(None), + } +} + +fn deserialize_socket_addr<'de, D: Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + let host_str: Option = Deserialize::deserialize(deserializer)?; + host_str + .map(|h| { + parse_socket_addr(&h).map_err(|e| { + D::Error::custom(anyhow::anyhow!("Failed to parse socket address {h:?}: {e}")) + }) + }) + .transpose() +} + +fn deserialize_site_prefix<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + let prefix: String = Deserialize::deserialize(deserializer)?; + Ok(normalize_site_prefix(prefix.as_str())) +} + +/// We standardize the site prefix to always be stored with both leading and trailing slashes. +/// We also percent-encode special characters in the prefix, but allow it to contain slashes (to allow +/// hosting on a sub-sub-path). +fn normalize_site_prefix(prefix: &str) -> String { + const TO_ENCODE: AsciiSet = percent_encoding::CONTROLS + .add(b' ') + .add(b'"') + .add(b'#') + .add(b'<') + .add(b'>') + .add(b'?'); + + let prefix = prefix.trim_start_matches('/').trim_end_matches('/'); + if prefix.is_empty() { + return default_site_prefix(); + } + let encoded_prefix = percent_encoding::percent_encode(prefix.as_bytes(), &TO_ENCODE); + + let invalid_chars = ["%09", "%0A", "%0D"]; + + std::iter::once("/") + .chain(encoded_prefix.filter(|c| !invalid_chars.contains(c))) + .chain(std::iter::once("/")) + .collect::() +} + +#[test] +fn test_normalize_site_prefix() { + assert_eq!(normalize_site_prefix(""), "/"); + assert_eq!(normalize_site_prefix("/"), "/"); + assert_eq!(normalize_site_prefix("a"), "/a/"); + assert_eq!(normalize_site_prefix("a/"), "/a/"); + assert_eq!(normalize_site_prefix("/a"), "/a/"); + assert_eq!(normalize_site_prefix("a/b"), "/a/b/"); + assert_eq!(normalize_site_prefix("a/b/"), "/a/b/"); + assert_eq!(normalize_site_prefix("a/b/c"), "/a/b/c/"); + assert_eq!(normalize_site_prefix("a b"), "/a%20b/"); + assert_eq!(normalize_site_prefix("a b/c"), "/a%20b/c/"); +} + +fn default_site_prefix() -> String { + '/'.to_string() +} + +fn parse_socket_addr(host_str: &str) -> anyhow::Result { + host_str + .to_socket_addrs()? + .next() + .with_context(|| format!("Resolving host '{host_str}'")) +} + +#[cfg(test)] +fn default_database_url() -> String { + "sqlite://:memory:?cache=shared".to_owned() +} +#[cfg(not(test))] +fn default_database_url() -> String { + // When using a custom configuration directory, the default database URL + // will be set later in `AppConfig::from_cli`. + String::new() +} + +fn create_default_database(configuration_directory: &Path) -> String { + let prefix = "sqlite://".to_owned(); + + #[cfg(not(feature = "lambda-web"))] + { + let config_dir = cannonicalize_if_possible(configuration_directory); + let old_default_db_path = PathBuf::from(DEFAULT_DATABASE_FILE); + let default_db_path = config_dir.join(DEFAULT_DATABASE_FILE); + if let Ok(true) = old_default_db_path.try_exists() { + log::warn!( + "Your sqlite database in {} is publicly accessible through your web server. Please move it to {}.", + old_default_db_path.display(), + default_db_path.display() + ); + return prefix + old_default_db_path.to_str().unwrap(); + } else if let Ok(true) = default_db_path.try_exists() { + log::debug!( + "Using the default database file in {}", + default_db_path.display() + ); + return prefix + &encode_uri(&default_db_path); + } + // Create the default database file if we can + if let Ok(tmp_file) = std::fs::File::create(&default_db_path) { + log::info!( + "No DATABASE_URL provided, {} is writable, creating a new database file.", + default_db_path.display() + ); + drop(tmp_file); + if let Err(e) = std::fs::remove_file(&default_db_path) { + log::debug!( + "Unable to remove temporary probe file. It might have already been removed by another instance started concurrently: {e}" + ); + } + return prefix + &encode_uri(&default_db_path) + "?mode=rwc"; + } + } + + log::warn!( + "No DATABASE_URL provided, and {} is not writeable. Using a temporary in-memory SQLite database. All the data created will be lost when this server shuts down.", + configuration_directory.display() + ); + prefix + ":memory:?cache=shared" +} + +#[cfg(any(test, not(feature = "lambda-web")))] +fn encode_uri(path: &Path) -> std::borrow::Cow<'_, str> { + const ASCII_SET: &percent_encoding::AsciiSet = &percent_encoding::NON_ALPHANUMERIC + .remove(b'-') + .remove(b'_') + .remove(b'.') + .remove(b':') + .remove(b' ') + .remove(b'/'); + let path_bytes = path.as_os_str().as_encoded_bytes(); + percent_encoding::percent_encode(path_bytes, ASCII_SET).into() +} + +fn default_database_connection_retries() -> u32 { + 6 +} + +fn default_database_connection_acquire_timeout_seconds() -> f64 { + 10. +} + +fn default_web_root() -> PathBuf { + std::env::current_dir().unwrap_or_else(|e| { + log::error!("Unable to get current directory: {e}"); + PathBuf::from(&std::path::Component::CurDir) + }) +} + +fn default_max_file_size() -> usize { + 5 * 1024 * 1024 +} + +fn default_https_certificate_cache_dir() -> PathBuf { + default_web_root().join("sqlpage").join("https") +} + +fn default_https_acme_directory_url() -> String { + "https://acme-v02.api.letsencrypt.org/directory".to_string() +} + +/// If the sending queue exceeds this number of outgoing messages, an error will be thrown +/// This prevents a single request from using up all available memory +fn default_max_pending_rows() -> usize { + 256 +} + +fn default_compress_responses() -> bool { + false +} + +fn default_system_root_ca_certificates() -> bool { + std::env::var("SSL_CERT_FILE").is_ok_and(|x| !x.is_empty()) + || std::env::var("SSL_CERT_DIR").is_ok_and(|x| !x.is_empty()) +} + +fn default_max_recursion_depth() -> u8 { + 10 +} + +fn default_markdown_allow_dangerous_html() -> bool { + false +} + +fn default_markdown_allow_dangerous_protocol() -> bool { + false +} + +fn default_oidc_client_id() -> String { + "sqlpage".to_string() +} + +fn default_oidc_scopes() -> String { + "openid email profile".to_string() +} + +fn default_oidc_protected_paths() -> Vec { + vec!["/".to_string()] +} + +#[derive(Debug, Deserialize, Serialize, PartialEq, Clone, Copy, Eq, Default)] +#[serde(rename_all = "lowercase")] +pub enum DevOrProd { + #[default] + Development, + Production, +} +impl DevOrProd { + pub(crate) fn is_prod(self) -> bool { + self == DevOrProd::Production + } +} + +fn deserialize_duration_seconds<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let seconds: Option = Option::deserialize(deserializer)?; + match seconds { + None => Ok(None), + Some(s) if s <= 0.0 || !s.is_finite() => Ok(Some(Duration::ZERO)), + Some(s) => Ok(Some(Duration::from_secs_f64(s))), + } +} + +fn resolve_timeout(config_val: Option, default: Option) -> Option { + match config_val { + Some(v) if v.is_zero() => None, + Some(v) => Some(v), + None => default, + } +} + +#[must_use] +pub fn test_database_url() -> String { + std::env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite::memory:".to_string()) +} + +#[cfg(test)] +pub mod tests { + use super::AppConfig; + pub use super::test_database_url; + + #[must_use] + pub fn test_config() -> AppConfig { + let mut config = serde_json::from_str::( + &serde_json::json!({ + "database_url": test_database_url(), + "listen_on": "localhost:8080" + }) + .to_string(), + ) + .unwrap(); + config.resolve_timeouts(); + config + } +} + +#[cfg(test)] +mod test { + use super::*; + use std::env; + use std::sync::Mutex; + + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + #[test] + fn test_default_site_prefix() { + assert_eq!(default_site_prefix(), "/".to_string()); + } + + #[test] + fn test_encode_uri() { + assert_eq!( + encode_uri(Path::new("/hello world/xxx.db")), + "/hello world/xxx.db" + ); + assert_eq!(encode_uri(Path::new("é")), "%C3%A9"); + assert_eq!(encode_uri(Path::new("/a?b/c")), "/a%3Fb/c"); + } + + #[test] + fn test_normalize_site_prefix() { + assert_eq!(normalize_site_prefix(""), "/"); + assert_eq!(normalize_site_prefix("/"), "/"); + assert_eq!(normalize_site_prefix("a"), "/a/"); + assert_eq!(normalize_site_prefix("a/"), "/a/"); + assert_eq!(normalize_site_prefix("/a"), "/a/"); + assert_eq!(normalize_site_prefix("a/b"), "/a/b/"); + assert_eq!(normalize_site_prefix("a/b/"), "/a/b/"); + assert_eq!(normalize_site_prefix("a/b/c"), "/a/b/c/"); + assert_eq!(normalize_site_prefix("a b"), "/a%20b/"); + assert_eq!(normalize_site_prefix("a b/c"), "/a%20b/c/"); + assert_eq!(normalize_site_prefix("*-+/:;,?%\"'{"), "/*-+/:;,%3F%%22'{/"); + assert_eq!( + normalize_site_prefix(&(0..=0x7F).map(char::from).collect::()), + "/%00%01%02%03%04%05%06%07%08%0B%0C%0E%0F%10%11%12%13%14%15%16%17%18%19%1A%1B%1C%1D%1E%1F%20!%22%23$%&'()*+,-./0123456789:;%3C=%3E%3F@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~%7F/" + ); + } + + #[test] + fn test_sqlpage_prefixed_env_variable_parsing() { + let _lock = ENV_LOCK + .lock() + .expect("Another test panicked while holding the lock"); + unsafe { + env::set_var("SQLPAGE_CONFIGURATION_DIRECTORY", "/path/to/config"); + } + + let config = load_from_env().unwrap(); + + assert_eq!( + config.configuration_directory, + PathBuf::from("/path/to/config"), + "Configuration directory should match the SQLPAGE_CONFIGURATION_DIRECTORY env var" + ); + + unsafe { + env::remove_var("SQLPAGE_CONFIGURATION_DIRECTORY"); + } + } + + #[test] + fn test_k8s_env_var_ignored() { + let _lock = ENV_LOCK + .lock() + .expect("Another test panicked while holding the lock"); + unsafe { + env::set_var("SQLPAGE_PORT", "tcp://10.0.0.1:8080"); + } + + let config = load_from_env().unwrap(); + assert_eq!(config.port, None); + + unsafe { + env::remove_var("SQLPAGE_PORT"); + } + } + + #[test] + fn test_valid_port_env_var() { + let _lock = ENV_LOCK + .lock() + .expect("Another test panicked while holding the lock"); + unsafe { + env::set_var("SQLPAGE_PORT", "9000"); + } + + let config = load_from_env().unwrap(); + assert_eq!(config.port, Some(9000)); + + unsafe { + env::remove_var("SQLPAGE_PORT"); + } + } + + #[test] + fn test_config_priority() { + let _lock = ENV_LOCK + .lock() + .expect("Another test panicked while holding the lock"); + unsafe { + env::set_var("SQLPAGE_WEB_ROOT", "/"); + } + + let cli = Cli { + web_root: Some(PathBuf::from(".")), + config_dir: None, + config_file: None, + command: None, + }; + + let config = AppConfig::from_cli(&cli).unwrap(); + + assert_eq!( + config.web_root, + PathBuf::from("."), + "CLI argument should take precedence over environment variable" + ); + + unsafe { + env::remove_var("SQLPAGE_WEB_ROOT"); + } + } + + #[test] + fn test_config_file_priority() { + let _lock = ENV_LOCK + .lock() + .expect("Another test panicked while holding the lock"); + let temp_dir = std::env::temp_dir().join("sqlpage_test"); + std::fs::create_dir_all(&temp_dir).unwrap(); + let config_file_path = temp_dir.join("sqlpage.json"); + let config_web_dir = temp_dir.join("config/web"); + let env_web_dir = temp_dir.join("env/web"); + let cli_web_dir = temp_dir.join("cli/web"); + std::fs::create_dir_all(&config_web_dir).unwrap(); + std::fs::create_dir_all(&env_web_dir).unwrap(); + std::fs::create_dir_all(&cli_web_dir).unwrap(); + + let config_content = serde_json::json!({ + "web_root": config_web_dir.to_str().unwrap() + }) + .to_string(); + std::fs::write(&config_file_path, config_content).unwrap(); + + unsafe { + env::set_var("SQLPAGE_WEB_ROOT", env_web_dir.to_str().unwrap()); + } + + let cli = Cli { + web_root: None, + config_dir: None, + config_file: Some(config_file_path.clone()), + command: None, + }; + + let config = AppConfig::from_cli(&cli).unwrap(); + + assert_eq!( + config.web_root, env_web_dir, + "Environment variable should override config file" + ); + assert_eq!( + config.configuration_directory, + cannonicalize_if_possible(&PathBuf::from("./sqlpage")), + "Configuration directory should be default when not overridden" + ); + + let cli_with_web_root = Cli { + web_root: Some(cli_web_dir.clone()), + config_dir: None, + config_file: Some(config_file_path), + command: None, + }; + + let config = AppConfig::from_cli(&cli_with_web_root).unwrap(); + assert_eq!( + config.web_root, cli_web_dir, + "CLI argument should take precedence over environment variable and config file" + ); + assert_eq!( + config.configuration_directory, + cannonicalize_if_possible(&PathBuf::from("./sqlpage")), + "Configuration directory should remain unchanged" + ); + + unsafe { + env::remove_var("SQLPAGE_WEB_ROOT"); + } + std::fs::remove_dir_all(&temp_dir).unwrap(); + } + + #[test] + fn test_default_values() { + let _lock = ENV_LOCK + .lock() + .expect("Another test panicked while holding the lock"); + unsafe { + env::remove_var("SQLPAGE_CONFIGURATION_DIRECTORY"); + } + unsafe { + env::remove_var("SQLPAGE_WEB_ROOT"); + } + + let cli = Cli { + web_root: None, + config_dir: None, + config_file: None, + command: None, + }; + + let config = AppConfig::from_cli(&cli).unwrap(); + + assert_eq!( + config.web_root, + default_web_root(), + "Web root should default to current directory when not specified" + ); + assert_eq!( + config.configuration_directory, + cannonicalize_if_possible(&PathBuf::from("./sqlpage")), + "Configuration directory should default to ./sqlpage when not specified" + ); + } +} diff --git a/src/cli/arguments.rs b/src/cli/arguments.rs new file mode 100644 index 0000000..5ba5207 --- /dev/null +++ b/src/cli/arguments.rs @@ -0,0 +1,43 @@ +use super::commands::SubCommand; +use clap::Parser; +use std::path::PathBuf; + +#[derive(Parser)] +#[clap(author, version, about, long_about = None)] +pub struct Cli { + /// The directory where the .sql files are located. + #[clap(short, long)] + pub web_root: Option, + /// The directory where the sqlpage.json configuration, the templates, and the migrations are located. + #[clap(short = 'd', long)] + pub config_dir: Option, + /// The path to the configuration file. + #[clap(short = 'c', long)] + pub config_file: Option, + + /// Subcommands for additional functionality. + #[clap(subcommand)] + pub command: Option, +} + +pub fn parse_cli() -> anyhow::Result { + let cli = Cli::parse(); + Ok(cli) +} + +#[test] +fn test_cli_argument_parsing() { + let cli = Cli::parse_from([ + "sqlpage", + "--web-root", + "/path/to/web", + "--config-dir", + "/path/to/config", + "--config-file", + "/path/to/config.json", + ]); + + assert_eq!(cli.web_root, Some(PathBuf::from("/path/to/web"))); + assert_eq!(cli.config_dir, Some(PathBuf::from("/path/to/config"))); + assert_eq!(cli.config_file, Some(PathBuf::from("/path/to/config.json"))); +} diff --git a/src/cli/commands.rs b/src/cli/commands.rs new file mode 100644 index 0000000..afd3d3b --- /dev/null +++ b/src/cli/commands.rs @@ -0,0 +1,74 @@ +use chrono::Utc; +use clap::Parser; +use std::path::Path; + +use crate::app_config::AppConfig; + +/// Sub-commands for the sqlpage CLI. +/// Each subcommand can be executed using the `sqlpage ` from the command line. +#[derive(Parser)] +pub enum SubCommand { + /// Create a new migration file. + CreateMigration { + /// Name of the migration. + migration_name: String, + }, +} + +impl SubCommand { + /// Execute the subcommand. + pub async fn execute(&self, app_config: AppConfig) -> anyhow::Result<()> { + match self { + SubCommand::CreateMigration { migration_name } => { + // Pass configuration_directory from app_config + create_migration_file(migration_name, &app_config.configuration_directory).await?; + Ok(()) + } + } + } +} + +async fn create_migration_file( + migration_name: &str, + configuration_directory: &Path, +) -> anyhow::Result<()> { + let timestamp = Utc::now().format("%Y%m%d%H%M%S").to_string(); + let snake_case_name = migration_name + .replace(|c: char| !c.is_alphanumeric(), "_") + .to_lowercase(); + let file_name = format!("{timestamp}_{snake_case_name}.sql"); + let migrations_dir = Path::new(configuration_directory).join("migrations"); + + if !migrations_dir.exists() { + tokio::fs::create_dir_all(&migrations_dir).await?; + } + + let mut unique_file_name = file_name.clone(); + let mut counter = 1; + + while migrations_dir.join(&unique_file_name).exists() { + unique_file_name = format!("{timestamp}_{snake_case_name}_{counter}.sql"); + counter += 1; + } + + let file_path = migrations_dir.join(unique_file_name); + tokio::fs::write(&file_path, "-- Write your migration here\n").await?; + + // the following code cleans up the display path to show where the migration was created + // relative to the current working directory, and then outputs the path to the migration + let file_path_canon = file_path.canonicalize().unwrap_or(file_path.clone()); + let cwd_canon = std::env::current_dir()? + .canonicalize() + .unwrap_or(std::env::current_dir()?); + let rel_path = match file_path_canon.strip_prefix(&cwd_canon) { + Ok(p) => p, + Err(_) => file_path_canon.as_path(), + }; + let mut display_path_str = rel_path.display().to_string(); + if display_path_str.starts_with("\\\\?\\") { + display_path_str = display_path_str.trim_start_matches("\\\\?\\").to_string(); + } + display_path_str = display_path_str.replace('\\', "/"); + println!("Migration file created: {display_path_str}"); + Ok(()) +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs new file mode 100644 index 0000000..43e12ad --- /dev/null +++ b/src/cli/mod.rs @@ -0,0 +1,2 @@ +pub mod arguments; +pub mod commands; diff --git a/src/default_404.sql b/src/default_404.sql new file mode 100644 index 0000000..9955c98 --- /dev/null +++ b/src/default_404.sql @@ -0,0 +1,31 @@ +SELECT + 'shell' as component, + 'Page Not Found' as title, + 'error-404' as body_class, + '/' as link; + +SELECT + 'empty_state' as component, + 'Page Not Found' as title, + '404' as header, + 'The page you were looking for does not exist.' as description_md, + 'Go to Homepage' as link_text, + 'home' as link_icon, + '/' as link; + +select + 'text' as component, + ' +> **Routing Debug Info** +> When a URL is requested, SQLPage looks for matching files in this order: +> 1. **Exact filename match** (e.g. `page.html` for `/page.html`) +> 2. **For paths ending with `/`**: +> - Looks for `index.sql` in that directory (e.g. `/dir/` → `dir/index.sql`) +> 3. **For paths without extensions**: +> - First tries adding `.sql` extension (e.g. `/dir/page` → `dir/page.sql`) +> - If not found, redirects to add trailing `/` (e.g. `/dir` → `/dir/`) +> 4. **If no matches found**: +> - Searches for `404.sql` in current and parent directories (e.g. `dir/x/y/` could use `dir/404.sql`) +> +> Try creating one of these files to handle this route. +' as contents_md; \ No newline at end of file diff --git a/src/dynamic_component.rs b/src/dynamic_component.rs new file mode 100644 index 0000000..058a254 --- /dev/null +++ b/src/dynamic_component.rs @@ -0,0 +1,218 @@ +use anyhow::{self, Context as _}; +use serde_json::Value as JsonValue; + +use crate::webserver::database::DbItem; + +pub fn parse_dynamic_rows(row: DbItem) -> impl Iterator { + DynamicComponentIterator { + stack: vec![], + db_item: Some(row), + } +} + +struct DynamicComponentIterator { + stack: Vec>, + db_item: Option, +} + +impl Iterator for DynamicComponentIterator { + type Item = DbItem; + + fn next(&mut self) -> Option { + if let Some(db_item) = self.db_item.take() { + if let DbItem::Row(mut row) = db_item { + match extract_dynamic_properties(&mut row) { + Ok(None) => { + // Most common case: just a regular row. We allocated nothing. + return Some(DbItem::Row(row)); + } + Ok(Some(properties)) => { + self.stack = dynamic_properties_to_vec(properties); + } + Err(err) => { + return Some(DbItem::Error(err)); + } + } + } else { + return Some(db_item); + } + } + expand_dynamic_stack(&mut self.stack); + self.stack.pop().map(|result| match result { + Ok(row) => DbItem::Row(row), + Err(err) => DbItem::Error(err), + }) + } +} + +fn expand_dynamic_stack(stack: &mut Vec>) { + while let Some(mut next) = stack.pop() { + let next_value = next.as_mut().ok(); + // .and_then(extract_dynamic_properties); + let dyn_props = if let Some(val) = next_value { + extract_dynamic_properties(val) + } else { + Ok(None) + }; + match dyn_props { + Ok(None) => { + // If the properties are not dynamic, push the row back onto the stack + stack.push(next); + // return at the first non-dynamic row + // we don't support non-dynamic rows after dynamic rows nested in the same array + return; + } + Ok(Some(properties)) => { + // if the properties contain new (nested) dynamic components, push them onto the stack + stack.extend(dynamic_properties_to_vec(properties)); + } + Err(err) => { + // if an error occurs, push it onto the stack + stack.push(Err(err)); + } + } + } +} + +/// if row.component == 'dynamic', return Some(row.properties), otherwise return None +#[inline] +fn extract_dynamic_properties(data: &mut JsonValue) -> anyhow::Result> { + let component = data.get("component").and_then(|v| v.as_str()); + if component == Some("dynamic") { + let Some(properties) = data.get_mut("properties").map(JsonValue::take) else { + anyhow::bail!( + "The dynamic component requires a property named \"properties\". \ + Instead, it received the following: {data}" + ); + }; + Ok(Some(properties)) + } else { + Ok(None) + } +} + +/// reverse the order of the vec returned by `dynamic_properties_to_result_vec`, +/// and wrap each element in a Result +fn dynamic_properties_to_vec(properties_obj: JsonValue) -> Vec> { + dynamic_properties_to_result_vec(properties_obj).map_or_else( + |err| vec![Err(err)], + |vec| vec.into_iter().rev().map(Ok).collect::>(), + ) +} + +/// if properties is a string, parse it as JSON and return a vec with the parsed value +/// if properties is an array, return it as is +/// if properties is an object, return it as a single element vec +/// otherwise, return an error +fn dynamic_properties_to_result_vec( + mut properties_obj: JsonValue, +) -> anyhow::Result> { + if let JsonValue::String(s) = properties_obj { + properties_obj = serde_json::from_str::(&s) + .with_context(|| format!("Invalid json in dynamic component properties: {s}"))?; + } + match properties_obj { + obj @ JsonValue::Object(_) => Ok(vec![obj]), + JsonValue::Array(values) => { + let mut vec = Vec::with_capacity(values.len()); + for value in values { + vec.extend_from_slice(&dynamic_properties_to_result_vec(value)?); + } + Ok(vec) + } + other => anyhow::bail!( + "Dynamic component expected properties of type array or object, got {other} instead." + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_dynamic_properties_to_result_vec() { + let mut properties = JsonValue::String(r#"{"a": 1}"#.to_string()); + assert_eq!( + dynamic_properties_to_result_vec(properties.clone()).unwrap(), + vec![JsonValue::Object( + serde_json::from_str(r#"{"a": 1}"#).unwrap() + )] + ); + + properties = JsonValue::Array(vec![JsonValue::String(r#"{"a": 1}"#.to_string())]); + assert_eq!( + dynamic_properties_to_result_vec(properties.clone()).unwrap(), + vec![serde_json::json!({"a": 1})] + ); + + properties = JsonValue::Object(serde_json::from_str(r#"{"a": 1}"#).unwrap()); + assert_eq!( + dynamic_properties_to_result_vec(properties.clone()).unwrap(), + vec![JsonValue::Object( + serde_json::from_str(r#"{"a": 1}"#).unwrap() + )] + ); + + properties = JsonValue::Null; + assert!(dynamic_properties_to_result_vec(properties).is_err()); + } + + #[test] + fn test_dynamic_properties_to_vec() { + let properties = JsonValue::String(r#"{"a": 1}"#.to_string()); + assert_eq!( + dynamic_properties_to_vec(properties.clone()) + .first() + .unwrap() + .as_ref() + .unwrap(), + &serde_json::json!({"a": 1}) + ); + } + + #[test] + fn test_parse_dynamic_rows() { + let row = DbItem::Row(serde_json::json!({ + "component": "dynamic", + "properties": [ + {"a": 1}, + {"component": "dynamic", "properties": {"nested": 2}}, + ] + })); + let iter = parse_dynamic_rows(row) + .map(|item| match item { + DbItem::Row(row) => row, + x => panic!("Expected a row, got {x:?}"), + }) + .collect::>(); + assert_eq!( + iter, + vec![ + serde_json::json!({"a": 1}), + serde_json::json!({"nested": 2}), + ] + ); + } + + #[test] + fn test_parse_dynamic_array_json_strings() { + let row = DbItem::Row(serde_json::json!({ + "component": "dynamic", + "properties": [ + r#"{"a": 1}"#, + r#"{"b": 2}"#, + ] + })); + let iter = parse_dynamic_rows(row) + .map(|item| match item { + DbItem::Row(row) => row, + x => panic!("Expected a row, got {x:?}"), + }) + .collect::>(); + assert_eq!( + iter, + vec![serde_json::json!({"a": 1}), serde_json::json!({"b": 2}),] + ); + } +} diff --git a/src/file_cache.rs b/src/file_cache.rs new file mode 100644 index 0000000..7287e41 --- /dev/null +++ b/src/file_cache.rs @@ -0,0 +1,238 @@ +use crate::AppState; +use crate::filesystem::FileAccess; +use crate::webserver::ErrorWithStatus; +use crate::webserver::routing::FileStore; +use actix_web::http::StatusCode; +use anyhow::Context; +use async_trait::async_trait; +use chrono::{DateTime, TimeZone, Utc}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{ + AtomicU64, + Ordering::{Acquire, Release}, +}; +use std::time::SystemTime; +use tokio::sync::RwLock; + +#[derive(Default)] +struct Cached { + last_checked_at: AtomicU64, + content: Arc, +} + +impl Cached { + fn new(content: T) -> Self { + let s = Self { + last_checked_at: AtomicU64::new(0), + content: Arc::new(content), + }; + s.update_check_time(); + s + } + fn last_check_time(&self) -> DateTime { + let millis = self.last_checked_at.load(Acquire); + let as_i64 = i64::try_from(millis).expect("file timestamp out of bound"); + Utc.timestamp_millis_opt(as_i64) + .single() + .expect("utc has a single mapping for every timestamp") + } + fn update_check_time(&self) { + self.last_checked_at.store(Self::now_millis(), Release); + } + fn now_millis() -> u64 { + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .expect("invalid duration") + .as_millis() + .try_into() + .expect("invalid date") + } + fn needs_check(&self, stale_cache_duration_ms: u64) -> bool { + self.last_checked_at + .load(Acquire) + .saturating_add(stale_cache_duration_ms) + < Self::now_millis() + } + /// Creates a new cached entry with the same content but a new check time set to now + fn make_fresh(&self) -> Self { + Self { + last_checked_at: AtomicU64::from(Self::now_millis()), + content: Arc::clone(&self.content), + } + } +} + +pub struct FileCache { + cache: Arc>>>, + /// Files that are loaded at the beginning of the program, + /// and used as fallback when there is no match for the request in the file system + static_files: HashMap>, +} + +impl FileStore for FileCache { + async fn contains(&self, access: FileAccess<'_>) -> anyhow::Result { + let path = access.path(); + Ok(self.cache.read().await.contains_key(path) || self.static_files.contains_key(path)) + } +} + +impl Default for FileCache { + fn default() -> Self { + Self::new() + } +} + +impl FileCache { + #[must_use] + pub fn new() -> Self { + Self { + cache: Arc::default(), + static_files: HashMap::new(), + } + } + + /// Adds a static file to the cache so that it will never be looked up from the disk + pub fn add_static(&mut self, path: PathBuf, contents: T) { + log::trace!("Adding static file {} to the cache.", path.display()); + self.static_files.insert(path, Cached::new(contents)); + } + + pub fn get_static(&self, path: &Path) -> anyhow::Result> { + self.static_files + .get(path) + .map(|cached| Arc::clone(&cached.content)) + .ok_or_else(|| anyhow::anyhow!("File {} not found in static files", path.display())) + } + + /// Gets a file from the cache, or loads it from the file system if it's not there. + pub async fn get( + &self, + app_state: &AppState, + access: FileAccess<'_>, + ) -> anyhow::Result> { + let path = access.path(); + + log::trace!("Attempting to get from cache {}", path.display()); + if let Some(cached) = self.cache.read().await.get(path) { + if !cached.needs_check(app_state.config.cache_stale_duration_ms()) { + log::trace!( + "Cache answer without filesystem lookup for {}", + path.display() + ); + return Ok(Arc::clone(&cached.content)); + } + match app_state + .file_system + .modified_since(app_state, access, cached.last_check_time()) + .await + { + Ok(false) => { + log::trace!( + "Cache answer with filesystem metadata read for {}", + path.display() + ); + cached.update_check_time(); + return Ok(Arc::clone(&cached.content)); + } + Ok(true) => log::trace!("{} was changed, updating cache...", path.display()), + Err(e) => log::trace!( + "Cannot read metadata of {}, re-loading it: {:#}", + path.display(), + e + ), + } + } + // Read lock is released + log::trace!("Loading and parsing {}", path.display()); + let file_contents = app_state + .file_system + .read_to_string(app_state, access) + .await; + + let parsed = match file_contents { + Ok(contents) => { + let value = T::from_str_with_state(app_state, &contents, path).await?; + Ok(Cached::new(value)) + } + // If a file is not found, we try to load it from the static files + Err(e) + if e.downcast_ref() + == Some(&ErrorWithStatus { + status: StatusCode::NOT_FOUND, + }) => + { + if let Some(static_file) = self.static_files.get(path) { + log::trace!( + "File {} not found, loading it from static files instead.", + path.display() + ); + let cached: Cached = static_file.make_fresh(); + Ok(cached) + } else { + Err(e) + .with_context(|| format!("Couldn't load \"{}\" into cache", path.display())) + } + } + Err(e) => { + Err(e).with_context(|| format!("Couldn't load {} into cache", path.display())) + } + }; + + match parsed { + Ok(value) => { + let new_val = Arc::clone(&value.content); + log::trace!("Writing to cache {}", path.display()); + self.cache.write().await.insert(PathBuf::from(path), value); + log::trace!("Done writing to cache {}", path.display()); + log::trace!("{} loaded in cache", path.display()); + Ok(new_val) + } + Err(e) => { + log::trace!( + "Evicting {} from the cache because the following error occurred: {}", + path.display(), + e + ); + log::trace!("Removing from cache {}", path.display()); + self.cache.write().await.remove(path); + log::trace!("Done removing from cache {}", path.display()); + Err(e) + } + } + } +} + +#[async_trait(? Send)] +pub trait AsyncFromStrWithState: Sized { + /// Parses the string into an object. + async fn from_str_with_state( + app_state: &AppState, + source: &str, + source_path: &Path, + ) -> anyhow::Result; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_cache_duration() { + let cached = Cached::new(()); + assert!( + !cached.needs_check(1000), + "Should not need check immediately after creation" + ); + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + assert!( + !cached.needs_check(1000), + "Should not need check before duration expires" + ); + assert!( + cached.needs_check(1), + "Should need check after duration expires" + ); + } +} diff --git a/src/filesystem.rs b/src/filesystem.rs new file mode 100644 index 0000000..ce508b0 --- /dev/null +++ b/src/filesystem.rs @@ -0,0 +1,499 @@ +use crate::webserver::ErrorWithStatus; +use crate::webserver::database::SupportedDatabase; +use crate::webserver::{Database, StatusCodeResultExt, make_placeholder}; +use crate::{AppState, TEMPLATES_DIR}; +use anyhow::Context; +use chrono::{DateTime, Utc}; +use sqlx::any::{AnyStatement, AnyTypeInfo}; +use sqlx::postgres::types::PgTimeTz; +use sqlx::{Executor, Postgres, Statement, Type}; +use std::fmt::Write; +use std::io::ErrorKind; +use std::path::{Component, Path, PathBuf}; + +#[derive(Clone, Copy)] +#[must_use] +pub struct FileAccess<'a> { + path: &'a Path, + privileged: bool, +} + +impl<'a> FileAccess<'a> { + /// Creates access for an untrusted, HTTP-facing path after validating it. + pub fn unprivileged(path: &'a Path) -> anyhow::Result { + validate_unprivileged_path(path)?; + Ok(Self { + path, + privileged: false, + }) + } + + /// Creates access for a trusted internal path. + pub const fn privileged(path: &'a Path) -> Self { + Self { + path, + privileged: true, + } + } + + #[must_use] + pub const fn path(&self) -> &'a Path { + self.path + } +} + +pub(crate) struct FileSystem { + local_root: PathBuf, + db_fs_queries: Option, +} + +impl FileSystem { + pub async fn init(local_root: impl Into, db: &Database) -> Self { + Self { + local_root: local_root.into(), + db_fs_queries: match DbFsQueries::init(db).await { + Ok(q) => Some(q), + Err(e) => { + log::debug!( + "Using local filesystem only, could not initialize on-database filesystem. \ + You can host sql files directly in your database by creating the following table: \n\ + {} \n\ + The error while trying to use the database file system is: {e:#}", + DbFsQueries::get_create_table_sql(db.info.database_type) + ); + None + } + }, + } + } + + pub async fn modified_since( + &self, + app_state: &AppState, + access: FileAccess<'_>, + since: DateTime, + ) -> anyhow::Result { + let path = access.path(); + let local_path = self.safe_local_path(app_state, access); + let local_result = file_modified_since_local(&local_path, since).await; + log::trace!( + "Local file {} modified since {since:?} ? {local_result:?}", + local_path.display() + ); + match (local_result, &self.db_fs_queries) { + (Ok(modified), _) => Ok(modified), + (Err(e), Some(db_fs)) if is_path_missing_error(&e) => { + // no local file, try the database + db_fs + .file_modified_since_in_db(app_state, path, since) + .await + } + (Err(e), _) => { + let status = io_error_status(&e) + .unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR); + Err(e).with_status(status).with_context(|| { + format!("Unable to read local file metadata for {}", path.display()) + }) + } + } + } + + pub async fn read_to_string( + &self, + app_state: &AppState, + access: FileAccess<'_>, + ) -> anyhow::Result { + let path = access.path(); + let bytes = self.read_file(app_state, access).await?; + String::from_utf8(bytes).map_err(|utf8_err| { + let invalid_idx = utf8_err.utf8_error().valid_up_to(); + let bytes = utf8_err.into_bytes(); + let valid_prefix = String::from_utf8_lossy(&bytes[..invalid_idx]); + let line_num = valid_prefix.lines().count(); + let mut bad_seq = valid_prefix.lines().last().unwrap_or_default().to_string(); + let bad_char_idx = bad_seq.len() + 1; + for b in bytes[invalid_idx..].iter().take(8) { + write!(&mut bad_seq, "\\x{b:02X}").unwrap(); + } + + let display_path = path.display(); + anyhow::format_err!( + "SQLPage expects all sql files to be encoded in UTF-8. \n\ + In \"{display_path}\", around line {line_num} character {bad_char_idx}, the following invalid UTF-8 byte sequence was found: \n\ + \"{bad_seq}\". \n\ + Please convert the file to UTF-8.", + ) + }) + } + + pub async fn read_file( + &self, + app_state: &AppState, + access: FileAccess<'_>, + ) -> anyhow::Result> { + let path = access.path(); + let local_path = self.safe_local_path(app_state, access); + log::debug!( + "Reading file {} from {}", + path.display(), + local_path.display() + ); + let local_result = tokio::fs::read(&local_path).await; + match (local_result, &self.db_fs_queries) { + (Ok(f), _) => Ok(f), + (Err(e), Some(db_fs)) if is_path_missing_error(&e) => { + // no local file, try the database + db_fs.read_file(app_state, path.as_ref()).await + } + (Err(e), None) if is_path_missing_error(&e) => Err(e) + .with_status(actix_web::http::StatusCode::NOT_FOUND) + .with_context(|| format!("Unable to read local file {}", path.display())), + (Err(e), _) => { + let status = io_error_status(&e) + .unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR); + Err(e) + .with_status(status) + .with_context(|| format!("Unable to read local file {}", path.display())) + } + } + } + + fn safe_local_path(&self, app_state: &AppState, access: FileAccess<'_>) -> PathBuf { + let path = access.path(); + if access.privileged { + // Templates requests are always made to the static TEMPLATES_DIR, because this is where they are stored in the database + // but when serving them from the filesystem, we need to serve them from the `SQLPAGE_CONFIGURATION_DIRECTORY/templates` directory + if let Ok(template_path) = path.strip_prefix(TEMPLATES_DIR) { + let normalized = app_state + .config + .configuration_directory + .join("templates") + .join(template_path); + log::trace!( + "Normalizing template path {} to {}", + path.display(), + normalized.display() + ); + return normalized; + } + } + self.local_root.join(path) + } + + pub(crate) async fn file_exists( + &self, + app_state: &AppState, + access: FileAccess<'_>, + ) -> anyhow::Result { + let path = access.path(); + let safe_path = self.safe_local_path(app_state, access); + let local_exists = match tokio::fs::try_exists(safe_path).await { + Ok(exists) => exists, + Err(e) if is_path_missing_error(&e) => false, + Err(e) => { + let status = io_error_status(&e) + .unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR); + return Err(e).with_status(status).with_context(|| { + format!("Unable to check if {} exists locally", path.display()) + }); + } + }; + + // If not in local fs and we have db_fs, check database + if !local_exists { + log::debug!( + "File {} not found in local filesystem, checking database", + path.display() + ); + if let Some(db_fs) = &self.db_fs_queries { + return db_fs.file_exists(app_state, path).await; + } + } + Ok(local_exists) + } +} + +/// Rejects paths that an untrusted HTTP request must never reach: the reserved +/// `sqlpage/` prefix, dotfiles, parent-directory traversal and absolute/root paths. +fn validate_unprivileged_path(path: &Path) -> anyhow::Result<()> { + for (i, component) in path.components().enumerate() { + if let Component::Normal(c) = component { + if i == 0 && c.eq_ignore_ascii_case("sqlpage") { + return Err(ErrorWithStatus { + status: actix_web::http::StatusCode::FORBIDDEN, + }) + .with_context( + || "The /sqlpage/ path prefix is reserved for internal use. It is not public.", + ); + } + if c.as_encoded_bytes().starts_with(b".") { + return Err(ErrorWithStatus { + status: actix_web::http::StatusCode::FORBIDDEN, + }) + .with_context(|| "Directory traversal is not allowed"); + } + } else { + return Err(ErrorWithStatus { + status: actix_web::http::StatusCode::FORBIDDEN, + }) + .with_context(|| { + format!( + "Unsupported path: {}. Path component '{component:?}' is not allowed.", + path.display() + ) + }); + } + } + Ok(()) +} + +fn is_path_missing_error(error: &std::io::Error) -> bool { + matches!(error.kind(), ErrorKind::NotFound | ErrorKind::NotADirectory) +} + +fn io_error_status(error: &std::io::Error) -> Option { + match error.kind() { + ErrorKind::NotFound | ErrorKind::NotADirectory => { + Some(actix_web::http::StatusCode::NOT_FOUND) + } + ErrorKind::PermissionDenied => Some(actix_web::http::StatusCode::FORBIDDEN), + _ => None, + } +} + +async fn file_modified_since_local(path: &Path, since: DateTime) -> tokio::io::Result { + tokio::fs::metadata(path) + .await + .and_then(|m| m.modified()) + .map(|modified_at| DateTime::::from(modified_at) > since) +} + +pub struct DbFsQueries { + was_modified: AnyStatement<'static>, + read_file: AnyStatement<'static>, + exists: AnyStatement<'static>, +} + +impl DbFsQueries { + #[must_use] + pub fn get_create_table_sql(dbms: SupportedDatabase) -> &'static str { + match dbms { + SupportedDatabase::Mssql => { + "CREATE TABLE sqlpage_files(path NVARCHAR(255) NOT NULL PRIMARY KEY, contents VARBINARY(MAX), last_modified DATETIME2(3) NOT NULL DEFAULT CURRENT_TIMESTAMP);" + } + SupportedDatabase::Postgres => { + "CREATE TABLE IF NOT EXISTS sqlpage_files(path VARCHAR(255) NOT NULL PRIMARY KEY, contents BYTEA, last_modified TIMESTAMP DEFAULT CURRENT_TIMESTAMP);" + } + SupportedDatabase::Snowflake => { + "CREATE TABLE IF NOT EXISTS sqlpage_files(path VARCHAR(255) NOT NULL PRIMARY KEY, contents VARBINARY, last_modified TIMESTAMP_TZ DEFAULT CONVERT_TIMEZONE('UTC', CURRENT_TIMESTAMP()));" + } + _ => { + "CREATE TABLE IF NOT EXISTS sqlpage_files(path VARCHAR(255) NOT NULL PRIMARY KEY, contents BLOB, last_modified TIMESTAMP DEFAULT CURRENT_TIMESTAMP);" + } + } + } + + async fn init(db: &Database) -> anyhow::Result { + log::debug!("Initializing database filesystem queries"); + Self::check_table_available(db).await?; + Ok(Self { + was_modified: Self::make_was_modified_query(db).await?, + read_file: Self::make_read_file_query(db).await?, + exists: Self::make_exists_query(db).await?, + }) + } + + async fn check_table_available(db: &Database) -> anyhow::Result<()> { + db.connection + .execute("SELECT 1 FROM sqlpage_files WHERE 1 = 0") + .await + .map(|_| ()) + .context("Unable to access sqlpage_files")?; + Ok(()) + } + + async fn make_was_modified_query(db: &Database) -> anyhow::Result> { + let was_modified_query = format!( + "SELECT 1 from sqlpage_files WHERE last_modified >= {} AND path = {}", + make_placeholder(db.info.kind, 1), + make_placeholder(db.info.kind, 2) + ); + let param_types: &[AnyTypeInfo; 2] = &[ + PgTimeTz::type_info().into(), + >::type_info().into(), + ]; + log::debug!("Preparing the database filesystem was_modified_query: {was_modified_query}"); + db.prepare_with(&was_modified_query, param_types).await + } + + async fn make_read_file_query(db: &Database) -> anyhow::Result> { + let read_file_query = format!( + "SELECT contents from sqlpage_files WHERE path = {}", + make_placeholder(db.info.kind, 1), + ); + let param_types: &[AnyTypeInfo; 1] = &[>::type_info().into()]; + log::debug!("Preparing the database filesystem read_file_query: {read_file_query}"); + db.prepare_with(&read_file_query, param_types).await + } + + async fn make_exists_query(db: &Database) -> anyhow::Result> { + let exists_query = format!( + "SELECT 1 from sqlpage_files WHERE path = {}", + make_placeholder(db.info.kind, 1), + ); + let param_types: &[AnyTypeInfo; 1] = &[>::type_info().into()]; + db.prepare_with(&exists_query, param_types).await + } + + async fn file_modified_since_in_db( + &self, + app_state: &AppState, + path: &Path, + since: DateTime, + ) -> anyhow::Result { + let query = self + .was_modified + .query_as::<(i32,)>() + .bind(since) + .bind(path.display().to_string()); + log::trace!( + "Checking if file {} was modified since {} by executing query: \n\ + {}\n\ + with parameters: {:?}", + path.display(), + since, + self.was_modified.sql(), + (since, path) + ); + let was_modified_i32 = query + .fetch_optional(&app_state.db.connection) + .await + .with_context(|| { + format!( + "Unable to check when {} was last modified in the database", + path.display() + ) + })?; + log::trace!( + "DB File {} was modified result: {was_modified_i32:?}", + path.display() + ); + Ok(was_modified_i32 == Some((1,))) + } + + async fn read_file(&self, app_state: &AppState, path: &Path) -> anyhow::Result> { + log::debug!("Reading file {} from the database", path.display()); + self.read_file + .query_as::<(Vec,)>() + .bind(path.display().to_string()) + .fetch_optional(&app_state.db.connection) + .await + .map_err(anyhow::Error::from) + .and_then(|modified| { + if let Some((modified,)) = modified { + Ok(modified) + } else { + Err(ErrorWithStatus { + status: actix_web::http::StatusCode::NOT_FOUND, + } + .into()) + } + }) + .with_context(|| format!("Unable to read {} from the database", path.display())) + } + + async fn file_exists(&self, app_state: &AppState, path: &Path) -> anyhow::Result { + let query = self + .exists + .query_as::<(i32,)>() + .bind(path.display().to_string()); + log::trace!( + "Checking if file {} exists by executing query: \n\ + {}\n\ + with parameters: {:?}", + path.display(), + self.exists.sql(), + (path,) + ); + let result = query.fetch_optional(&app_state.db.connection).await; + log::debug!("DB File exists result: {result:?}"); + result.map(|result| result.is_some()).with_context(|| { + format!( + "Unable to check if {} exists in the database", + path.display() + ) + }) + } +} + +#[actix_web::test] +async fn test_sql_file_read_utf8() -> anyhow::Result<()> { + use crate::app_config; + use sqlx::Executor; + let config = app_config::tests::test_config(); + let state = AppState::init(&config).await?; + + // Oracle has specific issues with implicit timestamp conversions and empty strings in this test setup + // so we skip it for Oracle to avoid complex workarounds in the main codebase. + if config.database_url.contains("Oracle") { + log::warn!( + "Skipping test_sql_file_read_utf8 for Oracle due to date format/implicit conversion issues" + ); + return Ok(()); + } + + let create_table_sql = DbFsQueries::get_create_table_sql(state.db.info.database_type); + let db = &state.db; + let conn = &db.connection; + conn.execute("DROP TABLE IF EXISTS sqlpage_files").await?; + log::debug!("Creating table sqlpage_files: {create_table_sql}"); + conn.execute(create_table_sql).await?; + + let dbms = db.info.kind; + let insert_sql = format!( + "INSERT INTO sqlpage_files(path, contents) VALUES ({}, {})", + make_placeholder(dbms, 1), + make_placeholder(dbms, 2) + ); + sqlx::query(&insert_sql) + .bind("unit test file.txt") + .bind("Héllö world! 😀".as_bytes()) + .execute(conn) + .await?; + + let fs = FileSystem::init("/", db).await; + let actual = fs + .read_to_string( + &state, + FileAccess::unprivileged("unit test file.txt".as_ref())?, + ) + .await?; + assert_eq!(actual, "Héllö world! 😀"); + + let one_hour_ago = Utc::now() - chrono::Duration::hours(1); + let one_hour_future = Utc::now() + chrono::Duration::hours(1); + + let was_modified = fs + .modified_since( + &state, + FileAccess::unprivileged("unit test file.txt".as_ref())?, + one_hour_ago, + ) + .await?; + + assert!(was_modified, "File should be modified since one hour ago"); + + let was_modified = fs + .modified_since( + &state, + FileAccess::unprivileged("unit test file.txt".as_ref())?, + one_hour_future, + ) + .await?; + assert!( + !was_modified, + "File should not be modified since one hour in the future" + ); + + Ok(()) +} diff --git a/src/index.sql b/src/index.sql new file mode 100644 index 0000000..35d82e9 --- /dev/null +++ b/src/index.sql @@ -0,0 +1,36 @@ +-- Welcome to SQLPage ! This is a short demonstration of a few things you can do with SQLPage +-- Using the 'shell' component at the top allows you to customize your web page, giving it a title and a description +select 'shell' as component, + 'SQLpage' as title, + '/' as link, + 'en' as lang, + 'Welcome to SQLPage' as description; +-- Making a web page with SQLPage works by using a set of predefined "components" +-- and filling them with contents from the results of your SQL queries +select 'hero' as component, -- We select a component. The documentation for each component can be found on https://sql-page.com/documentation.sql + 'It works !' as title, -- 'title' is top-level parameter of the 'hero' component + 'If you can see this, then SQLPage v' || + sqlpage.version() || + ' is running correctly on your server. Congratulations! ' as description; +-- Properties can be textual, numeric, or booleans + +-- Let's start with the text component +SELECT 'text' as component, -- We can switch to another component at any time just with a select statement. + 'Get started' as title; +-- We are now inside the text component. Each row that will be returned by our SELECT queries will be a span of text +-- The text component has a property called "contents" that can be that we use to set the contents of our block of text +-- and a property called "center" that we use to center the text +SELECT 'In order to get started, visit ' as contents; +select 'SQLPage''s website' as contents, + 'https://sql-page.com/your-first-sql-website/' as link, + 1 as italics; +SELECT '. You can replace this page''s contents by creating a file named ' as contents; +SELECT 'index.sql' as contents, 1 as code; +SELECT ' in the web root directory: ' as contents; +SELECT sqlpage.web_root() as contents, 1 as code; +SELECT '.' as contents; +SELECT 'You can customize your server''s [configuration](https://github.com/sqlpage/SQLPage/blob/main/configuration.md) +by creating a file named `sqlpage.json` in the configuration directory: `' || sqlpage.configuration_directory() || '`.' as contents_md; + +SELECT ' +Alternatively, you can create a table called `sqlpage_files` in your database with the following columns: `path`, `contents`, and `last_modified`.' as contents_md; diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..d0c13b3 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,158 @@ +#![deny(clippy::pedantic)] +#![allow(clippy::missing_errors_doc, clippy::missing_panics_doc)] + +//! [SQLPage](https://sql-page.com) is a high-performance web server that converts SQL queries +//! into dynamic web applications by rendering [handlebars templates](https://sql-page.com/custom_components.sql) +//! with data coming from SQL queries declared in `.sql` files. +//! +//! # Overview +//! +//! `SQLPage` is a web server that lets you build data-centric applications using only SQL queries. +//! It automatically converts database queries into professional-looking web pages using pre-built components +//! for common UI patterns like [tables](https://sql-page.com/component.sql?component=table), +//! [charts](https://sql-page.com/component.sql?component=chart), +//! [forms](https://sql-page.com/component.sql?component=form), and more. +//! +//! # Key Features +//! +//! - **SQL-Only Development**: Build full web applications without HTML, CSS, or JavaScript +//! - **Built-in Components**: Rich library of [pre-made UI components](https://sql-page.com/documentation.sql) +//! - **Security**: Protection against [SQL injection, XSS and other vulnerabilities](https://sql-page.com/safety.sql) +//! - **Performance**: [Optimized request handling and rendering](https://sql-page.com/performance.sql) +//! - **Database Support**: Works with `SQLite`, `PostgreSQL`, `MySQL`, and MS SQL Server +//! +//! # Architecture +//! +//! The crate is organized into several key modules: +//! +//! - [`webserver`]: Core HTTP server implementation using actix-web +//! - [`render`]: Component rendering system, streaming rendering of the handlebars templates with data +//! - [`templates`]: Pre-defined UI component definitions +//! - [`file_cache`]: Caching layer for SQL file parsing +//! - [`filesystem`]: Abstract interface for disk and DB-stored files +//! - [`app_config`]: Configuration and environment handling +//! +//! # Query Processing Pipeline +//! +//! When processing a request, `SQLPage`: +//! +//! 1. Parses the SQL using sqlparser-rs. Once a SQL file is parsed, it is cached for later reuse. +//! 2. Executes queries through sqlx. +//! 3. Finds the requested component's handlebars template in the database or in the filesystem. +//! 4. Maps results to the component template, using handlebars-rs. +//! 5. Streams rendered HTML to the client. +//! +//! # Extended Functionality +//! +//! - [Custom SQL Functions](https://sql-page.com/functions.sql) +//! - [Custom Components](https://sql-page.com/custom_components.sql) +//! - [Authentication & Sessions](https://sql-page.com/examples/authentication) +//! - [File Uploads](https://sql-page.com/examples/handle_picture_upload.sql) +//! +//! # Example +//! +//! ```sql +//! -- Open a data list component +//! SELECT 'list' as component, 'Users' as title; +//! +//! -- Populate it with data +//! SELECT +//! name as title, +//! email as description +//! FROM users +//! ORDER BY created_at DESC; +//! ``` +//! +//! For more examples and documentation, visit: +//! - [Getting Started Guide](https://sql-page.com/get%20started.sql) +//! - [Component Reference](https://sql-page.com/components.sql) +//! - [Example Gallery](https://sql-page.com/examples/tabs) + +extern crate core; + +pub mod app_config; +pub mod cli; +pub mod dynamic_component; +pub mod file_cache; +pub mod filesystem; +pub mod render; +pub mod telemetry; +pub mod telemetry_metrics; +pub mod template_helpers; +pub mod templates; +pub mod utils; +pub mod webserver; + +use crate::app_config::AppConfig; +use crate::filesystem::FileSystem; +use crate::webserver::database::ParsedSqlFile; +use crate::webserver::oidc::OidcState; +use file_cache::FileCache; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use telemetry_metrics::TelemetryMetrics; +use templates::AllTemplates; +use webserver::Database; + +/// `TEMPLATES_DIR` is the directory where .handlebars files are stored +/// When a template is requested, it is looked up in `sqlpage/templates/component_name.handlebars` in the database, +/// or in `$SQLPAGE_CONFIGURATION_DIRECTORY/templates/component_name.handlebars` in the filesystem. +pub const TEMPLATES_DIR: &str = "sqlpage/templates/"; +pub const MIGRATIONS_DIR: &str = "migrations"; +pub const ON_CONNECT_FILE: &str = "on_connect.sql"; +pub const ON_RESET_FILE: &str = "on_reset.sql"; +pub const DEFAULT_404_FILE: &str = "default_404.sql"; + +pub struct AppState { + pub db: Database, + all_templates: AllTemplates, + sql_file_cache: FileCache, + file_system: FileSystem, + config: AppConfig, + pub oidc_state: Option>, + pub telemetry_metrics: TelemetryMetrics, +} + +impl AppState { + pub async fn init(config: &AppConfig) -> anyhow::Result { + let db = Database::init(config).await?; + Self::init_with_db(config, db).await + } + pub async fn init_with_db(config: &AppConfig, db: Database) -> anyhow::Result { + let all_templates = AllTemplates::init(config)?; + let mut sql_file_cache = FileCache::new(); + let file_system = FileSystem::init(&config.web_root, &db).await; + sql_file_cache.add_static( + PathBuf::from("index.sql"), + ParsedSqlFile::new(&db, include_str!("index.sql"), Path::new("index.sql")), + ); + sql_file_cache.add_static( + PathBuf::from(DEFAULT_404_FILE), + ParsedSqlFile::new( + &db, + include_str!("default_404.sql"), + Path::new(DEFAULT_404_FILE), + ), + ); + + let oidc_state = crate::webserver::oidc::initialize_oidc_state(config).await?; + let telemetry_metrics = + TelemetryMetrics::new(&db.connection, db.info.database_type.otel_name()); + + Ok(AppState { + db, + all_templates, + sql_file_cache, + file_system, + config: config.clone(), + oidc_state, + telemetry_metrics, + }) + } +} + +impl std::fmt::Debug for AppState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AppState").finish() + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..ec7d4bf --- /dev/null +++ b/src/main.rs @@ -0,0 +1,75 @@ +use sqlpage::{ + AppState, + app_config::AppConfig, + cli, telemetry, + webserver::{self, Database}, +}; + +#[actix_web::main] +async fn main() { + let cli = match cli::arguments::parse_cli() { + Ok(cli) => cli, + Err(e) => { + eprintln!("{e:#}"); + std::process::exit(1); + } + }; + + let is_server_mode = cli.command.is_none(); + + if is_server_mode { + if let Err(e) = init_logging() { + eprintln!("Failed to initialize logging/telemetry: {e:#}"); + std::process::exit(1); + } + } else { + let _ = dotenvy::dotenv(); + } + + if let Err(e) = start(cli).await { + if is_server_mode { + log::error!("{e:?}"); + } else { + eprintln!("{e:#}"); + } + std::process::exit(1); + } +} + +async fn start(cli: cli::arguments::Cli) -> anyhow::Result<()> { + let app_config = AppConfig::from_cli(&cli)?; + + if let Some(command) = cli.command { + return command.execute(app_config).await; + } + + let db = Database::init(&app_config).await?; + webserver::database::migrations::apply(&app_config, &db).await?; + let state = AppState::init_with_db(&app_config, db).await?; + + log::debug!("Starting server..."); + webserver::http::run_server(&app_config, state).await?; + log::info!("Server stopped gracefully. Goodbye!"); + telemetry::shutdown_telemetry(); + Ok(()) +} + +fn init_logging() -> anyhow::Result<()> { + let load_env = dotenvy::dotenv(); + + let otel_active = telemetry::init_telemetry()?; + + match load_env { + Ok(path) => log::info!("Loaded environment variables from {path:?}"), + Err(dotenvy::Error::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => log::debug!( + "No .env file found, using only environment variables and configuration files" + ), + Err(e) => log::error!("Error loading .env file: {e}"), + } + + if otel_active { + log::info!("OpenTelemetry tracing enabled (OTEL_EXPORTER_OTLP_ENDPOINT is set)"); + } + + Ok(()) +} diff --git a/src/render.rs b/src/render.rs new file mode 100644 index 0000000..17558ef --- /dev/null +++ b/src/render.rs @@ -0,0 +1,1272 @@ +//! Handles the rendering of SQL query results into HTTP responses using components. +//! +//! This module is responsible for transforming database query results into formatted HTTP responses +//! by utilizing a component-based rendering system. It supports multiple output formats including HTML, +//! JSON, and CSV. +//! +//! # Components +//! +//! Components are small user interface elements that display data in specific ways. The rendering +//! system supports two types of parameters for components: +//! +//! * **Top-level parameters**: Properties that customize the component's appearance and behavior +//! * **Row-level parameters**: The actual data to be displayed within the component +//! +//! # Page Context States +//! +//! The rendering process moves through different states represented by [`PageContext`]: +//! +//! * `Header`: Initial state for processing HTTP headers and response setup +//! * `Body`: Active rendering state where component output is generated +//! * `Close`: Final state indicating the response is complete +//! +//! # Header Components +//! +//! Some components must be processed before any response body is sent: +//! +//! * [`status_code`](https://sql-page.com/component.sql?component=status_code): Sets the HTTP response status +//! * [`http_header`](https://sql-page.com/component.sql?component=http_header): Sets custom HTTP headers +//! * [`redirect`](https://sql-page.com/component.sql?component=redirect): Performs HTTP redirects +//! * `authentication`: Handles password-protected access +//! * `cookie`: Manages browser cookies +//! +//! # Body Components +//! +//! The module supports multiple output formats through different renderers: +//! +//! * HTML: Renders templated HTML output using components +//! * JSON: Generates JSON responses for API endpoints +//! * CSV: Creates downloadable CSV files +//! +//! For more details on available components and their usage, see the +//! [SQLPage documentation](https://sql-page.com/documentation.sql). + +use crate::AppState; +use crate::templates::SplitTemplate; +use crate::webserver::ErrorWithStatus; +use crate::webserver::error::ClientError; +use crate::webserver::http::{RequestContext, ResponseFormat}; +use crate::webserver::response_writer::{AsyncResponseWriter, ResponseWriter}; +use actix_web::body::MessageBody; +use actix_web::cookie::time::OffsetDateTime; +use actix_web::cookie::time::format_description::well_known::Rfc3339; +use actix_web::http::header::{ + ContentDisposition, DispositionParam, DispositionType, TryIntoHeaderPair, +}; +use actix_web::http::{StatusCode, header}; +use actix_web::{HttpResponse, HttpResponseBuilder}; +use anyhow::{Context as AnyhowContext, bail, format_err}; +use awc::cookie::time::Duration; +use handlebars::{BlockContext, JsonValue, RenderError, Renderable}; +use serde::Serialize; +use serde_json::{Value, json}; +use std::borrow::Cow; +use std::convert::TryFrom; +use std::fmt::Write as _; +use std::io::Write; +use std::path::Path; +use std::str::FromStr; +use std::sync::Arc; + +pub enum PageContext { + /// Indicates that we should stay in the header context + Header(HeaderContext), + + /// Indicates that we should start rendering the body + Body { + http_response: HttpResponseBuilder, + renderer: AnyRenderBodyContext, + }, + + /// The response is ready, and should be sent as is. No further statements should be executed + Close(HttpResponse), +} + +/// Handles the first SQL statements, before the headers have been sent to +pub struct HeaderContext { + app_state: Arc, + pub request_context: RequestContext, + pub writer: ResponseWriter, + response: HttpResponseBuilder, + has_status: bool, +} + +impl HeaderContext { + #[must_use] + pub fn new( + app_state: Arc, + request_context: RequestContext, + writer: ResponseWriter, + ) -> Self { + let mut response = HttpResponseBuilder::new(StatusCode::OK); + response.content_type(request_context.response_format.content_type()); + if request_context.response_format == ResponseFormat::Html { + let tpl = &app_state.config.content_security_policy; + request_context + .content_security_policy + .apply_to_response(tpl, &mut response); + } + Self { + app_state, + request_context, + writer, + response, + has_status: false, + } + } + pub async fn handle_row(self, data: JsonValue) -> anyhow::Result { + log::debug!("Handling header row: {data}"); + let comp_opt = + get_object_str(&data, "component").and_then(|s| HeaderComponent::try_from(s).ok()); + match comp_opt { + Some(HeaderComponent::StatusCode) => self.status_code(&data).map(PageContext::Header), + Some(HeaderComponent::HttpHeader) => { + self.add_http_header(&data).map(PageContext::Header) + } + Some(HeaderComponent::Redirect) => self.redirect(&data), + Some(HeaderComponent::Json) => self.json(&data), + Some(HeaderComponent::Csv) => self.csv(&data).await, + Some(HeaderComponent::Cookie) => self.add_cookie(&data).map(PageContext::Header), + Some(HeaderComponent::Authentication) => self.authentication(data).await, + Some(HeaderComponent::Download) => self.download(&data), + Some(HeaderComponent::Log) => self.log(&data), + None => self.start_body(data).await, + } + } + + pub async fn handle_error(self, err: anyhow::Error) -> anyhow::Result { + // Reduce the error to its client-safe form. The single environment + // check lives in `ClientError::new`; here we only branch on the result. + let client_error = ClientError::new(&err, self.app_state.config.environment, None); + if client_error.is_generic() { + // Production: no body byte has been sent yet, so bubble the error + // up. The top-level handler then produces a proper error *response* + // (correct status code and the production-safe HTML error page) + // instead of a 200 body. The detailed error never reaches the client. + return Err(err); + } + log::debug!("Handling header error: {err}"); + // Development: show the full detail inline as an error component. + let mut data = client_error.to_html_data(); + data["component"] = json!("error"); + self.start_body(data).await + } + + fn status_code(mut self, data: &JsonValue) -> anyhow::Result { + let status_code = data + .as_object() + .and_then(|m| m.get("status")) + .with_context(|| "status_code component requires a status")? + .as_u64() + .with_context(|| "status must be a number")?; + let code = u16::try_from(status_code) + .with_context(|| format!("status must be a number between 0 and {}", u16::MAX))?; + self.response.status(StatusCode::from_u16(code)?); + self.has_status = true; + Ok(self) + } + + fn insert_header(&mut self, header: impl TryIntoHeaderPair) -> anyhow::Result<()> { + let pair = header.try_into_pair().map_err(Into::into)?; + self.response.insert_header(pair); + Ok(()) + } + + fn append_header(&mut self, header: impl TryIntoHeaderPair) -> anyhow::Result<()> { + let pair = header.try_into_pair().map_err(Into::into)?; + self.response.append_header(pair); + Ok(()) + } + + fn add_http_header(mut self, data: &JsonValue) -> anyhow::Result { + let obj = data.as_object().with_context(|| "expected object")?; + for (name, value) in obj { + if name == "component" { + continue; + } + let value_str = value + .as_str() + .with_context(|| "http header values must be strings")?; + if name.eq_ignore_ascii_case("location") && !self.has_status { + self.response.status(StatusCode::FOUND); + self.has_status = true; + } + let header = TryIntoHeaderPair::try_into_pair((name.as_str(), value_str)) + .map_err(|e| anyhow::anyhow!("Invalid header: {name}:{value_str}: {e:#?}"))?; + self.insert_header(header)?; + } + Ok(self) + } + + fn add_cookie(mut self, data: &JsonValue) -> anyhow::Result { + let obj = data.as_object().with_context(|| "expected object")?; + let name = obj + .get("name") + .and_then(JsonValue::as_str) + .with_context(|| "cookie name must be a string")?; + let mut cookie = actix_web::cookie::Cookie::named(name); + + let path = obj.get("path").and_then(JsonValue::as_str); + if let Some(path) = path { + cookie.set_path(path); + } else { + cookie.set_path("/"); + } + let domain = obj.get("domain").and_then(JsonValue::as_str); + if let Some(domain) = domain { + cookie.set_domain(domain); + } + + let remove = obj.get("remove"); + if remove == Some(&json!(true)) || remove == Some(&json!(1)) { + cookie.make_removal(); + self.response.cookie(cookie); + log::trace!("Removing cookie {name}"); + return Ok(self); + } + + let value = obj + .get("value") + .and_then(JsonValue::as_str) + .with_context(|| "The 'value' property of the cookie component is required (unless 'remove' is set) and must be a string.")?; + cookie.set_value(value); + let http_only = obj.get("http_only"); + cookie.set_http_only(http_only != Some(&json!(false)) && http_only != Some(&json!(0))); + let same_site = obj.get("same_site").and_then(Value::as_str); + cookie.set_same_site(match same_site { + Some("none") => actix_web::cookie::SameSite::None, + Some("lax") => actix_web::cookie::SameSite::Lax, + None | Some("strict") => actix_web::cookie::SameSite::Strict, // strict by default + Some(other) => bail!("Cookie: invalid value for same_site: {other}"), + }); + let secure = obj.get("secure"); + cookie.set_secure(secure != Some(&json!(false)) && secure != Some(&json!(0))); + if let Some(max_age_json) = obj.get("max_age") { + let seconds = max_age_json + .as_i64() + .ok_or_else(|| anyhow::anyhow!("max_age must be a number, not {max_age_json}"))?; + cookie.set_max_age(Duration::seconds(seconds)); + } + let expires = obj.get("expires"); + if let Some(expires) = expires { + cookie.set_expires(actix_web::cookie::Expiration::DateTime(match expires { + JsonValue::String(s) => OffsetDateTime::parse(s, &Rfc3339)?, + JsonValue::Number(n) => OffsetDateTime::from_unix_timestamp( + n.as_i64().with_context(|| "expires must be a timestamp")?, + )?, + _ => bail!("expires must be a string or a number"), + })); + } + log::trace!("Setting cookie {cookie}"); + self.append_header((header::SET_COOKIE, cookie.encoded().to_string()))?; + Ok(self) + } + + fn redirect(mut self, data: &JsonValue) -> anyhow::Result { + self.response.status(StatusCode::FOUND); + self.has_status = true; + let link = get_object_str(data, "link") + .with_context(|| "The redirect component requires a 'link' property")?; + self.insert_header((header::LOCATION, link))?; + self.close_with_body(()) + } + + /// Answers to the HTTP request with a single json object + fn json(mut self, data: &JsonValue) -> anyhow::Result { + self.insert_header((header::CONTENT_TYPE, "application/json"))?; + if let Some(contents) = data.get("contents") { + let json_response = if let Some(s) = contents.as_str() { + s.as_bytes().to_owned() + } else { + serde_json::to_vec(contents)? + }; + self.close_with_body(json_response) + } else { + let body_type = get_object_str(data, "type"); + let json_renderer = match body_type { + None | Some("array") => JsonBodyRenderer::new_array(self.writer), + Some("jsonlines") => JsonBodyRenderer::new_jsonlines(self.writer), + Some("sse") => { + self.insert_header((header::CONTENT_TYPE, "text/event-stream"))?; + JsonBodyRenderer::new_server_sent_events(self.writer) + } + _ => bail!( + "Invalid value for the 'type' property of the json component: {body_type:?}" + ), + }; + let renderer = AnyRenderBodyContext::new( + BodyRenderer::Json(json_renderer), + self.app_state.config.environment, + ); + let http_response = self.response; + Ok(PageContext::Body { + http_response, + renderer, + }) + } + } + + async fn csv(mut self, options: &JsonValue) -> anyhow::Result { + self.insert_header((header::CONTENT_TYPE, "text/csv; charset=utf-8"))?; + if let Some(filename) = + get_object_str(options, "filename").or_else(|| get_object_str(options, "title")) + { + let extension = if filename.contains('.') { "" } else { ".csv" }; + self.insert_header(attachment_with_filename(&format!("{filename}{extension}")))?; + } + let environment = self.app_state.config.environment; + let csv_renderer = CsvBodyRenderer::new(self.writer, options).await?; + let renderer = AnyRenderBodyContext::new(BodyRenderer::Csv(csv_renderer), environment); + let http_response = self.response.take(); + Ok(PageContext::Body { + renderer, + http_response, + }) + } + + async fn authentication(mut self, mut data: JsonValue) -> anyhow::Result { + let password_hash = take_object_str(&mut data, "password_hash"); + let password = take_object_str(&mut data, "password"); + if let (Some(password), Some(password_hash)) = (password, password_hash) { + log::debug!("Authentication with password_hash = {password_hash:?}"); + match verify_password_async(password_hash, password).await? { + Ok(()) => return Ok(PageContext::Header(self)), + Err(e) => log::info!("Password didn't match: {e}"), + } + } + log::debug!("Authentication failed"); + // The authentication failed + if let Some(link) = get_object_str(&data, "link") { + self.response.status(StatusCode::FOUND); + self.has_status = true; + self.insert_header((header::LOCATION, link))?; + let response = self.into_response( + "Sorry, but you are not authorized to access this page. \ + Redirecting to the login page...", + )?; + Ok(PageContext::Close(response)) + } else { + anyhow::bail!(ErrorWithStatus { + status: StatusCode::UNAUTHORIZED + }) + } + } + + fn download(mut self, options: &JsonValue) -> anyhow::Result { + if let Some(filename) = get_object_str(options, "filename") { + self.insert_header(attachment_with_filename(filename))?; + } + let data_url = get_object_str(options, "data_url") + .with_context(|| "The download component requires a 'data_url' property")?; + let rest = data_url + .strip_prefix("data:") + .with_context(|| "Invalid data URL: missing 'data:' prefix")?; + let (mut content_type, data) = rest + .split_once(',') + .with_context(|| "Invalid data URL: missing comma")?; + let mut body_bytes: Cow<[u8]> = percent_encoding::percent_decode(data.as_bytes()).into(); + if let Some(stripped) = content_type.strip_suffix(";base64") { + content_type = stripped; + body_bytes = + base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &body_bytes) + .with_context(|| "Invalid base64 data in data URL")? + .into(); + } + if !content_type.is_empty() { + self.insert_header((header::CONTENT_TYPE, content_type))?; + } + self.close_with_body(body_bytes.into_owned()) + } + + fn log(self, data: &JsonValue) -> anyhow::Result { + handle_log_component(&self.request_context.source_path, Option::None, data)?; + Ok(PageContext::Header(self)) + } + + fn add_server_timing_header(&mut self) -> anyhow::Result<()> { + if let Some(header_value) = self.request_context.server_timing.header_value() { + self.insert_header(("Server-Timing", header_value))?; + } + Ok(()) + } + + fn into_response(mut self, body: B) -> anyhow::Result { + self.add_server_timing_header()?; + match self.response.message_body(body) { + Ok(response) => Ok(response.map_into_boxed_body()), + Err(e) => Err(anyhow::anyhow!( + "An error occured while generating the request headers: {e:#}" + )), + } + } + + fn close_with_body(self, body: B) -> anyhow::Result { + Ok(PageContext::Close(self.into_response(body)?)) + } + + async fn start_body(mut self, data: JsonValue) -> anyhow::Result { + self.add_server_timing_header()?; + let environment = self.app_state.config.environment; + let body_renderer = match self.request_context.response_format { + ResponseFormat::Json => BodyRenderer::Json(JsonBodyRenderer::new_array_with_first_row( + self.writer, + &data, + )), + ResponseFormat::JsonLines => BodyRenderer::Json( + JsonBodyRenderer::new_jsonlines_with_first_row(self.writer, &data), + ), + ResponseFormat::Html => { + let html_renderer = + HtmlRenderContext::new(self.app_state, self.request_context, self.writer, data) + .await + .with_context( + || "Failed to create a render context from the header context.", + )?; + BodyRenderer::Html(html_renderer) + } + }; + let renderer = AnyRenderBodyContext::new(body_renderer, environment); + let http_response = self.response; + Ok(PageContext::Body { + renderer, + http_response, + }) + } + + pub fn close(self) -> anyhow::Result { + self.into_response(()) + } +} + +async fn verify_password_async( + password_hash: String, + password: String, +) -> Result, anyhow::Error> { + tokio::task::spawn_blocking(move || { + let hash = argon2::password_hash::PasswordHash::new(&password_hash) + .map_err(|e| anyhow::anyhow!("invalid value for the password_hash property: {e}"))?; + let phfs = &[&argon2::Argon2::default() as &dyn argon2::password_hash::PasswordVerifier]; + Ok(hash.verify_password(phfs, password)) + }) + .await? +} + +/// Builds an `attachment` `Content-Disposition` header with the given filename, +/// using actix-web's structured [`ContentDisposition`] type so the filename is +/// properly quoted and escaped. This prevents a user-supplied filename +/// containing `;`, `"`, or `=` from injecting additional header parameters +/// (e.g. a second, agent-preferred `filename*`). +fn attachment_with_filename(filename: &str) -> ContentDisposition { + ContentDisposition { + disposition: DispositionType::Attachment, + parameters: vec![DispositionParam::Filename(filename.to_owned())], + } +} + +fn get_object_str<'a>(json: &'a JsonValue, key: &str) -> Option<&'a str> { + json.as_object() + .and_then(|obj| obj.get(key)) + .and_then(JsonValue::as_str) +} + +fn take_object_str(json: &mut JsonValue, key: &str) -> Option { + match json.get_mut(key)?.take() { + JsonValue::String(s) => Some(s), + _ => None, + } +} + +/// A body renderer for one of the supported output formats. It can receive +/// rows and write them, in its format, to an `io::Write`. +pub enum BodyRenderer { + Html(HtmlRenderContext), + Json(JsonBodyRenderer), + Csv(CsvBodyRenderer), +} + +/// Wraps a [`BodyRenderer`] together with the environment, so errors can be +/// reduced to their client-safe form ([`ClientError`]) at this single boundary +/// before they ever reach a format-specific renderer. +/// +/// Renderers never see the raw [`anyhow::Error`]; they only receive a +/// [`ClientError`], which already hides every sensitive detail in production. +/// This makes leaking impossible by construction: a renderer cannot emit what +/// it never receives. +pub struct AnyRenderBodyContext { + renderer: BodyRenderer, + environment: crate::app_config::DevOrProd, +} + +impl AnyRenderBodyContext { + #[must_use] + pub fn new(renderer: BodyRenderer, environment: crate::app_config::DevOrProd) -> Self { + Self { + renderer, + environment, + } + } + + pub async fn handle_row(&mut self, data: &JsonValue) -> anyhow::Result<()> { + log::debug!( + "<- Rendering properties: {}", + serde_json::to_string(&data).unwrap_or_else(|e| e.to_string()) + ); + match &mut self.renderer { + BodyRenderer::Html(render_context) => render_context.handle_row(data).await, + BodyRenderer::Json(json_body_renderer) => json_body_renderer.handle_row(data), + BodyRenderer::Csv(csv_renderer) => csv_renderer.handle_row(data).await, + } + } + + pub async fn handle_error(&mut self, error: &anyhow::Error) -> anyhow::Result<()> { + // The full error is always logged server-side, regardless of the + // environment and of which format the client requested. + log::error!("SQL error: {error:?}"); + // Reduce the raw error to its client-safe form ONCE, here, before it + // reaches any format renderer. In production this strips the source + // path, the SQL statement, the raw database error, and the backtrace. + let query_number = match &self.renderer { + BodyRenderer::Html(html) => Some(html.current_statement), + BodyRenderer::Json(_) | BodyRenderer::Csv(_) => None, + }; + let client_error = ClientError::new(error, self.environment, query_number); + match &mut self.renderer { + BodyRenderer::Html(render_context) => render_context.handle_error(&client_error).await, + BodyRenderer::Json(json_body_renderer) => { + json_body_renderer.handle_error(&client_error) + } + BodyRenderer::Csv(csv_renderer) => csv_renderer.handle_error(&client_error).await, + } + } + + pub async fn finish_query(&mut self) -> anyhow::Result<()> { + match &mut self.renderer { + BodyRenderer::Html(render_context) => render_context.finish_query().await, + BodyRenderer::Json(_json_body_renderer) => Ok(()), + BodyRenderer::Csv(_csv_renderer) => Ok(()), + } + } + + pub async fn flush(&mut self) -> anyhow::Result<()> { + match &mut self.renderer { + BodyRenderer::Html(HtmlRenderContext { writer, .. }) + | BodyRenderer::Json(JsonBodyRenderer { writer, .. }) => { + writer.async_flush().await?; + } + BodyRenderer::Csv(csv_renderer) => csv_renderer.flush().await?, + } + Ok(()) + } + + pub async fn close(self) -> ResponseWriter { + match self.renderer { + BodyRenderer::Html(render_context) => render_context.close().await, + BodyRenderer::Json(json_body_renderer) => json_body_renderer.close(), + BodyRenderer::Csv(csv_renderer) => csv_renderer.close().await, + } + } +} + +pub struct JsonBodyRenderer { + writer: W, + is_first: bool, + prefix: &'static [u8], + suffix: &'static [u8], + separator: &'static [u8], +} + +impl JsonBodyRenderer { + pub fn new_array(writer: W) -> JsonBodyRenderer { + let mut renderer = Self { + writer, + is_first: true, + prefix: b"[\n", + suffix: b"\n]", + separator: b",\n", + }; + let _ = renderer.write_prefix(); + renderer + } + pub fn new_array_with_first_row(writer: W, first_row: &JsonValue) -> JsonBodyRenderer { + let mut renderer = Self::new_array(writer); + let _ = renderer.handle_row(first_row); + renderer + } + pub fn new_jsonlines(writer: W) -> JsonBodyRenderer { + let mut renderer = Self { + writer, + is_first: true, + prefix: b"", + suffix: b"", + separator: b"\n", + }; + renderer.write_prefix().unwrap(); + renderer + } + pub fn new_jsonlines_with_first_row(writer: W, first_row: &JsonValue) -> JsonBodyRenderer { + let mut renderer = Self::new_jsonlines(writer); + let _ = renderer.handle_row(first_row); + renderer + } + pub fn new_server_sent_events(writer: W) -> JsonBodyRenderer { + let mut renderer = Self { + writer, + is_first: true, + prefix: b"data: ", + suffix: b"\n\n", + separator: b"\n\ndata: ", + }; + renderer.write_prefix().unwrap(); + renderer + } + fn write_prefix(&mut self) -> anyhow::Result<()> { + self.writer.write_all(self.prefix)?; + Ok(()) + } + pub fn handle_row(&mut self, data: &JsonValue) -> anyhow::Result<()> { + if self.is_first { + self.is_first = false; + } else { + let _ = self.writer.write_all(self.separator); + } + serde_json::to_writer(&mut self.writer, data)?; + Ok(()) + } + pub fn handle_error(&mut self, error: &ClientError) -> anyhow::Result<()> { + self.handle_row(&json!({ "error": error.message() })) + } + + pub fn close(mut self) -> W { + let _ = self.writer.write_all(self.suffix); + self.writer + } +} + +pub struct CsvBodyRenderer { + // The writer is a large struct, so we store it on the heap + writer: Box>, + columns: Vec, +} + +impl CsvBodyRenderer { + pub async fn new( + mut writer: ResponseWriter, + options: &JsonValue, + ) -> anyhow::Result { + let mut builder = csv_async::AsyncWriterBuilder::new(); + if let Some(separator) = get_object_str(options, "separator") { + let &[separator_byte] = separator.as_bytes() else { + bail!("Invalid csv separator: {separator:?}. It must be a single byte."); + }; + builder.delimiter(separator_byte); + } + if let Some(quote) = get_object_str(options, "quote") { + let &[quote_byte] = quote.as_bytes() else { + bail!("Invalid csv quote: {quote:?}. It must be a single byte."); + }; + builder.quote(quote_byte); + } + if let Some(escape) = get_object_str(options, "escape") { + let &[escape_byte] = escape.as_bytes() else { + bail!("Invalid csv escape: {escape:?}. It must be a single byte."); + }; + builder.escape(escape_byte); + } + if options + .get("bom") + .and_then(JsonValue::as_bool) + .unwrap_or(false) + { + let utf8_bom = b"\xEF\xBB\xBF"; + writer.write_all(utf8_bom)?; + } + let mut async_writer = AsyncResponseWriter::new(writer); + tokio::io::AsyncWriteExt::flush(&mut async_writer).await?; + let writer = builder.create_writer(async_writer); + Ok(CsvBodyRenderer { + writer: Box::new(writer), + columns: vec![], + }) + } + + pub async fn handle_row(&mut self, data: &JsonValue) -> anyhow::Result<()> { + if self.columns.is_empty() + && let Some(obj) = data.as_object() + { + let headers: Vec = obj.keys().map(String::to_owned).collect(); + self.columns = headers; + self.writer.write_record(&self.columns).await?; + } + + if let Some(obj) = data.as_object() { + let col2bytes = |s| { + let val = obj.get(s); + let Some(val) = val else { + return Cow::Borrowed(&b""[..]); + }; + if let Some(s) = val.as_str() { + Cow::Borrowed(s.as_bytes()) + } else { + Cow::Owned(val.to_string().into_bytes()) + } + }; + let record = self.columns.iter().map(col2bytes); + self.writer.write_record(record).await?; + } + + Ok(()) + } + + pub async fn handle_error(&mut self, error: &ClientError) -> anyhow::Result<()> { + let err_str = error.message(); + if self.columns.is_empty() { + // The error happened before any header row was written, so there are + // no columns to align with. Emit a single-field record so the error + // is still reported instead of an empty record. + self.writer.write_record([err_str]).await?; + } else { + self.writer + .write_record( + self.columns + .iter() + .enumerate() + .map(|(i, _)| if i == 0 { err_str } else { "" }) + .collect::>(), + ) + .await?; + } + Ok(()) + } + + pub async fn flush(&mut self) -> anyhow::Result<()> { + self.writer.flush().await?; + Ok(()) + } + + pub async fn close(self) -> ResponseWriter { + self.writer + .into_inner() + .await + .expect("Failed to get inner writer") + .into_inner() + } +} + +#[allow(clippy::module_name_repetitions)] +pub struct HtmlRenderContext { + app_state: Arc, + pub writer: W, + current_component: Option, + shell_renderer: SplitTemplateRenderer, + current_statement: usize, + request_context: RequestContext, +} + +const DEFAULT_COMPONENT: &str = "table"; +const PAGE_SHELL_COMPONENT: &str = "shell"; +const FRAGMENT_SHELL_COMPONENT: &str = "shell-empty"; + +impl HtmlRenderContext { + pub async fn new( + app_state: Arc, + request_context: RequestContext, + mut writer: W, + initial_row: JsonValue, + ) -> anyhow::Result> { + log::debug!("Creating the shell component for the page"); + + let mut initial_rows = vec![Cow::Borrowed(&initial_row)]; + + if !initial_rows + .first() + .and_then(|c| get_object_str(c, "component")) + .is_some_and(Self::is_shell_component) + { + let default_shell = if request_context.is_embedded { + FRAGMENT_SHELL_COMPONENT + } else { + PAGE_SHELL_COMPONENT + }; + let added_row = json!({"component": default_shell}); + log::trace!( + "No shell component found in the first row. Adding the default shell: {added_row}" + ); + initial_rows.insert(0, Cow::Owned(added_row)); + } + let mut rows_iter = initial_rows.into_iter().map(Cow::into_owned); + + let shell_row = rows_iter + .next() + .expect("shell row should exist at this point"); + let mut shell_component = + get_object_str(&shell_row, "component").expect("shell should exist"); + if request_context.is_embedded && shell_component != FRAGMENT_SHELL_COMPONENT { + log::warn!( + "Embedded pages cannot use a shell component! Ignoring the '{shell_component}' component and its properties: {shell_row}" + ); + shell_component = FRAGMENT_SHELL_COMPONENT; + } + let mut shell_renderer = Self::create_renderer( + shell_component, + Arc::clone(&app_state), + 0, + request_context.content_security_policy.nonce, + ) + .await + .with_context(|| "The shell component should always exist")?; + log::debug!("Rendering the shell with properties: {shell_row}"); + shell_renderer.render_start(&mut writer, shell_row)?; + + let mut initial_context = HtmlRenderContext { + app_state, + writer, + current_component: None, + shell_renderer, + current_statement: 1, + request_context, + }; + + for row in rows_iter { + initial_context.handle_row(&row).await?; + } + + Ok(initial_context) + } + + fn is_shell_component(component: &str) -> bool { + component.starts_with(PAGE_SHELL_COMPONENT) + } + + async fn handle_component( + &mut self, + component_name: &str, + data: &JsonValue, + ) -> anyhow::Result<()> { + if Self::is_shell_component(component_name) { + bail!( + "There cannot be more than a single shell per page. You are trying to open the {component_name} component, but a shell component is already opened for the current page. You can fix this by removing the extra shell component, or by moving this component to the top of the SQL file, before any other component that displays data." + ); + } + + if component_name == "log" { + return handle_log_component( + &self.request_context.source_path, + Some(self.current_statement), + data, + ); + } + + match self.open_component_with_data(component_name, &data).await { + Ok(_) => Ok(()), + Err(err) => match HeaderComponent::try_from(component_name) { + Ok(_) => bail!( + "The {component_name} component cannot be used after data has already been sent to the client's browser. \n\ + This component must be used before any other component. \n\ + To fix this, either move the call to the '{component_name}' component to the top of the SQL file, \n\ + or create a new SQL file where '{component_name}' is the first component." + ), + Err(()) => Err(err), + }, + } + } + + pub async fn handle_row(&mut self, data: &JsonValue) -> anyhow::Result<()> { + let new_component = get_object_str(data, "component"); + let current_component = self + .current_component + .as_ref() + .map(SplitTemplateRenderer::name); + if let Some(component_name) = new_component { + self.handle_component(component_name, data).await?; + } else if current_component.is_none() { + self.open_component_with_data(DEFAULT_COMPONENT, &JsonValue::Null) + .await?; + self.render_current_template_with_data(&data).await?; + } else { + self.render_current_template_with_data(&data).await?; + } + Ok(()) + } + + #[allow(clippy::unused_async)] + pub async fn finish_query(&mut self) -> anyhow::Result<()> { + log::debug!("-> Query {} finished", self.current_statement); + self.current_statement += 1; + Ok(()) + } + + /// Renders an already-reduced, client-safe error into the page. + pub async fn handle_error(&mut self, error: &ClientError) -> anyhow::Result<()> { + self.close_component()?; + let data = error.to_html_data(); + let saved_component = self.open_component_with_data("error", &data).await?; + self.close_component()?; + self.current_component = saved_component; + Ok(()) + } + + /// Reduces a raw error to its client-safe form and renders it. Used for + /// errors that arise during HTML rendering itself (e.g. while closing a + /// component), where the page is already committed to HTML. + pub async fn handle_result(&mut self, result: &anyhow::Result) -> anyhow::Result<()> { + if let Err(error) = result { + let client_error = ClientError::new( + error, + self.app_state.config.environment, + Some(self.current_statement), + ); + self.handle_error(&client_error).await + } else { + Ok(()) + } + } + + pub async fn handle_result_and_log(&mut self, result: &anyhow::Result) { + if let Err(e) = self.handle_result(result).await { + log::error!("{e}"); + } + } + + async fn render_current_template_with_data( + &mut self, + data: &T, + ) -> anyhow::Result<()> { + if self.current_component.is_none() { + self.set_current_component(DEFAULT_COMPONENT).await?; + } + self.current_component + .as_mut() + .expect("just set the current component") + .render_item(&mut self.writer, json!(data))?; + self.shell_renderer + .render_item(&mut self.writer, JsonValue::Null)?; + Ok(()) + } + + async fn create_renderer( + component: &str, + app_state: Arc, + component_index: usize, + nonce: u64, + ) -> anyhow::Result { + let split_template = app_state + .all_templates + .get_template(&app_state, component) + .await?; + Ok(SplitTemplateRenderer::new( + split_template, + app_state, + component_index, + nonce, + )) + } + + /// Set a new current component and return the old one + async fn set_current_component( + &mut self, + component: &str, + ) -> anyhow::Result> { + let current_component_index = self + .current_component + .as_ref() + .map_or(1, |c| c.component_index); + let new_component = Self::create_renderer( + component, + Arc::clone(&self.app_state), + current_component_index + 1, + self.request_context.content_security_policy.nonce, + ) + .await?; + Ok(self.current_component.replace(new_component)) + } + + async fn open_component_with_data( + &mut self, + component: &str, + data: &T, + ) -> anyhow::Result> { + self.close_component()?; + let old_component = self.set_current_component(component).await?; + self.current_component + .as_mut() + .expect("just set the current component") + .render_start(&mut self.writer, json!(data))?; + Ok(old_component) + } + + fn close_component(&mut self) -> anyhow::Result<()> { + if let Some(old_component) = self.current_component.as_mut() { + old_component.render_end(&mut self.writer)?; + } + Ok(()) + } + + pub async fn close(mut self) -> W { + if let Some(old_component) = self.current_component.as_mut() { + let res = old_component + .render_end(&mut self.writer) + .map_err(|e| format_err!("Unable to render the component closing: {e}")); + self.handle_result_and_log(&res).await; + } + let res = self + .shell_renderer + .render_end(&mut self.writer) + .map_err(|e| format_err!("Unable to render the shell closing: {e}")); + self.handle_result_and_log(&res).await; + self.writer + } +} + +fn handle_log_component( + source_path: &Path, + current_statement: Option, + data: &JsonValue, +) -> anyhow::Result<()> { + let level_name = get_object_str(data, "level").unwrap_or("info"); + let log_level = log::Level::from_str(level_name).with_context(|| "Invalid log level value")?; + + let mut target = format!("sqlpage::log from \"{}\"", source_path.display()); + if let Some(current_statement) = current_statement { + write!(&mut target, " statement {current_statement}")?; + } + + let message = get_object_str(data, "message").context("log: missing property 'message'")?; + log::log!(target: &target, log_level, "{message}"); + Ok(()) +} + +struct HandlebarWriterOutput(W); + +impl handlebars::Output for HandlebarWriterOutput { + fn write(&mut self, seg: &str) -> std::io::Result<()> { + std::io::Write::write_all(&mut self.0, seg.as_bytes()) + } +} + +pub struct SplitTemplateRenderer { + split_template: Arc, + // LocalVars is a large struct, so we store it on the heap + local_vars: Option>, + ctx: Box, + app_state: Arc, + row_index: usize, + component_index: usize, + nonce: u64, +} + +const _: () = assert!( + std::mem::size_of::() <= 64, + "SplitTemplateRenderer should be small enough to be allocated on the stack" +); + +impl SplitTemplateRenderer { + fn new( + split_template: Arc, + app_state: Arc, + component_index: usize, + nonce: u64, + ) -> Self { + Self { + split_template, + local_vars: None, + app_state, + row_index: 0, + ctx: Box::new(handlebars::Context::null()), + component_index, + nonce, + } + } + fn name(&self) -> &str { + self.split_template + .list_content + .name + .as_deref() + .unwrap_or_default() + } + + fn render_start( + &mut self, + writer: W, + data: JsonValue, + ) -> Result<(), RenderError> { + log::trace!( + "Starting rendering of a template{} with the following top-level parameters: {data}", + self.split_template + .name() + .map(|n| format!(" ('{n}')")) + .unwrap_or_default(), + ); + let mut render_context = handlebars::RenderContext::new(None); + let blk = render_context + .block_mut() + .expect("context created without block"); + blk.set_local_var("component_index", self.component_index.into()); + blk.set_local_var("csp_nonce", self.nonce.into()); + + *self.ctx.data_mut() = data; + let mut output = HandlebarWriterOutput(writer); + self.split_template.before_list.render( + &self.app_state.all_templates.handlebars, + &self.ctx, + &mut render_context, + &mut output, + )?; + let blk = render_context.block_mut(); + if let Some(blk) = blk { + let local_vars = std::mem::take(blk.local_variables_mut()); + self.local_vars = Some(Box::new(local_vars)); + } + self.row_index = 0; + Ok(()) + } + + fn render_item( + &mut self, + writer: W, + data: JsonValue, + ) -> Result<(), RenderError> { + log::trace!("Rendering a new item in the page: {data:?}"); + if let Some(local_vars) = self.local_vars.take() { + let mut render_context = handlebars::RenderContext::new(None); + let blk = render_context + .block_mut() + .expect("context created without block"); + *blk.local_variables_mut() = *local_vars; + let mut blk = BlockContext::new(); + blk.set_base_value(data); + blk.set_local_var("component_index", self.component_index.into()); + blk.set_local_var("row_index", self.row_index.into()); + blk.set_local_var("csp_nonce", self.nonce.into()); + render_context.push_block(blk); + let mut output = HandlebarWriterOutput(writer); + self.split_template.list_content.render( + &self.app_state.all_templates.handlebars, + &self.ctx, + &mut render_context, + &mut output, + )?; + render_context.pop_block(); + let blk = render_context.block_mut(); + if let Some(blk) = blk { + let local_vars = std::mem::take(blk.local_variables_mut()); + self.local_vars = Some(Box::new(local_vars)); + } + self.row_index += 1; + } + Ok(()) + } + + fn render_end(&mut self, writer: W) -> Result<(), RenderError> { + log::trace!( + "Closing a template {}", + self.split_template + .name() + .map(|name| format!("('{name}')")) + .unwrap_or_default(), + ); + if let Some(mut local_vars) = self.local_vars.take() { + let mut render_context = handlebars::RenderContext::new(None); + local_vars.put("row_index", self.row_index.into()); + local_vars.put("component_index", self.component_index.into()); + local_vars.put("csp_nonce", self.nonce.into()); + log::trace!( + "Rendering the after_list template with the following local variables: {local_vars:?}" + ); + *render_context + .block_mut() + .expect("ctx created without block") + .local_variables_mut() = *local_vars; + let mut output = HandlebarWriterOutput(writer); + self.split_template.after_list.render( + &self.app_state.all_templates.handlebars, + &self.ctx, + &mut render_context, + &mut output, + )?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_config; + use crate::templates::split_template; + use handlebars::Template; + + #[actix_web::test] + async fn test_split_template_render() -> anyhow::Result<()> { + let template = Template::compile( + "Hello {{name}} !\ + {{#each_row}} ({{x}} : {{../name}}) {{/each_row}}\ + Goodbye {{name}}", + )?; + let split = split_template(template); + let mut output = Vec::new(); + let config = app_config::tests::test_config(); + let app_state = Arc::new(AppState::init(&config).await.unwrap()); + let mut rdr = SplitTemplateRenderer::new(Arc::new(split), app_state, 0, 0); + rdr.render_start(&mut output, json!({"name": "SQL"}))?; + rdr.render_item(&mut output, json!({"x": 1}))?; + rdr.render_item(&mut output, json!({"x": 2}))?; + rdr.render_end(&mut output)?; + assert_eq!( + String::from_utf8_lossy(&output), + "Hello SQL ! (1 : SQL) (2 : SQL) Goodbye SQL" + ); + Ok(()) + } + + #[actix_web::test] + async fn test_delayed() -> anyhow::Result<()> { + let template = Template::compile( + "{{#each_row}} {{x}} {{#delay}} {{x}} {{/delay}}{{/each_row}}{{flush_delayed}}", + )?; + let split = split_template(template); + let mut output = Vec::new(); + let config = app_config::tests::test_config(); + let app_state = Arc::new(AppState::init(&config).await.unwrap()); + let mut rdr = SplitTemplateRenderer::new(Arc::new(split), app_state, 0, 0); + rdr.render_start(&mut output, json!(null))?; + rdr.render_item(&mut output, json!({"x": 1}))?; + rdr.render_item(&mut output, json!({"x": 2}))?; + rdr.render_end(&mut output)?; + assert_eq!( + String::from_utf8_lossy(&output), + " 1 2 2 1 " + ); + Ok(()) + } +} + +#[derive(Copy, Clone, PartialEq, Eq)] +enum HeaderComponent { + StatusCode, + HttpHeader, + Redirect, + Json, + Csv, + Cookie, + Authentication, + Download, + Log, +} + +impl TryFrom<&str> for HeaderComponent { + type Error = (); + fn try_from(s: &str) -> Result { + match s { + "status_code" => Ok(Self::StatusCode), + "http_header" => Ok(Self::HttpHeader), + "redirect" => Ok(Self::Redirect), + "json" => Ok(Self::Json), + "csv" => Ok(Self::Csv), + "cookie" => Ok(Self::Cookie), + "authentication" => Ok(Self::Authentication), + "download" => Ok(Self::Download), + "log" => Ok(Self::Log), + _ => Err(()), + } + } +} diff --git a/src/telemetry.rs b/src/telemetry.rs new file mode 100644 index 0000000..8c83b26 --- /dev/null +++ b/src/telemetry.rs @@ -0,0 +1,874 @@ +//! OpenTelemetry initialization and shutdown. +//! +//! When `OTEL_EXPORTER_OTLP_ENDPOINT` is set, sets up a full tracing pipeline +//! with OTLP export. Otherwise, sets up tracing with logfmt output only. +//! +//! In both cases, the same logfmt log format is used, with carefully chosen +//! fields for human readability and machine parseability. + +use std::env; +use std::sync::{Once, OnceLock}; + +use anyhow::Context as _; +use opentelemetry_sdk::metrics::SdkMeterProvider; +use opentelemetry_sdk::trace::SdkTracerProvider; + +static TRACER_PROVIDER: OnceLock = OnceLock::new(); +static METER_PROVIDER: OnceLock = OnceLock::new(); +static TEST_LOGGING_INIT: Once = Once::new(); +static OTLP_HTTP_WORKER_SENDER: OnceLock< + Result, String>, +> = OnceLock::new(); +const DEFAULT_ENV_FILTER_DIRECTIVES: &str = "sqlpage=info,actix_web=info,tracing_actix_web=info,opentelemetry=warn,opentelemetry_sdk=warn,opentelemetry_otlp=warn"; +pub(crate) const ACCESS_LOG_TARGET: &str = "sqlpage::access"; + +type OtlpHttpResponse = Result, String>; + +struct OtlpHttpJob { + request: opentelemetry_http::Request, + response_sender: tokio::sync::oneshot::Sender, +} + +#[derive(Clone)] +struct AwcOtlpHttpClient { + sender: tokio::sync::mpsc::UnboundedSender, +} + +impl AwcOtlpHttpClient { + fn new() -> anyhow::Result { + Ok(Self { + sender: otlp_http_worker_sender()?, + }) + } +} + +impl std::fmt::Debug for AwcOtlpHttpClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AwcOtlpHttpClient").finish_non_exhaustive() + } +} + +fn otlp_http_worker_sender() -> anyhow::Result> { + let sender_result = OTLP_HTTP_WORKER_SENDER + .get_or_init(|| init_otlp_http_worker_sender().map_err(|error| error.to_string())); + + match sender_result { + Ok(sender) => Ok(sender.clone()), + Err(error) => { + anyhow::bail!("Failed to initialize OTLP AWC worker thread: {error}"); + } + } +} + +fn init_otlp_http_worker_sender() -> anyhow::Result> +{ + let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel::(); + let (init_result_sender, init_result_receiver) = std::sync::mpsc::sync_channel(1); + let use_system_root_ca_certificates = + crate::webserver::http_client::default_system_root_ca_certificates_from_env(); + + std::thread::Builder::new() + .name("sqlpage-otlp-http".to_owned()) + .spawn(move || { + actix_web::rt::System::new().block_on(async move { + let awc_client = + match crate::webserver::http_client::make_http_client_with_system_roots( + use_system_root_ca_certificates, + ) { + Ok(client) => { + let _ = init_result_sender.send(Ok(())); + client + } + Err(error) => { + let error = format!("Failed to initialize OTLP AWC client: {error:#}"); + let _ = init_result_sender.send(Err(error.clone())); + while let Some(job) = receiver.recv().await { + let _ = job.response_sender.send(Err(error.clone())); + } + return; + } + }; + + while let Some(job) = receiver.recv().await { + let response = execute_otlp_http_request_with_awc(&awc_client, job.request) + .await + .map_err(|error| error.to_string()); + let _ = job.response_sender.send(response); + } + }); + }) + .context("Failed to spawn OTLP AWC worker thread")?; + + match init_result_receiver.recv() { + Ok(Ok(())) => {} + Ok(Err(error)) => anyhow::bail!("{error}"), + Err(error) => anyhow::bail!("OTLP AWC worker initialization channel closed: {error}"), + } + + Ok(sender) +} + +async fn execute_otlp_http_request_with_awc( + awc_client: &awc::Client, + request: opentelemetry_http::Request, +) -> anyhow::Result> { + let (request_parts, request_body) = request.into_parts(); + + let awc_method = awc::http::Method::from_bytes(request_parts.method.as_str().as_bytes()) + .with_context(|| format!("Invalid OTLP HTTP method: {}", request_parts.method))?; + let awc_uri: awc::http::Uri = request_parts + .uri + .to_string() + .parse() + .with_context(|| format!("Invalid OTLP collector URI: {}", request_parts.uri))?; + + let mut awc_request = awc_client.request(awc_method, awc_uri.clone()); + for (header_name, header_value) in &request_parts.headers { + let header_name_str = header_name.as_str(); + let awc_header_name = awc::http::header::HeaderName::from_bytes(header_name_str.as_bytes()) + .with_context(|| format!("Invalid OTLP header name: {header_name_str}"))?; + let awc_header_value = awc::http::header::HeaderValue::from_bytes(header_value.as_bytes()) + .with_context(|| format!("Invalid OTLP header value for {header_name_str}"))?; + awc_request = awc_request.insert_header((awc_header_name, awc_header_value)); + } + + let mut awc_response = awc_request.send_body(request_body).await.map_err(|error| { + anyhow::anyhow!("Failed to send OTLP HTTP request to {awc_uri}: {error}") + })?; + + let mut response_builder = + opentelemetry_http::Response::builder().status(awc_response.status().as_u16()); + for (header_name, header_value) in awc_response.headers() { + let header_value = header_value.to_str().map_err(|error| { + anyhow::anyhow!( + "Invalid OTLP response header value for {}: {error}", + header_name.as_str() + ) + })?; + response_builder = response_builder.header(header_name.as_str(), header_value); + } + + let response_body = awc_response.body().await.map_err(|error| { + anyhow::anyhow!("Failed to read OTLP HTTP response body from {awc_uri}: {error}") + })?; + + response_builder + .body(response_body) + .context("Failed to build OTLP HTTP response") +} + +#[async_trait::async_trait] +impl opentelemetry_http::HttpClient for AwcOtlpHttpClient { + async fn send_bytes( + &self, + request: opentelemetry_http::Request, + ) -> Result< + opentelemetry_http::Response, + opentelemetry_http::HttpError, + > { + let (response_sender, response_receiver) = tokio::sync::oneshot::channel(); + self.sender + .send(OtlpHttpJob { + request, + response_sender, + }) + .map_err(|_| anyhow::anyhow!("OTLP AWC worker thread is unavailable"))?; + + response_receiver + .await + .map_err(|_| anyhow::anyhow!("OTLP AWC worker dropped response channel"))? + .map_err(Into::into) + } +} + +/// Initializes logging / tracing. Returns whether `OTel` was activated. +pub fn init_telemetry() -> anyhow::Result { + init_telemetry_with_log_layer(logfmt::LogfmtLayer::new()) +} + +fn init_telemetry_with_log_layer(logfmt_layer: logfmt::LogfmtLayer) -> anyhow::Result { + let otel_endpoint = env::var("OTEL_EXPORTER_OTLP_ENDPOINT").ok(); + let otel_active = otel_endpoint.as_deref().is_some_and(|v| !v.is_empty()); + + if otel_active { + init_otel_tracing(logfmt_layer)?; + } else { + init_tracing(logfmt_layer)?; + } + + Ok(otel_active) +} + +/// Initializes logging once for tests using the same formatter as production. +/// +/// Unlike `init_telemetry`, this does not initialize OTEL exporters and does +/// not panic on invalid `LOG_LEVEL` / `RUST_LOG` values. +pub fn init_test_logging() { + TEST_LOGGING_INIT.call_once(|| { + init_test_tracing(); + }); +} + +/// Shuts down the `OTel` tracer provider, flushing pending spans. +pub fn shutdown_telemetry() { + if let Some(provider) = TRACER_PROVIDER.get() + && let Err(e) = provider.shutdown() + { + eprintln!("Error shutting down tracer provider: {e}"); + } + if let Some(provider) = METER_PROVIDER.get() + && let Err(e) = provider.shutdown() + { + eprintln!("Error shutting down meter provider: {e}"); + } +} + +/// Tracing subscriber without `OTel` export — logfmt output only. +fn init_tracing(logfmt_layer: logfmt::LogfmtLayer) -> anyhow::Result<()> { + use tracing_subscriber::layer::SubscriberExt; + + let subscriber = tracing_subscriber::registry() + .with(default_env_filter()) + .with(logfmt_layer); + + set_global_subscriber(subscriber) +} + +fn init_test_tracing() { + use tracing_subscriber::layer::SubscriberExt; + + let subscriber = tracing_subscriber::registry() + .with(test_env_filter()) + .with(logfmt::LogfmtLayer::test_writer()); + + set_global_subscriber(subscriber).expect("Failed to initialize test tracing subscriber"); +} + +fn init_otel_tracing(logfmt_layer: logfmt::LogfmtLayer) -> anyhow::Result<()> { + use opentelemetry::global; + use opentelemetry::trace::TracerProvider as _; + use opentelemetry_otlp::WithExportConfig as _; + use opentelemetry_otlp::WithHttpConfig as _; + use opentelemetry_sdk::propagation::TraceContextPropagator; + use tracing_subscriber::layer::SubscriberExt; + + let http_client = AwcOtlpHttpClient::new().context("Failed to initialize OTLP AWC client")?; + + // W3C TraceContext propagation (traceparent header) + global::set_text_map_propagator(TraceContextPropagator::new()); + // OTLP exporter — reads OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_SERVICE_NAME, etc. + let exporter = opentelemetry_otlp::SpanExporter::builder() + .with_http() + .with_http_client(http_client.clone()) + .with_protocol(opentelemetry_otlp::Protocol::HttpBinary) + .build() + .context("Failed to build OTLP span exporter")?; + + let span_processor = + opentelemetry_sdk::trace::span_processor_with_async_runtime::BatchSpanProcessor::builder( + exporter, + opentelemetry_sdk::runtime::Tokio, + ) + .build(); + let provider = SdkTracerProvider::builder() + .with_span_processor(span_processor) + .build(); + + let tracer = provider.tracer("sqlpage"); + global::set_tracer_provider(provider.clone()); + let _ = TRACER_PROVIDER.set(provider); + + // OTLP Metric exporter + let metric_exporter = opentelemetry_otlp::MetricExporter::builder() + .with_http() + .with_http_client(http_client) + .with_protocol(opentelemetry_otlp::Protocol::HttpBinary) + .build() + .context("Failed to build OTLP metric exporter")?; + + let reader = + opentelemetry_sdk::metrics::periodic_reader_with_async_runtime::PeriodicReader::builder( + metric_exporter, + opentelemetry_sdk::runtime::Tokio, + ) + .build(); + let meter_provider = SdkMeterProvider::builder() + .with_reader(reader) + .with_view(|instrument: &opentelemetry_sdk::metrics::Instrument| { + if instrument.kind() == opentelemetry_sdk::metrics::InstrumentKind::Histogram { + opentelemetry_sdk::metrics::Stream::builder() + .with_aggregation( + opentelemetry_sdk::metrics::Aggregation::ExplicitBucketHistogram { + boundaries: vec![ + 0.001, 0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1.0, + 2.5, 5.0, 7.5, 10.0, + ], + record_min_max: true, + }, + ) + .build() + .ok() + } else { + None + } + }) + .build(); + global::set_meter_provider(meter_provider.clone()); + let _ = METER_PROVIDER.set(meter_provider.clone()); + + let otel_layer = tracing_opentelemetry::layer() + .with_tracer(tracer) + .with_location(false); + + let subscriber = tracing_subscriber::registry() + .with(default_env_filter()) + .with(logfmt_layer) + .with(otel_layer); + + set_global_subscriber(subscriber) +} + +fn default_env_filter() -> tracing_subscriber::EnvFilter { + env_filter_directives( + env::var("LOG_LEVEL").ok().as_deref(), + env::var("RUST_LOG").ok().as_deref(), + ) + .parse() + .expect("Invalid log filter value in LOG_LEVEL or RUST_LOG") +} + +fn test_env_filter() -> tracing_subscriber::EnvFilter { + env_filter_directives( + env::var("LOG_LEVEL").ok().as_deref(), + env::var("RUST_LOG").ok().as_deref(), + ) + .parse() + .unwrap_or_else(|_| { + DEFAULT_ENV_FILTER_DIRECTIVES + .parse() + .expect("Default filter directives should always be valid") + }) +} + +fn env_filter_directives(log_level: Option<&str>, rust_log: Option<&str>) -> String { + match ( + log_level.filter(|value| !value.is_empty()), + rust_log.filter(|value| !value.is_empty()), + ) { + (Some(value), _) | (None, Some(value)) => value.to_owned(), + (None, None) => DEFAULT_ENV_FILTER_DIRECTIVES.to_owned(), + } +} + +fn set_global_subscriber(subscriber: impl tracing::Subscriber + Send + Sync) -> anyhow::Result<()> { + tracing::subscriber::set_global_default(subscriber) + .context("Failed to set global tracing subscriber")?; + tracing_log::LogTracer::init().context("Failed to set log→tracing bridge") +} + +/// Custom logfmt logging layer. +/// +/// Outputs one line per event in logfmt format with carefully chosen fields: +/// ```text +/// ts=2026-03-08T20:56:15Z level=error target=sqlpage::webserver::error msg="..." http.request.method=GET url.path=/foo client.address=1.2.3.4 trace_id=abc123 +/// ``` +/// +/// With terminal colors when stderr is a TTY. +mod logfmt { + use std::collections::BTreeMap; + use std::collections::HashMap; + use std::fmt::Write; + use std::io::{self, IsTerminal}; + + use tracing::Subscriber; + use tracing::field::{Field, Visit}; + use tracing_subscriber::Layer; + use tracing_subscriber::layer::Context; + use tracing_subscriber::registry::LookupSpan; + + /// Stores span fields so we can access them when formatting events. + #[derive(Default)] + struct SpanFields(HashMap<&'static str, String>); + + /// Visitor that collects fields into a `HashMap`. + struct FieldCollector<'a>(&'a mut HashMap<&'static str, String>); + + impl Visit for FieldCollector<'_> { + fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { + self.0.insert(field.name(), format!("{value:?}")); + } + fn record_str(&mut self, field: &Field, value: &str) { + self.0.insert(field.name(), value.to_owned()); + } + fn record_i64(&mut self, field: &Field, value: i64) { + self.0.insert(field.name(), value.to_string()); + } + fn record_u64(&mut self, field: &Field, value: u64) { + self.0.insert(field.name(), value.to_string()); + } + fn record_bool(&mut self, field: &Field, value: bool) { + self.0.insert(field.name(), value.to_string()); + } + } + + use opentelemetry_semantic_conventions::attribute as otel; + /// Fields we pick from spans, in display order. + /// (`span_field_name`, `logfmt_key`) + const SPAN_FIELDS: &[(&str, &str)] = &[ + (otel::HTTP_REQUEST_METHOD, otel::HTTP_REQUEST_METHOD), + (otel::URL_PATH, otel::URL_PATH), + ( + otel::HTTP_RESPONSE_STATUS_CODE, + otel::HTTP_RESPONSE_STATUS_CODE, + ), + (otel::CODE_FILE_PATH, otel::CODE_FILE_PATH), + (otel::CLIENT_ADDRESS, otel::CLIENT_ADDRESS), + ]; + + /// All-zeros trace ID means no real trace context. + const INVALID_TRACE_ID: &str = "00000000000000000000000000000000"; + + // ANSI color codes + const RED: &str = "\x1b[31m"; + const YELLOW: &str = "\x1b[33m"; + const GREEN: &str = "\x1b[32m"; + const BLUE: &str = "\x1b[34m"; + const DIM: &str = "\x1b[2m"; + const BOLD: &str = "\x1b[1m"; + const RESET: &str = "\x1b[0m"; + + #[derive(Copy, Clone)] + enum OutputMode { + StdoutAndStderr, + TestWriter, + } + + pub(super) struct LogfmtLayer { + stdout_colors: bool, + stderr_colors: bool, + output_mode: OutputMode, + } + + impl LogfmtLayer { + pub fn new() -> Self { + Self { + stdout_colors: io::stdout().is_terminal(), + stderr_colors: io::stderr().is_terminal(), + output_mode: OutputMode::StdoutAndStderr, + } + } + + pub fn test_writer() -> Self { + Self { + stdout_colors: false, + stderr_colors: false, + output_mode: OutputMode::TestWriter, + } + } + } + + impl Layer for LogfmtLayer + where + S: Subscriber + for<'a> LookupSpan<'a>, + { + fn on_new_span( + &self, + attrs: &tracing::span::Attributes<'_>, + id: &tracing::span::Id, + ctx: Context<'_, S>, + ) { + let mut fields = SpanFields::default(); + attrs.record(&mut FieldCollector(&mut fields.0)); + if let Some(span) = ctx.span(id) { + span.extensions_mut().insert(fields); + } + } + + fn on_record( + &self, + id: &tracing::span::Id, + values: &tracing::span::Record<'_>, + ctx: Context<'_, S>, + ) { + if let Some(span) = ctx.span(id) { + let mut ext = span.extensions_mut(); + if let Some(fields) = ext.get_mut::() { + values.record(&mut FieldCollector(&mut fields.0)); + } + } + } + + fn on_event(&self, event: &tracing::Event<'_>, ctx: Context<'_, S>) { + let mut buf = String::with_capacity(256); + let level = *event.metadata().level(); + let include_all_span_fields = includes_all_span_fields(); + let mut event_fields = HashMap::new(); + event.record(&mut FieldCollector(&mut event_fields)); + let target = event_target(event, &event_fields); + let output_stream = output_stream_for_target(target); + let colors = self.use_colors(output_stream); + let msg = event_fields.get("message"); + let multiline_msg = is_multiline_terminal_message(colors, msg); + let include_all_event_fields = + include_all_span_fields || msg.is_none_or(String::is_empty); + + write_timestamp(&mut buf, colors); + write_level(&mut buf, level, colors); + write_message(&mut buf, msg, multiline_msg); + write_dimmed_field(&mut buf, "target", target, colors); + write_event_fields(&mut buf, &event_fields, include_all_event_fields); + write_span_fields(&mut buf, ctx.event_scope(event), include_all_span_fields); + write_trace_id(&mut buf, ctx.event_scope(event), colors); + + buf.push('\n'); + write_multiline_message(&mut buf, msg, multiline_msg); + match self.output_mode { + OutputMode::StdoutAndStderr => write_to_output(output_stream, &buf), + OutputMode::TestWriter => { + eprint!("{buf}"); + } + } + } + } + + impl LogfmtLayer { + fn use_colors(&self, output_stream: OutputStream) -> bool { + match output_stream { + OutputStream::Stdout => self.stdout_colors, + OutputStream::Stderr => self.stderr_colors, + } + } + } + + #[derive(Copy, Clone, Debug, Eq, PartialEq)] + enum OutputStream { + Stdout, + Stderr, + } + + fn output_stream_for_target(target: &str) -> OutputStream { + if target == super::ACCESS_LOG_TARGET { + OutputStream::Stdout + } else { + OutputStream::Stderr + } + } + + fn write_to_output(output_stream: OutputStream, buf: &str) { + use std::io::Write as _; + + match output_stream { + OutputStream::Stdout => { + let _ = io::stdout().write_all(buf.as_bytes()); + } + OutputStream::Stderr => { + let _ = io::stderr().write_all(buf.as_bytes()); + } + } + } + + fn event_target<'a>( + event: &'a tracing::Event<'_>, + event_fields: &'a HashMap<&'static str, String>, + ) -> &'a str { + event_fields + .get("log.target") + .map_or_else(|| event.metadata().target(), String::as_str) + } + + fn is_multiline_terminal_message(colors: bool, msg: Option<&String>) -> bool { + colors && msg.is_some_and(|message| message.contains('\n')) + } + + fn write_timestamp(buf: &mut String, colors: bool) { + let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ"); + if colors { + let _ = write!(buf, "{DIM}ts={now}{RESET}"); + } else { + let _ = write!(buf, "ts={now}"); + } + } + + fn write_level(buf: &mut String, level: tracing::Level, colors: bool) { + if colors { + let (color, label) = level_style(level); + let _ = write!(buf, " {DIM}level={RESET}{BOLD}{color}{label}{RESET}"); + } else { + let _ = write!(buf, " level={}", level.as_str().to_ascii_lowercase()); + } + } + + fn level_style(level: tracing::Level) -> (&'static str, &'static str) { + match level { + tracing::Level::ERROR => (RED, "error"), + tracing::Level::WARN => (YELLOW, "warn"), + tracing::Level::INFO => (GREEN, "info"), + tracing::Level::DEBUG => (BLUE, "debug"), + tracing::Level::TRACE => (DIM, "trace"), + } + } + + fn write_dimmed_field(buf: &mut String, key: &str, value: &str, colors: bool) { + if colors { + let _ = write!(buf, " {DIM}{key}={value}{RESET}"); + } else { + let _ = write!(buf, " {key}={value}"); + } + } + + fn write_message(buf: &mut String, msg: Option<&String>, multiline_msg: bool) { + if !multiline_msg && let Some(msg) = msg.filter(|msg| !msg.is_empty()) { + write_logfmt_value(buf, "msg", msg); + } + } + + fn write_event_fields( + buf: &mut String, + event_fields: &HashMap<&'static str, String>, + include_all_event_fields: bool, + ) { + if !include_all_event_fields { + return; + } + + let mut extra_fields = BTreeMap::new(); + for (&key, value) in event_fields { + if key == "message" || key == "log.target" { + continue; + } + extra_fields.insert(key, value); + } + + for (key, value) in extra_fields { + write_logfmt_value(buf, key, value); + } + } + + fn write_span_fields( + buf: &mut String, + scope: Option>, + include_all_span_fields: bool, + ) where + S: Subscriber + for<'a> LookupSpan<'a>, + { + if let Some(scope) = scope { + let mut seen_mapped_fields = [false; SPAN_FIELDS.len()]; + let mut extra_fields = BTreeMap::new(); + + for span in scope { + let ext = span.extensions(); + if let Some(fields) = ext.get::() { + for (i, &(span_key, logfmt_key)) in SPAN_FIELDS.iter().enumerate() { + if seen_mapped_fields[i] { + continue; + } + if let Some(val) = fields.0.get(span_key) { + write_logfmt_value(buf, logfmt_key, val); + seen_mapped_fields[i] = true; + } + } + if include_all_span_fields { + for (&key, val) in &fields.0 { + if SPAN_FIELDS.iter().any(|(span_key, _)| key == *span_key) { + continue; + } + extra_fields.entry(key).or_insert_with(|| val.clone()); + } + } + } + } + + if include_all_span_fields { + for (key, val) in extra_fields { + write_logfmt_value(buf, key, &val); + } + } + } + } + + fn includes_all_span_fields() -> bool { + tracing::level_filters::LevelFilter::current() >= tracing::level_filters::LevelFilter::DEBUG + } + + #[cfg(test)] + fn write_span_field_maps<'a>( + buf: &mut String, + span_fields: impl IntoIterator>, + include_all_span_fields: bool, + ) { + let mut seen_mapped_fields = [false; SPAN_FIELDS.len()]; + let mut extra_fields = BTreeMap::new(); + + for fields in span_fields { + for (i, &(span_key, logfmt_key)) in SPAN_FIELDS.iter().enumerate() { + if seen_mapped_fields[i] { + continue; + } + if let Some(val) = fields.get(span_key) { + write_logfmt_value(buf, logfmt_key, val); + seen_mapped_fields[i] = true; + } + } + if include_all_span_fields { + for (&key, val) in fields { + if SPAN_FIELDS.iter().any(|(span_key, _)| key == *span_key) { + continue; + } + extra_fields.entry(key).or_insert_with(|| val.clone()); + } + } + } + + if include_all_span_fields { + for (key, val) in extra_fields { + write_logfmt_value(buf, key, &val); + } + } + } + + fn write_trace_id( + buf: &mut String, + scope: Option>, + colors: bool, + ) where + S: Subscriber + for<'a> LookupSpan<'a>, + { + if let Some(trace_id) = first_valid_trace_id(scope) { + write_dimmed_field(buf, "trace_id", &trace_id, colors); + } + } + + fn first_valid_trace_id( + scope: Option>, + ) -> Option + where + S: Subscriber + for<'a> LookupSpan<'a>, + { + use opentelemetry::trace::TraceContextExt as _; + + let span_ids: Vec<_> = scope?.map(|span| span.id()).collect(); + tracing::dispatcher::get_default(|dispatch| { + for span_id in &span_ids { + if let Some(otel_context) = + tracing_opentelemetry::get_otel_context(span_id, dispatch) + { + let trace_id = otel_context.span().span_context().trace_id().to_string(); + if trace_id != INVALID_TRACE_ID { + return Some(trace_id); + } + } + } + None + }) + } + + fn write_multiline_message(buf: &mut String, msg: Option<&String>, multiline_msg: bool) { + if multiline_msg && let Some(msg) = msg { + buf.push_str(msg); + buf.push('\n'); + } + } + + /// Write a logfmt key=value pair, quoting the value if it contains spaces or special chars. + fn write_logfmt_value(buf: &mut String, key: &str, value: &str) { + let needs_quoting = value.contains([' ', '"', '=', '\n', '\t']) || value.is_empty(); + + if needs_quoting { + let escaped = value.replace('\n', " ").replace('"', "\\\""); + let _ = write!(buf, " {key}=\"{escaped}\""); + } else { + let _ = write!(buf, " {key}={value}"); + } + } + + #[cfg(test)] + mod tests { + use super::*; + use crate::telemetry::env_filter_directives; + + #[test] + fn log_level_takes_precedence_over_rust_log() { + assert_eq!( + env_filter_directives(Some("sqlpage=debug"), Some("sqlpage=trace")), + "sqlpage=debug" + ); + } + + #[test] + fn rust_log_is_used_as_alias_when_log_level_is_missing() { + assert_eq!( + env_filter_directives(None, Some("sqlpage=trace")), + "sqlpage=trace" + ); + } + + #[test] + fn empty_values_fall_back_to_default_filter() { + assert_eq!( + env_filter_directives(Some(""), Some("")), + "sqlpage=info,actix_web=info,tracing_actix_web=info,opentelemetry=warn,opentelemetry_sdk=warn,opentelemetry_otlp=warn" + ); + } + + #[test] + fn debug_logs_include_unmapped_span_fields() { + let mut buf = String::new(); + let span_fields = HashMap::from([ + (otel::HTTP_REQUEST_METHOD, "GET".to_string()), + (otel::HTTP_ROUTE, "/users/:id".to_string()), + ("otel.kind", "server".to_string()), + ]); + + write_span_field_maps(&mut buf, [&span_fields], true); + + assert_eq!( + buf, + " http.request.method=GET http.route=/users/:id otel.kind=server" + ); + } + + #[test] + fn info_logs_keep_only_mapped_span_fields_when_not_in_debug_mode() { + let mut buf = String::new(); + let span_fields = HashMap::from([ + (otel::HTTP_REQUEST_METHOD, "GET".to_string()), + (otel::HTTP_ROUTE, "/users/:id".to_string()), + ("otel.kind", "server".to_string()), + ]); + + write_span_field_maps(&mut buf, [&span_fields], false); + + assert_eq!(buf, " http.request.method=GET"); + } + + #[test] + fn event_fields_are_rendered_when_message_is_missing() { + let mut buf = String::new(); + let event_fields = HashMap::from([ + ("message", String::new()), + ("log.target", "opentelemetry_sdk".to_string()), + ("name", "BatchSpanProcessor.ExportFailed".to_string()), + ("reason", "connection error".to_string()), + ]); + + write_event_fields(&mut buf, &event_fields, true); + + assert_eq!( + buf, + " name=BatchSpanProcessor.ExportFailed reason=\"connection error\"" + ); + } + + #[test] + fn access_logs_use_stdout_and_other_logs_use_stderr() { + assert_eq!( + output_stream_for_target(super::super::ACCESS_LOG_TARGET), + OutputStream::Stdout + ); + assert_eq!( + output_stream_for_target("sqlpage::webserver::http"), + OutputStream::Stderr + ); + } + } +} diff --git a/src/telemetry_metrics.rs b/src/telemetry_metrics.rs new file mode 100644 index 0000000..8997443 --- /dev/null +++ b/src/telemetry_metrics.rs @@ -0,0 +1,97 @@ +use opentelemetry::global; +use opentelemetry::metrics::{Histogram, ObservableGauge}; +use opentelemetry_semantic_conventions::attribute as otel; +use opentelemetry_semantic_conventions::metric as otel_metric; +use sqlx::AnyPool; + +pub struct TelemetryMetrics { + pub http_request_duration: Histogram, + pub db_query_duration: Histogram, + _pool_connection_count: ObservableGauge, +} + +impl Default for TelemetryMetrics { + fn default() -> Self { + let meter = global::meter("sqlpage"); + let http_request_duration = meter + .f64_histogram(otel_metric::HTTP_SERVER_REQUEST_DURATION) + .with_unit("s") + .with_description("Duration of HTTP requests processed by the server.") + .build(); + let db_query_duration = meter + .f64_histogram(otel_metric::DB_CLIENT_OPERATION_DURATION) + .with_unit("s") + .with_description("Duration of executing SQL queries.") + .build(); + // This default is only used in tests that don't touch pool metrics. + let pool_connection_count = meter + .i64_observable_gauge(otel_metric::DB_CLIENT_CONNECTION_COUNT) + .with_unit("{connection}") + .with_description("Number of connections in the database pool.") + .with_callback(|_| {}) + .build(); + + Self { + http_request_duration, + db_query_duration, + _pool_connection_count: pool_connection_count, + } + } +} + +impl TelemetryMetrics { + #[must_use] + pub fn new(pool: &AnyPool, db_system_name: &'static str) -> Self { + let meter = global::meter("sqlpage"); + let http_request_duration = meter + .f64_histogram(otel_metric::HTTP_SERVER_REQUEST_DURATION) + .with_unit("s") + .with_description("Duration of HTTP requests processed by the server.") + .build(); + let db_query_duration = meter + .f64_histogram(otel_metric::DB_CLIENT_OPERATION_DURATION) + .with_unit("s") + .with_description("Duration of executing SQL queries.") + .build(); + let pool_ref = pool.clone(); + let pool_connection_count = meter + .i64_observable_gauge(otel_metric::DB_CLIENT_CONNECTION_COUNT) + .with_unit("{connection}") + .with_description("Number of connections in the database pool.") + .with_callback(move |observer| { + let size = pool_ref.size(); + let idle_u32 = u32::try_from(pool_ref.num_idle()).unwrap_or(u32::MAX); + let used = i64::from(size.saturating_sub(idle_u32)); + let idle = i64::from(idle_u32); + observer.observe( + used, + &[ + opentelemetry::KeyValue::new(otel::DB_SYSTEM_NAME, db_system_name), + opentelemetry::KeyValue::new( + otel::DB_CLIENT_CONNECTION_POOL_NAME, + "sqlpage", + ), + opentelemetry::KeyValue::new(otel::DB_CLIENT_CONNECTION_STATE, "used"), + ], + ); + observer.observe( + idle, + &[ + opentelemetry::KeyValue::new(otel::DB_SYSTEM_NAME, db_system_name), + opentelemetry::KeyValue::new( + otel::DB_CLIENT_CONNECTION_POOL_NAME, + "sqlpage", + ), + opentelemetry::KeyValue::new(otel::DB_CLIENT_CONNECTION_STATE, "idle"), + ], + ); + }) + .build(); + + Self { + http_request_duration, + db_query_duration, + _pool_connection_count: pool_connection_count, + } + } +} diff --git a/src/template_helpers.rs b/src/template_helpers.rs new file mode 100644 index 0000000..9e2a5da --- /dev/null +++ b/src/template_helpers.rs @@ -0,0 +1,734 @@ +use std::{borrow::Cow, collections::HashMap, sync::LazyLock}; + +use crate::{app_config::AppConfig, utils::static_filename}; +use anyhow::Context as _; +use handlebars::{ + Context, Handlebars, HelperDef, JsonTruthy, PathAndJson, RenderError, RenderErrorReason, + Renderable, ScopedJson, handlebars_helper, +}; +use serde_json::Value as JsonValue; + +/// Simple static json helper +type H0 = fn() -> JsonValue; +/// Simple json to json helper +type H = fn(&JsonValue) -> JsonValue; +/// Simple json to json helper with error handling +type EH = fn(&JsonValue) -> anyhow::Result; +/// Helper that takes two arguments +type HH = fn(&JsonValue, &JsonValue) -> JsonValue; +/// Helper that takes three arguments +#[allow(clippy::upper_case_acronyms)] +type HHH = fn(&JsonValue, &JsonValue, &JsonValue) -> JsonValue; + +pub fn register_all_helpers(h: &mut Handlebars<'_>, config: &AppConfig) { + let site_prefix = config.site_prefix.clone(); + + register_helper(h, "all", HelperCheckTruthy(false)); + register_helper(h, "any", HelperCheckTruthy(true)); + + register_helper(h, "stringify", stringify_helper as H); + register_helper(h, "parse_json", parse_json_helper as EH); + register_helper(h, "default", default_helper as HH); + register_helper(h, "entries", entries_helper as H); + register_helper(h, "replace", replace_helper as HHH); + // delay helper: store a piece of information in memory that can be output later with flush_delayed + h.register_helper("delay", Box::new(delay_helper)); + h.register_helper("flush_delayed", Box::new(flush_delayed_helper)); + register_helper(h, "plus", plus_helper as HH); + register_helper(h, "minus", minus_helper as HH); + h.register_helper("sum", Box::new(sum_helper)); + register_helper(h, "loose_eq", loose_eq_helper as HH); + register_helper(h, "starts_with", starts_with_helper as HH); + + // to_array: convert a value to a single-element array. If the value is already an array, return it as-is. + register_helper(h, "to_array", to_array_helper as H); + + // array_contains: check if an array contains an element. If the first argument is not an array, it is compared to the second argument. + handlebars_helper!(array_contains: |array: Json, element: Json| match array { + JsonValue::Array(arr) => arr.contains(element), + other => other == element + }); + h.register_helper("array_contains", Box::new(array_contains)); + + // array_contains_case_insensitive: check if an array contains an element case-insensitively. If the first argument is not an array, it is compared to the second argument case-insensitively. + handlebars_helper!(array_contains_case_insensitive: |array: Json, element: Json| { + match array { + JsonValue::Array(arr) => arr.iter().any(|v| json_eq_case_insensitive(v, element)), + other => json_eq_case_insensitive(other, element), + } + }); + h.register_helper( + "array_contains_case_insensitive", + Box::new(array_contains_case_insensitive), + ); + + // static_path helper: generate a path to a static file. Replaces sqpage.js by sqlpage..js + register_helper(h, "static_path", StaticPathHelper(site_prefix.clone())); + register_helper(h, "app_config", AppConfigHelper(config.clone())); + + // icon helper: generate an image with the specified icon + h.register_helper("icon_img", Box::new(IconImgHelper)); + register_helper(h, "markdown", MarkdownHelper::new(config)); + register_helper(h, "buildinfo", buildinfo_helper as EH); + register_helper(h, "typeof", typeof_helper as H); + register_helper(h, "rfc2822_date", rfc2822_date_helper as EH); + register_helper(h, "url_encode", url_encode_helper as H); + register_helper(h, "csv_escape", csv_escape_helper as HH); +} + +fn json_eq_case_insensitive(a: &JsonValue, b: &JsonValue) -> bool { + match (a, b) { + (JsonValue::String(a), JsonValue::String(b)) => a.eq_ignore_ascii_case(b), + _ => a == b, + } +} + +fn stringify_helper(v: &JsonValue) -> JsonValue { + v.to_string().into() +} + +fn parse_json_helper(v: &JsonValue) -> Result { + Ok(match v { + serde_json::value::Value::String(s) => serde_json::from_str(s)?, + other => other.clone(), + }) +} + +fn default_helper(v: &JsonValue, default: &JsonValue) -> JsonValue { + if v.is_null() { + default.clone() + } else { + v.clone() + } +} + +fn plus_helper(a: &JsonValue, b: &JsonValue) -> JsonValue { + if let (Some(a), Some(b)) = (a.as_i64(), b.as_i64()) { + (a + b).into() + } else if let (Some(a), Some(b)) = (a.as_f64(), b.as_f64()) { + (a + b).into() + } else { + JsonValue::Null + } +} + +fn minus_helper(a: &JsonValue, b: &JsonValue) -> JsonValue { + if let (Some(a), Some(b)) = (a.as_i64(), b.as_i64()) { + (a - b).into() + } else if let (Some(a), Some(b)) = (a.as_f64(), b.as_f64()) { + (a - b).into() + } else { + JsonValue::Null + } +} + +fn starts_with_helper(a: &JsonValue, b: &JsonValue) -> JsonValue { + if let (Some(a), Some(b)) = (a.as_str(), b.as_str()) { + a.starts_with(b) + } else if let (Some(arr1), Some(arr2)) = (a.as_array(), b.as_array()) { + arr1.starts_with(arr2) + } else { + false + } + .into() +} +fn entries_helper(v: &JsonValue) -> JsonValue { + match v { + serde_json::value::Value::Object(map) => map + .into_iter() + .map(|(k, v)| serde_json::json!({"key": k, "value": v})) + .collect(), + serde_json::value::Value::Array(values) => values + .iter() + .enumerate() + .map(|(k, v)| serde_json::json!({"key": k, "value": v})) + .collect(), + _ => vec![], + } + .into() +} + +fn to_array_helper(v: &JsonValue) -> JsonValue { + match v { + JsonValue::Array(arr) => arr.clone(), + JsonValue::Null => vec![], + JsonValue::String(s) if s.starts_with('[') => { + if let Ok(JsonValue::Array(r)) = serde_json::from_str(s) { + r + } else { + vec![JsonValue::String(s.clone())] + } + } + other => vec![other.clone()], + } + .into() +} + +/// Generate the full path to a builtin sqlpage asset. Struct Param is the site prefix +struct StaticPathHelper(String); + +impl CanHelp for StaticPathHelper { + fn call(&self, args: &[PathAndJson]) -> Result { + let static_file = match args { + [v] => v.value(), + _ => return Err("expected one argument".to_string()), + }; + let name = static_file + .as_str() + .ok_or_else(|| format!("static_path: not a string: {static_file}"))?; + let path = match name { + "sqlpage.js" => static_filename!("sqlpage.js"), + "sqlpage.css" => static_filename!("sqlpage.css"), + "apexcharts.js" => static_filename!("apexcharts.js"), + "tomselect.js" => static_filename!("tomselect.js"), + "favicon.svg" => static_filename!("favicon.svg"), + other => return Err(format!("unknown static file: {other:?}")), + }; + Ok(format!("{}{}", self.0, path).into()) + } +} + +/// Generate the full path to a builtin sqlpage asset. Struct Param is the site prefix +struct AppConfigHelper(AppConfig); + +impl CanHelp for AppConfigHelper { + fn call(&self, args: &[PathAndJson]) -> Result { + let static_file = match args { + [v] => v.value(), + _ => return Err("expected one argument".to_string()), + }; + let name = static_file + .as_str() + .ok_or_else(|| format!("app_config: not a string: {static_file}"))?; + match name { + "max_uploaded_file_size" => Ok(JsonValue::Number(self.0.max_uploaded_file_size.into())), + "environment" => serde_json::to_value(self.0.environment).map_err(|e| e.to_string()), + "site_prefix" => Ok(self.0.site_prefix.clone().into()), + other => Err(format!("unknown app config property: {other:?}")), + } + } +} + +pub static ICON_MAP: LazyLock> = + LazyLock::new(|| include!(concat!(env!("OUT_DIR"), "/icons.rs")).into()); + +/// Generate an image with the specified icon. +struct IconImgHelper; +impl HelperDef for IconImgHelper { + fn call<'reg: 'rc, 'rc>( + &self, + helper: &handlebars::Helper<'rc>, + _r: &'reg Handlebars<'reg>, + _ctx: &'rc Context, + _rc: &mut handlebars::RenderContext<'reg, 'rc>, + writer: &mut dyn handlebars::Output, + ) -> handlebars::HelperResult { + let null = handlebars::JsonValue::Null; + let [name, size] = [0, 1].map(|i| helper.params().get(i).map_or(&null, PathAndJson::value)); + let size = size.as_u64().unwrap_or(24); + let content = name.as_str().and_then(|name| ICON_MAP.get(name)); + let Some(&inner_content) = content else { + log::warn!("icon_img: icon {name} not found"); + return Ok(()); + }; + + write!( + writer, + r#"{inner_content}"# + )?; + Ok(()) + } +} + +fn typeof_helper(v: &JsonValue) -> JsonValue { + match v { + JsonValue::Null => "null", + JsonValue::Bool(_) => "boolean", + JsonValue::Number(_) => "number", + JsonValue::String(_) => "string", + JsonValue::Array(_) => "array", + JsonValue::Object(_) => "object", + } + .into() +} + +pub trait MarkdownConfig { + fn allow_dangerous_html(&self) -> bool; + fn allow_dangerous_protocol(&self) -> bool; +} + +impl MarkdownConfig for AppConfig { + fn allow_dangerous_html(&self) -> bool { + self.markdown_allow_dangerous_html + } + + fn allow_dangerous_protocol(&self) -> bool { + self.markdown_allow_dangerous_protocol + } +} + +/// Helper to render markdown with configurable options +#[derive(Default)] +struct MarkdownHelper { + allow_dangerous_html: bool, + allow_dangerous_protocol: bool, +} + +impl MarkdownHelper { + fn new(config: &impl MarkdownConfig) -> Self { + Self { + allow_dangerous_html: config.allow_dangerous_html(), + allow_dangerous_protocol: config.allow_dangerous_protocol(), + } + } + + fn get_preset_options(&self, preset_name: &str) -> Result { + let mut options = markdown::Options::gfm(); + options.compile.allow_dangerous_html = self.allow_dangerous_html; + options.compile.allow_dangerous_protocol = self.allow_dangerous_protocol; + options.compile.allow_any_img_src = true; + + match preset_name { + "default" => {} + "allow_unsafe" => { + options.compile.allow_dangerous_html = true; + options.compile.allow_dangerous_protocol = true; + } + _ => return Err(format!("unknown markdown preset: {preset_name}")), + } + + Ok(options) + } +} + +impl CanHelp for MarkdownHelper { + fn call(&self, args: &[PathAndJson]) -> Result { + let (markdown_src_value, preset_name) = match args { + [v] => (v.value(), "default"), + [v, preset] => { + let value = v.value(); + let preset_name_value = preset.value(); + let preset = preset_name_value.as_str() + .ok_or_else(|| format!("markdown template helper expects a string as preset name. Got: {preset_name_value}"))?; + (value, preset) + } + _ => return Err("markdown template helper expects one or two arguments".to_string()), + }; + let markdown_src = match markdown_src_value { + JsonValue::String(s) => Cow::Borrowed(s), + JsonValue::Array(arr) => Cow::Owned( + arr.iter() + .map(|v| v.as_str().unwrap_or_default()) + .collect::>() + .join("\n"), + ), + JsonValue::Null => Cow::Owned(String::new()), + other => Cow::Owned(other.to_string()), + }; + + let options = self.get_preset_options(preset_name)?; + markdown::to_html_with_options(&markdown_src, &options) + .map(JsonValue::String) + .map_err(|e| e.to_string()) + } +} + +fn buildinfo_helper(x: &JsonValue) -> anyhow::Result { + match x { + JsonValue::String(s) if s == "CARGO_PKG_NAME" => Ok(env!("CARGO_PKG_NAME").into()), + JsonValue::String(s) if s == "CARGO_PKG_VERSION" => Ok(env!("CARGO_PKG_VERSION").into()), + other => Err(anyhow::anyhow!("unknown buildinfo key: {other:?}")), + } +} + +// rfc2822_date: take an ISO date and convert it to an RFC 2822 date +fn rfc2822_date_helper(v: &JsonValue) -> anyhow::Result { + let date: chrono::DateTime = match v { + JsonValue::String(s) => { + // we accept both dates with and without time + chrono::DateTime::parse_from_rfc3339(s) + .or_else(|_| { + chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") + .map(|d| d.and_hms_opt(0, 0, 0).unwrap().and_utc().fixed_offset()) + }) + .with_context(|| format!("invalid date: {s}"))? + } + JsonValue::Number(n) => { + chrono::DateTime::from_timestamp(n.as_i64().with_context(|| "not a timestamp")?, 0) + .with_context(|| "invalid timestamp")? + .into() + } + other => anyhow::bail!("expected a date, got {other:?}"), + }; + // format: Thu, 01 Jan 1970 00:00:00 +0000 + Ok(date.format("%a, %d %b %Y %T %z").to_string().into()) +} + +// Percent-encode a string +fn url_encode_helper(v: &JsonValue) -> JsonValue { + let as_str = match v { + JsonValue::String(s) => s, + other => &other.to_string(), + }; + percent_encoding::percent_encode(as_str.as_bytes(), percent_encoding::NON_ALPHANUMERIC) + .to_string() + .into() +} + +// Percent-encode a string +fn csv_escape_helper(v: &JsonValue, separator: &JsonValue) -> JsonValue { + let as_str = match v { + JsonValue::String(s) => s, + other => &other.to_string(), + }; + let separator = separator.as_str().unwrap_or(","); + if as_str.contains(separator) || as_str.contains('"') || as_str.contains('\n') { + format!(r#""{}""#, as_str.replace('"', r#""""#)).into() + } else { + as_str.to_owned().into() + } +} + +fn with_each_block<'a, 'reg, 'rc>( + rc: &'a mut handlebars::RenderContext<'reg, 'rc>, + mut action: impl FnMut(&mut handlebars::BlockContext<'rc>, bool) -> Result<(), RenderError>, +) -> Result<(), RenderError> { + let mut blks = Vec::new(); + while let Some(mut top) = rc.block_mut().map(std::mem::take) { + rc.pop_block(); + action(&mut top, rc.block().is_none())?; + blks.push(top); + } + while let Some(blk) = blks.pop() { + rc.push_block(blk); + } + Ok(()) +} + +pub(crate) const DELAYED_CONTENTS: &str = "_delayed_contents"; + +fn delay_helper<'reg, 'rc>( + h: &handlebars::Helper<'rc>, + r: &'reg Handlebars<'reg>, + ctx: &'rc Context, + rc: &mut handlebars::RenderContext<'reg, 'rc>, + _out: &mut dyn handlebars::Output, +) -> handlebars::HelperResult { + let inner = h + .template() + .ok_or(RenderErrorReason::BlockContentRequired)?; + let mut str_out = handlebars::StringOutput::new(); + inner.render(r, ctx, rc, &mut str_out)?; + let mut delayed_render = str_out.into_string()?; + with_each_block(rc, |block, is_last| { + if is_last { + let old_delayed_render = block + .get_local_var(DELAYED_CONTENTS) + .and_then(JsonValue::as_str) + .unwrap_or_default(); + delayed_render += old_delayed_render; + let contents = JsonValue::String(std::mem::take(&mut delayed_render)); + block.set_local_var(DELAYED_CONTENTS, contents); + } + Ok(()) + })?; + Ok(()) +} + +fn flush_delayed_helper<'reg, 'rc>( + _h: &handlebars::Helper<'rc>, + _r: &'reg Handlebars<'reg>, + _ctx: &'rc Context, + rc: &mut handlebars::RenderContext<'reg, 'rc>, + writer: &mut dyn handlebars::Output, +) -> handlebars::HelperResult { + with_each_block(rc, |block_context, _last| { + let delayed = block_context + .get_local_var(DELAYED_CONTENTS) + .and_then(JsonValue::as_str) + .filter(|s| !s.is_empty()); + if let Some(contents) = delayed { + writer.write(contents)?; + block_context.set_local_var(DELAYED_CONTENTS, JsonValue::Null); + } + Ok(()) + }) +} + +fn sum_helper<'reg, 'rc>( + helper: &handlebars::Helper<'rc>, + _r: &'reg Handlebars<'reg>, + _ctx: &'rc Context, + _rc: &mut handlebars::RenderContext<'reg, 'rc>, + writer: &mut dyn handlebars::Output, +) -> handlebars::HelperResult { + let mut sum = 0f64; + for v in helper.params() { + sum += v + .value() + .as_f64() + .ok_or(RenderErrorReason::InvalidParamType("number"))?; + } + write!(writer, "{sum}")?; + Ok(()) +} + +/// Compare two values loosely, i.e. treat all values as strings. (42 == "42") +fn loose_eq_helper(a: &JsonValue, b: &JsonValue) -> JsonValue { + match (a, b) { + (JsonValue::String(a), JsonValue::String(b)) => a == b, + (JsonValue::String(a), non_str) => a == &non_str.to_string(), + (non_str, JsonValue::String(b)) => &non_str.to_string() == b, + (a, b) => a == b, + } + .into() +} +/// Helper that returns the first argument with the given truthiness, or the last argument if none have it. +/// Equivalent to a && b && c && ... if the truthiness is false, +/// or a || b || c || ... if the truthiness is true. +pub struct HelperCheckTruthy(bool); + +impl CanHelp for HelperCheckTruthy { + fn call(&self, args: &[PathAndJson]) -> Result { + for arg in args { + if arg.value().is_truthy(false) == self.0 { + return Ok(arg.value().clone()); + } + } + if let Some(last) = args.last() { + Ok(last.value().clone()) + } else { + Err("expected at least one argument".to_string()) + } + } +} + +trait CanHelp: Send + Sync + 'static { + fn call(&self, v: &[PathAndJson]) -> Result; +} + +impl CanHelp for H0 { + fn call(&self, args: &[PathAndJson]) -> Result { + match args { + [] => Ok(self()), + _ => Err("expected no arguments".to_string()), + } + } +} + +impl CanHelp for H { + fn call(&self, args: &[PathAndJson]) -> Result { + match args { + [v] => Ok(self(v.value())), + _ => Err("expected one argument".to_string()), + } + } +} + +impl CanHelp for EH { + fn call(&self, args: &[PathAndJson]) -> Result { + match args { + [v] => self(v.value()).map_err(|e| e.to_string()), + _ => Err("expected one argument".to_string()), + } + } +} + +impl CanHelp for HH { + fn call(&self, args: &[PathAndJson]) -> Result { + match args { + [a, b] => Ok(self(a.value(), b.value())), + _ => Err("expected two arguments".to_string()), + } + } +} + +impl CanHelp for HHH { + fn call(&self, args: &[PathAndJson]) -> Result { + match args { + [a, b, c] => Ok(self(a.value(), b.value(), c.value())), + _ => Err("expected three arguments".to_string()), + } + } +} + +struct JFun { + name: &'static str, + fun: F, +} +impl handlebars::HelperDef for JFun { + fn call_inner<'reg: 'rc, 'rc>( + &self, + helper: &handlebars::Helper<'rc>, + _r: &'reg Handlebars<'reg>, + _: &'rc Context, + _rc: &mut handlebars::RenderContext<'reg, 'rc>, + ) -> Result, RenderError> { + let result = self + .fun + .call(helper.params().as_slice()) + .map_err(|s| RenderErrorReason::Other(format!("{}: {}", self.name, s)))?; + Ok(ScopedJson::Derived(result)) + } +} + +fn register_helper(h: &mut Handlebars, name: &'static str, fun: impl CanHelp) { + h.register_helper(name, Box::new(JFun { name, fun })); +} + +fn replace_helper(text: &JsonValue, original: &JsonValue, replacement: &JsonValue) -> JsonValue { + let text_str = match text { + JsonValue::String(s) => s, + other => &other.to_string(), + }; + let original_str = match original { + JsonValue::String(s) => s, + other => &other.to_string(), + }; + let replacement_str = match replacement { + JsonValue::String(s) => s, + other => &other.to_string(), + }; + + text_str.replace(original_str, replacement_str).into() +} + +#[cfg(test)] +mod tests { + use crate::template_helpers::{CanHelp, MarkdownHelper, rfc2822_date_helper}; + use handlebars::{JsonValue, PathAndJson, ScopedJson}; + use serde_json::Value; + + const CONTENT_KEY: &str = "contents_md"; + + #[test] + fn test_rfc2822_date() { + assert_eq!( + rfc2822_date_helper(&JsonValue::String("1970-01-02T03:04:05+02:00".into())) + .unwrap() + .as_str() + .unwrap(), + "Fri, 02 Jan 1970 03:04:05 +0200" + ); + assert_eq!( + rfc2822_date_helper(&JsonValue::String("1970-01-02".into())) + .unwrap() + .as_str() + .unwrap(), + "Fri, 02 Jan 1970 00:00:00 +0000" + ); + } + + #[test] + fn test_basic_gfm_markdown() { + let helper = MarkdownHelper::default(); + + let contents = Value::String("# Heading".to_string()); + let actual = helper.call(&as_args(&contents)).unwrap(); + + assert_eq!(Some("

Heading

"), actual.as_str()); + } + + // Optionally allow potentially unsafe html blocks + // See https://spec.commonmark.org/0.31.2/#html-blocks + mod markdown_html_blocks { + + use super::*; + + const UNSAFE_MARKUP: &str = "
"; + const ESCAPED_UNSAFE_MARKUP: &str = "<table><tr><td>"; + #[test] + fn test_html_blocks_with_various_settings() { + struct TestCase { + name: &'static str, + preset: Option, + expected_output: Result<&'static str, String>, + } + + let helper = MarkdownHelper::default(); + let content = contents(); + + let test_cases = [ + TestCase { + name: "default settings", + preset: Some(Value::String("default".to_string())), + expected_output: Ok(ESCAPED_UNSAFE_MARKUP), + }, + TestCase { + name: "allow_unsafe preset", + preset: Some(Value::String("allow_unsafe".to_string())), + expected_output: Ok(UNSAFE_MARKUP), + }, + TestCase { + name: "undefined allow_unsafe", + preset: Some(Value::Null), + expected_output: Err( + "markdown template helper expects a string as preset name. Got: null" + .to_string(), + ), + }, + TestCase { + name: "allow_unsafe is false", + preset: Some(Value::Bool(false)), + expected_output: Err( + "markdown template helper expects a string as preset name. Got: false" + .to_string(), + ), + }, + ]; + + for case in test_cases { + let args = match case.preset { + None => &as_args(&content)[..], + Some(ref preset) => &as_args_with_unsafe(&content, preset)[..], + }; + + match helper.call(args) { + Ok(actual) => assert_eq!( + case.expected_output.unwrap(), + actual.as_str().unwrap(), + "Failed on case: {}", + case.name + ), + Err(e) => assert_eq!( + case.expected_output.unwrap_err(), + e, + "Failed on case: {}", + case.name + ), + } + } + } + + fn as_args_with_unsafe<'a>( + contents: &'a Value, + allow_unsafe: &'a Value, + ) -> [PathAndJson<'a>; 2] { + [ + as_helper_arg(CONTENT_KEY, contents), + as_helper_arg("allow_unsafe", allow_unsafe), + ] + } + + fn contents() -> Value { + Value::String(UNSAFE_MARKUP.to_string()) + } + } + + fn as_args(contents: &Value) -> [PathAndJson<'_>; 1] { + [as_helper_arg(CONTENT_KEY, contents)] + } + + fn as_helper_arg<'a>(path: &'a str, value: &'a Value) -> PathAndJson<'a> { + let json_context = as_json_context(path, value); + to_path_and_json(path, json_context) + } + + fn to_path_and_json<'a>(path: &'a str, value: ScopedJson<'a>) -> PathAndJson<'a> { + PathAndJson::new(Some(path.to_string()), value) + } + + fn as_json_context<'a>(path: &'a str, value: &'a Value) -> ScopedJson<'a> { + ScopedJson::Context(value, vec![path.to_string()]) + } +} diff --git a/src/templates.rs b/src/templates.rs new file mode 100644 index 0000000..af001f7 --- /dev/null +++ b/src/templates.rs @@ -0,0 +1,162 @@ +use crate::app_config::AppConfig; +use crate::file_cache::AsyncFromStrWithState; +use crate::filesystem::FileAccess; +use crate::template_helpers::register_all_helpers; +use crate::{AppState, FileCache, TEMPLATES_DIR}; +use async_trait::async_trait; +use handlebars::{Handlebars, Template, template::TemplateElement}; +use include_dir::{Dir, include_dir}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +pub struct SplitTemplate { + pub before_list: Template, + pub list_content: Template, + pub after_list: Template, +} + +impl SplitTemplate { + #[must_use] + pub fn name(&self) -> Option<&str> { + self.before_list.name.as_deref() + } +} + +pub fn split_template(mut original: Template) -> SplitTemplate { + let mut elements_after = Vec::new(); + let mut mapping_after = Vec::new(); + let mut items_template = None; + let found = original.elements.iter().position(is_template_list_item); + if let Some(idx) = found { + elements_after = original.elements.split_off(idx + 1); + mapping_after = original.mapping.split_off(idx + 1); + if let Some(TemplateElement::HelperBlock(tpl)) = original.elements.pop() { + original.mapping.pop(); + items_template = tpl.template; + } + } + let mut before_list = original.clone(); + let mut list_content = items_template.unwrap_or_default(); + let mut after_list = Template::new(); + let original_name = original.name.unwrap_or_default(); + before_list.name = Some(format!("{original_name} before each block")); + list_content.name = Some(format!("{original_name} each block")); + after_list.name = Some(format!("{original_name} after each block")); + after_list.elements = elements_after; + after_list.mapping = mapping_after; + SplitTemplate { + before_list, + list_content, + after_list, + } +} + +#[async_trait(? Send)] +impl AsyncFromStrWithState for SplitTemplate { + async fn from_str_with_state( + _app_state: &AppState, + source: &str, + source_path: &Path, + ) -> anyhow::Result { + log::debug!("Compiling template \"{}\"", source_path.display()); + let tpl = Template::compile_with_name(source, "SQLPage component".to_string())?; + Ok(split_template(tpl)) + } +} + +fn is_template_list_item(element: &TemplateElement) -> bool { + use Parameter::Name; + use handlebars::template::Parameter; + matches!(element, + TemplateElement::HelperBlock(tpl) + if matches!(&tpl.name, Name(name) if name == "each_row")) +} + +#[allow(clippy::module_name_repetitions)] +pub struct AllTemplates { + pub handlebars: Handlebars<'static>, + split_templates: FileCache, +} + +const STATIC_TEMPLATES: Dir = include_dir!("$CARGO_MANIFEST_DIR/sqlpage/templates"); + +impl AllTemplates { + pub fn init(config: &AppConfig) -> anyhow::Result { + let mut handlebars = Handlebars::new(); + register_all_helpers(&mut handlebars, config); + let mut this = Self { + handlebars, + split_templates: FileCache::new(), + }; + this.preregister_static_templates()?; + Ok(this) + } + + /// Embeds pre-defined templates directly in the binary in release mode + pub fn preregister_static_templates(&mut self) -> anyhow::Result<()> { + for file in STATIC_TEMPLATES.files() { + let mut path = PathBuf::from(TEMPLATES_DIR); + path.push(file.path()); + let name = file + .path() + .file_stem() + .unwrap() + .to_string_lossy() + .to_string(); + let source = String::from_utf8_lossy(file.contents()); + let tpl = Template::compile_with_name(&source, name)?; + let split_template = split_template(tpl); + self.split_templates.add_static(path, split_template); + } + Ok(()) + } + + fn template_path(name: &str) -> PathBuf { + let mut path: PathBuf = + PathBuf::with_capacity(TEMPLATES_DIR.len() + 1 + name.len() + ".handlebars".len()); + path.push(TEMPLATES_DIR); + path.push(name); + path.set_extension("handlebars"); + path + } + + pub async fn get_template( + &self, + app_state: &AppState, + name: &str, + ) -> anyhow::Result> { + use anyhow::Context; + let path = Self::template_path(name); + self.split_templates + .get(app_state, FileAccess::privileged(&path)) + .await + .with_context(|| format!("Unable to get the component '{name}'")) + } + + pub fn get_static_template(&self, name: &str) -> anyhow::Result> { + let path = Self::template_path(name); + self.split_templates.get_static(&path) + } +} +#[test] +fn test_split_template() { + let template = Template::compile( + "Hello {{name}} ! \ + {{#each_row}}
  • {{this}}
  • {{/each_row}}\ + end", + ) + .unwrap(); + let split = split_template(template); + assert_eq!( + split.before_list.elements, + Template::compile("Hello {{name}} ! ").unwrap().elements + ); + assert_eq!( + split.list_content.elements, + Template::compile("
  • {{this}}
  • ").unwrap().elements + ); + assert_eq!( + split.after_list.elements, + Template::compile("end").unwrap().elements + ); +} diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..93bdce3 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,34 @@ +use serde_json::{Map, Value}; + +#[must_use] +pub fn add_value_to_map( + mut map: Map, + (key, value): (String, Value), +) -> Map { + use Value::Array; + use serde_json::map::Entry::{Occupied, Vacant}; + match map.entry(key) { + Vacant(vacant) => { + vacant.insert(value); + } + Occupied(mut old_entry) => { + let mut new_array = if let Array(v) = value { v } else { vec![value] }; + match old_entry.get_mut() { + Array(old_array) => old_array.append(&mut new_array), + old_scalar => { + new_array.insert(0, old_scalar.take()); + *old_scalar = Array(new_array); + } + } + } + } + map +} + +macro_rules! static_filename { + ($filename:expr) => { + include_str!(concat!(env!("OUT_DIR"), "/", $filename, ".filename.txt")) + }; +} + +pub(crate) use static_filename; diff --git a/src/webserver/content_security_policy.rs b/src/webserver/content_security_policy.rs new file mode 100644 index 0000000..32f9af7 --- /dev/null +++ b/src/webserver/content_security_policy.rs @@ -0,0 +1,107 @@ +use actix_web::HttpResponseBuilder; +use actix_web::http::header::CONTENT_SECURITY_POLICY; +use rand::random; +use serde::Deserialize; + +pub const DEFAULT_CONTENT_SECURITY_POLICY: &str = "script-src 'self' 'nonce-{NONCE}'"; +pub const NONCE_PLACEHOLDER: &str = "{NONCE}"; + +#[derive(Debug, Clone)] +pub struct ContentSecurityPolicy { + pub nonce: u64, +} + +/// A template for the Content Security Policy header. +/// The template is a string that contains the nonce placeholder. +/// The nonce placeholder is replaced with the nonce value when the Content Security Policy is applied to a response. +/// This struct is cheap to clone. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContentSecurityPolicyTemplate { + pub template: String, + pub nonce_position: Option, +} + +impl ContentSecurityPolicyTemplate { + #[must_use] + pub fn is_enabled(&self) -> bool { + self.nonce_position.is_some() + } + + fn format_nonce(&self, nonce: u64) -> String { + if let Some(pos) = self.nonce_position { + format!( + "{}{}{}", + &self.template[..pos], + nonce, + &self.template[pos + NONCE_PLACEHOLDER.len()..] + ) + } else { + self.template.clone() + } + } +} + +impl Default for ContentSecurityPolicyTemplate { + fn default() -> Self { + Self::from(DEFAULT_CONTENT_SECURITY_POLICY) + } +} + +impl From<&str> for ContentSecurityPolicyTemplate { + fn from(s: &str) -> Self { + let nonce_position = s.find(NONCE_PLACEHOLDER); + Self { + template: s.to_owned(), + nonce_position, + } + } +} + +impl<'de> Deserialize<'de> for ContentSecurityPolicyTemplate { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s: String = Deserialize::deserialize(deserializer)?; + Ok(Self::from(s.as_str())) + } +} + +impl ContentSecurityPolicy { + #[must_use] + pub fn with_random_nonce() -> Self { + Self { nonce: random() } + } + + pub fn apply_to_response( + &self, + template: &ContentSecurityPolicyTemplate, + response: &mut HttpResponseBuilder, + ) { + if template.is_enabled() { + response.insert_header((CONTENT_SECURITY_POLICY, template.format_nonce(self.nonce))); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_content_security_policy_display() { + let template = ContentSecurityPolicyTemplate::from( + "script-src 'self' 'nonce-{NONCE}' 'unsafe-inline'", + ); + let csp = ContentSecurityPolicy::with_random_nonce(); + let csp_str = template.format_nonce(csp.nonce); + assert!(csp_str.starts_with("script-src 'self' 'nonce-")); + assert!(csp_str.ends_with("' 'unsafe-inline'")); + let second_csp = ContentSecurityPolicy::with_random_nonce(); + let second_csp_str = template.format_nonce(second_csp.nonce); + assert_ne!( + csp_str, second_csp_str, + "We should not generate the same nonce twice" + ); + } +} diff --git a/src/webserver/database/blob_to_data_url.rs b/src/webserver/database/blob_to_data_url.rs new file mode 100644 index 0000000..b8e1fad --- /dev/null +++ b/src/webserver/database/blob_to_data_url.rs @@ -0,0 +1,189 @@ +/// Detects MIME type based on file signatures (magic bytes). +/// Returns the most appropriate MIME type for common file formats. +#[must_use] +pub fn detect_mime_type(bytes: &[u8]) -> &'static str { + // PNG: 89 50 4E 47 0D 0A 1A 0A + if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + return "image/png"; + } + // JPEG: FF D8 + if bytes.starts_with(b"\xFF\xD8") { + return "image/jpeg"; + } + // GIF87a/89a: GIF87a or GIF89a + if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") { + return "image/gif"; + } + // BMP: 42 4D + if bytes.starts_with(b"BM") { + return "image/bmp"; + } + // WebP: RIFF....WEBP + if bytes.starts_with(b"RIFF") && bytes.len() >= 12 && &bytes[8..12] == b"WEBP" { + return "image/webp"; + } + // PDF: %PDF + if bytes.starts_with(b"%PDF") { + return "application/pdf"; + } + // ZIP: 50 4B 03 04 + if bytes.starts_with(b"PK\x03\x04") { + // Check for Office document types in ZIP central directory + if bytes.len() >= 50 { + let central_dir = &bytes[30..bytes.len().min(50)]; + if central_dir.windows(6).any(|w| w == b"word/") { + return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + } + if central_dir.windows(3).any(|w| w == b"xl/") { + return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + } + if central_dir.windows(4).any(|w| w == b"ppt/") { + return "application/vnd.openxmlformats-officedocument.presentationml.presentation"; + } + } + return "application/zip"; + } + + if bytes.starts_with(b" String { + let mime_type = detect_mime_type(bytes); + vec_to_data_uri_with_mime(bytes, mime_type) +} + +/// Converts binary data to a data URL string with a specific MIME type. +/// This function is used by both SQL type conversion and file reading functions. +#[must_use] +pub fn vec_to_data_uri_with_mime(bytes: &[u8], mime_type: &str) -> String { + let mut data_url = format!("data:{mime_type};base64,"); + base64::Engine::encode_string( + &base64::engine::general_purpose::STANDARD, + bytes, + &mut data_url, + ); + data_url +} + +/// Converts binary data to a data URL JSON value. +/// This is a convenience function for SQL type conversion. +#[must_use] +pub fn vec_to_data_uri_value(bytes: &[u8]) -> serde_json::Value { + serde_json::Value::String(vec_to_data_uri(bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_detect_mime_type() { + // Test empty data + assert_eq!(detect_mime_type(&[]), "application/octet-stream"); + + // Test PNG + assert_eq!(detect_mime_type(b"\x89PNG\r\n\x1a\n"), "image/png"); + + // Test JPEG + assert_eq!(detect_mime_type(b"\xFF\xD8\xFF\xE0"), "image/jpeg"); + + // Test GIF87a + assert_eq!(detect_mime_type(b"GIF87a"), "image/gif"); + + // Test GIF89a + assert_eq!(detect_mime_type(b"GIF89a"), "image/gif"); + + // Test BMP + assert_eq!(detect_mime_type(b"BM\x00\x00"), "image/bmp"); + + // Test PDF + assert_eq!(detect_mime_type(b"%PDF-"), "application/pdf"); + + // Test SVG + assert_eq!( + detect_mime_type(b""), + "image/svg+xml" + ); + + // Test XML (non-SVG) + assert_eq!( + detect_mime_type(b"test"), + "application/xml" + ); + + // Test JSON + assert_eq!( + detect_mime_type(b"{\"key\": \"value\"}"), + "application/json" + ); + + // Test ZIP + assert_eq!(detect_mime_type(b"PK\x03\x04"), "application/zip"); + + // Test unknown data + assert_eq!( + detect_mime_type(&[0x00, 0x01, 0x02, 0x03]), + "application/octet-stream" + ); + } + + #[test] + fn test_vec_to_data_uri() { + // Test with empty bytes + let result = vec_to_data_uri(&[]); + assert_eq!(result, "data:application/octet-stream;base64,"); + + // Test with simple text + let result = vec_to_data_uri(b"Hello World"); + assert_eq!( + result, + "data:application/octet-stream;base64,SGVsbG8gV29ybGQ=" + ); + + // Test with binary data + let binary_data = [0, 1, 2, 255, 254, 253]; + let result = vec_to_data_uri(&binary_data); + assert_eq!(result, "data:application/octet-stream;base64,AAEC//79"); + } + + #[test] + fn test_vec_to_data_uri_with_mime() { + // Test with custom MIME type + let result = vec_to_data_uri_with_mime(b"Hello", "text/plain"); + assert_eq!(result, "data:text/plain;base64,SGVsbG8="); + + // Test with image MIME type + let result = vec_to_data_uri_with_mime(&[255, 216, 255], "image/jpeg"); + assert_eq!(result, "data:image/jpeg;base64,/9j/"); + + // Test with empty bytes and custom MIME + let result = vec_to_data_uri_with_mime(&[], "application/json"); + assert_eq!(result, "data:application/json;base64,"); + } + + #[test] + fn test_vec_to_data_uri_value() { + // Test that it returns a JSON string value + let result = vec_to_data_uri_value(b"test"); + match result { + serde_json::Value::String(s) => { + assert_eq!(s, "data:application/octet-stream;base64,dGVzdA=="); + } + _ => panic!("Expected String value"), + } + } +} diff --git a/src/webserver/database/connect.rs b/src/webserver/database/connect.rs new file mode 100644 index 0000000..3433e81 --- /dev/null +++ b/src/webserver/database/connect.rs @@ -0,0 +1,275 @@ +use std::{mem::take, time::Duration}; + +use super::Database; +use crate::{ + ON_CONNECT_FILE, ON_RESET_FILE, + app_config::AppConfig, + webserver::database::{DbInfo, SupportedDatabase}, +}; +use anyhow::Context; +use futures_util::future::BoxFuture; +use sqlx::odbc::OdbcConnectOptions; +use sqlx::{ + ConnectOptions, Connection, Executor, + any::{Any, AnyConnectOptions, AnyConnection, AnyKind}, + pool::PoolOptions, + sqlite::{Function, SqliteConnectOptions, SqliteFunctionCtx}, +}; + +impl Database { + pub async fn init(config: &AppConfig) -> anyhow::Result { + let database_url = &config.database_url; + let mut connect_options: AnyConnectOptions = database_url + .parse() + .with_context(|| format!("\"{database_url}\" is not a valid database URL. Please change the \"database_url\" option in the configuration file."))?; + if let Some(password) = &config.database_password { + set_database_password(&mut connect_options, password); + } + connect_options.log_statements(log::LevelFilter::Trace); + connect_options.log_slow_statements( + log::LevelFilter::Warn, + std::time::Duration::from_millis(250), + ); + log::debug!( + "Connecting to a {:?} database on {}", + connect_options.kind(), + database_url + ); + set_custom_connect_options(&mut connect_options, config); + log::debug!("Connecting to database: {database_url}"); + let mut retries = config.database_connection_retries; + + let mut conn: AnyConnection = loop { + match AnyConnection::connect_with(&connect_options).await { + Ok(c) => break c, + Err(e) => { + if retries == 0 { + return Err(anyhow::Error::new(e) + .context(format!("Unable to open connection to {database_url}"))); + } + log::warn!("Failed to connect to the database: {e:#}. Retrying in 5 seconds."); + retries -= 1; + tokio::time::sleep(Duration::from_secs(5)).await; + } + } + }; + let dbms_name: String = conn.dbms_name().await?; + let database_type = SupportedDatabase::from_dbms_name(&dbms_name); + drop(conn); + + let db_kind = connect_options.kind(); + let pool = Self::create_pool_options(config, db_kind) + .connect_with(connect_options) + .await + .with_context(|| format!("Unable to open connection pool to {database_url}"))?; + + log::debug!("Initialized {dbms_name:?} database pool: {pool:#?}"); + Ok(Database { + connection: pool, + info: DbInfo { + dbms_name, + database_type, + kind: db_kind, + }, + }) + } + + fn create_pool_options(config: &AppConfig, kind: AnyKind) -> PoolOptions { + let mut pool_options = PoolOptions::new() + .max_connections(if let Some(max) = config.max_database_pool_connections { + max + } else { + // Different databases have a different number of max concurrent connections allowed by default + match kind { + AnyKind::Postgres | AnyKind::Odbc => 50, // Default to PostgreSQL-like limits for Generic + AnyKind::MySql => 75, + AnyKind::Sqlite => { + if config.database_url.contains(":memory:") { + 128 + } else { + 16 + } + } + AnyKind::Mssql => 100, + } + }) + .idle_timeout(config.database_connection_idle_timeout) + .max_lifetime(config.database_connection_max_lifetime) + .acquire_timeout(Duration::from_secs_f64( + config.database_connection_acquire_timeout_seconds, + )); + pool_options = add_on_return_to_pool(config, pool_options); + pool_options = add_on_connection_handler(config, pool_options); + pool_options + } +} + +fn add_on_return_to_pool(config: &AppConfig, pool_options: PoolOptions) -> PoolOptions { + let on_disconnect_file = config.configuration_directory.join(ON_RESET_FILE); + let sql = if on_disconnect_file.exists() { + log::info!( + "Creating a custom SQL connection cleanup handler from {}", + on_disconnect_file.display() + ); + match std::fs::read_to_string(&on_disconnect_file) { + Ok(sql) => { + log::trace!("The custom SQL connection cleanup handler is:\n{sql}"); + Some(std::sync::Arc::new(sql)) + } + Err(e) => { + log::error!( + "Unable to read the file {}: {}", + on_disconnect_file.display(), + e + ); + None + } + } + } else { + log::debug!( + "Not creating a custom SQL connection cleanup handler because {} does not exist", + on_disconnect_file.display() + ); + None + }; + + pool_options.after_release(move |conn, meta| { + let sql = sql.clone(); + Box::pin(async move { + if let Some(sql) = sql { + on_return_to_pool(conn, meta, sql).await + } else { + Ok(true) + } + }) + }) +} + +fn on_return_to_pool( + conn: &mut sqlx::AnyConnection, + meta: sqlx::pool::PoolConnectionMetadata, + sql: std::sync::Arc, +) -> BoxFuture<'_, Result> { + use sqlx::Row; + Box::pin(async move { + log::trace!("Running the custom SQL connection cleanup handler. {meta:?}"); + let query_result = conn.fetch_optional(sql.as_str()).await?; + if let Some(query_result) = query_result { + let is_healthy = query_result.try_get::(0); + log::debug!("Is the connection healthy? {is_healthy:?}"); + is_healthy + } else { + log::debug!("No result from the custom SQL connection cleanup handler"); + Ok(true) + } + }) +} + +fn add_on_connection_handler( + config: &AppConfig, + pool_options: PoolOptions, +) -> PoolOptions { + let on_connect_file = config.configuration_directory.join(ON_CONNECT_FILE); + let on_connect_file_display = on_connect_file.display().to_string(); + let sql = if on_connect_file.exists() { + log::info!( + "Creating a custom SQL database connection handler from {}", + on_connect_file.display() + ); + match std::fs::read_to_string(&on_connect_file) { + Ok(sql) => { + log::trace!("The custom SQL database connection handler is:\n{sql}"); + Some(std::sync::Arc::new(sql)) + } + Err(e) => { + log::error!( + "Unable to read the file {}: {}", + on_connect_file.display(), + e + ); + None + } + } + } else { + log::debug!( + "Not creating a custom SQL database connection handler because {} does not exist", + on_connect_file.display() + ); + None + }; + + pool_options.after_connect(move |conn, _| { + let sql = sql.clone(); + let on_connect_file_display = on_connect_file_display.clone(); + Box::pin(async move { + if let Some(sql) = sql { + log::debug!("Running {on_connect_file_display} on new connection"); + let r = conn.execute(sql.as_str()).await?; + log::debug!("Finished running connection handler on new connection: {r:?}"); + } + Ok(()) + }) + }) +} + +fn set_custom_connect_options(options: &mut AnyConnectOptions, config: &AppConfig) { + if let Some(sqlite_options) = options.as_sqlite_mut() { + set_custom_connect_options_sqlite(sqlite_options, config); + } + + if let Some(odbc_options) = options.as_odbc_mut() { + set_custom_connect_options_odbc(odbc_options, config); + } +} + +fn set_custom_connect_options_sqlite( + sqlite_options: &mut SqliteConnectOptions, + config: &AppConfig, +) { + for extension_name in &config.sqlite_extensions { + log::info!("Loading SQLite extension: {extension_name}"); + *sqlite_options = std::mem::take(sqlite_options).extension(extension_name.clone()); + } + *sqlite_options = std::mem::take(sqlite_options) + .collation("NOCASE", |a, b| a.to_lowercase().cmp(&b.to_lowercase())) + .function(make_sqlite_fun("upper", str::to_uppercase)) + .function(make_sqlite_fun("lower", str::to_lowercase)); +} + +fn make_sqlite_fun(name: &str, f: fn(&str) -> String) -> Function { + Function::new(name, move |ctx: &SqliteFunctionCtx| { + let arg = ctx.try_get_arg::>(0); + match arg { + Ok(Some(s)) => ctx.set_result(f(s)), + Ok(None) => ctx.set_result(None::), + Err(e) => ctx.set_error(&e.to_string()), + } + }) +} + +fn set_custom_connect_options_odbc(odbc_options: &mut OdbcConnectOptions, config: &AppConfig) { + // Allow fetching very large text fields when using ODBC by removing the max column size limit + let batch_size = config.max_pending_rows.clamp(1, 1024); + odbc_options.batch_size(batch_size); + log::trace!("ODBC batch size set to {batch_size}"); + // Disables ODBC batching, but avoids truncation of large text fields + odbc_options.max_column_size(None); +} + +fn set_database_password(options: &mut AnyConnectOptions, password: &str) { + if let Some(opts) = options.as_postgres_mut() { + *opts = take(opts).password(password); + } else if let Some(opts) = options.as_mysql_mut() { + *opts = take(opts).password(password); + } else if let Some(opts) = options.as_mssql_mut() { + *opts = take(opts).password(password); + } else if let Some(_opts) = options.as_odbc_mut() { + log::warn!( + "Setting a password for an ODBC connection is not supported via separate config; include credentials in the DSN or connection string" + ); + } else if let Some(_opts) = options.as_sqlite_mut() { + log::warn!("Setting a password for a SQLite database is not supported"); + } else { + unreachable!("Unsupported database type"); + } +} diff --git a/src/webserver/database/csv_import.rs b/src/webserver/database/csv_import.rs new file mode 100644 index 0000000..94ac13d --- /dev/null +++ b/src/webserver/database/csv_import.rs @@ -0,0 +1,387 @@ +use std::collections::HashMap; + +use anyhow::Context; +use futures_util::StreamExt; +use sqlparser::ast::{ + CopyLegacyCsvOption, CopyLegacyOption, CopyOption, CopySource, CopyTarget, Statement, +}; +use sqlx::{ + AnyConnection, Arguments, Executor, PgConnection, + any::{AnyArguments, AnyConnectionKind, AnyKind}, +}; +use tokio::io::AsyncRead; + +use crate::webserver::http_request_info::RequestInfo; + +use super::make_placeholder; + +#[derive(Debug, PartialEq)] +pub(super) struct CsvImport { + /// Used only in postgres + pub query: String, + pub table_name: String, + pub columns: Vec, + pub delimiter: Option, + pub quote: Option, + // If true, the first line of the CSV file will be interpreted as a header + // If false, then the column order will be determined by the order of the columns in the table + pub header: Option, + // A string that will be interpreted as null + pub null_str: Option, + pub escape: Option, + /// Reference the the uploaded file name + pub uploaded_file: String, +} + +enum CopyCsvOption<'a> { + Legacy(&'a sqlparser::ast::CopyLegacyOption), + CopyLegacyCsvOption(&'a sqlparser::ast::CopyLegacyCsvOption), + New(&'a sqlparser::ast::CopyOption), +} + +impl CopyCsvOption<'_> { + fn delimiter(&self) -> Option { + match self { + CopyCsvOption::Legacy(CopyLegacyOption::Delimiter(c)) + | CopyCsvOption::New(CopyOption::Delimiter(c)) => Some(*c), + _ => None, + } + } + + fn quote(&self) -> Option { + match self { + CopyCsvOption::CopyLegacyCsvOption(CopyLegacyCsvOption::Quote(c)) + | CopyCsvOption::New(CopyOption::Quote(c)) => Some(*c), + _ => None, + } + } + + fn header(&self) -> Option { + match self { + CopyCsvOption::CopyLegacyCsvOption(CopyLegacyCsvOption::Header) => Some(true), + CopyCsvOption::New(CopyOption::Header(b)) => Some(*b), + _ => None, + } + } + + fn null(&self) -> Option { + match self { + CopyCsvOption::New(CopyOption::Null(s)) => Some(s.clone()), + _ => None, + } + } + + fn escape(&self) -> Option { + match self { + CopyCsvOption::New(CopyOption::Escape(c)) + | CopyCsvOption::CopyLegacyCsvOption(CopyLegacyCsvOption::Escape(c)) => Some(*c), + _ => None, + } + } +} + +pub(super) fn extract_csv_copy_statement(stmt: &mut Statement) -> Option { + if let Statement::Copy { + source: CopySource::Table { + table_name, + columns, + }, + to: false, + target: source, + options, + legacy_options, + values, + } = stmt + { + if !values.is_empty() { + log::warn!("COPY ... VALUES not compatible with SQLPage: {stmt}"); + return None; + } + let uploaded_file = match std::mem::replace(source, CopyTarget::Stdin) { + CopyTarget::File { filename } => filename, + other => { + log::warn!("COPY from {other} not compatible with SQLPage: {stmt}"); + return None; + } + }; + + let all_options: Vec = legacy_options + .iter() + .flat_map(|o| match o { + CopyLegacyOption::Csv(o) => { + o.iter().map(CopyCsvOption::CopyLegacyCsvOption).collect() + } + o => vec![CopyCsvOption::Legacy(o)], + }) + .chain(options.iter().map(CopyCsvOption::New)) + .collect(); + + let table_name = table_name.to_string(); + let columns = columns.iter().map(|ident| ident.value.clone()).collect(); + let delimiter = all_options.iter().find_map(CopyCsvOption::delimiter); + let quote = all_options.iter().find_map(CopyCsvOption::quote); + let header = all_options.iter().find_map(CopyCsvOption::header); + let null = all_options.iter().find_map(CopyCsvOption::null); + let escape = all_options.iter().find_map(CopyCsvOption::escape); + let query = stmt.to_string(); + + Some(CsvImport { + query, + table_name, + columns, + delimiter, + quote, + header, + null_str: null, + escape, + uploaded_file, + }) + } else { + None + } +} + +pub(super) async fn run_csv_import( + db: &mut AnyConnection, + csv_import: &CsvImport, + request: &RequestInfo, +) -> anyhow::Result<()> { + let named_temp_file = &request + .uploaded_files + .get(&csv_import.uploaded_file) + .ok_or_else(|| { + anyhow::anyhow!( + "The request does not contain a field named {:?} with an uploaded file.\n\ + Please check that :\n\ + - you have selected a file to upload, \n\ + - the form field name is correct.", + csv_import.uploaded_file + ) + })? + .file; + let file_path = named_temp_file.path(); + let file = tokio::fs::File::open(file_path).await.with_context(|| { + format!( + "The CSV file {} was uploaded correctly, but could not be opened", + file_path.display() + ) + })?; + let buffered = tokio::io::BufReader::new(file); + // private_get_mut is not supposed to be used outside of sqlx, but it is the only way to + // access the underlying connection + match db.private_get_mut() { + AnyConnectionKind::Postgres(pg_connection) => { + run_csv_import_postgres(pg_connection, csv_import, buffered).await + } + _ => run_csv_import_insert(db, csv_import, buffered).await, + } + .with_context(|| { + let table_name = &csv_import.table_name; + format!( + "{} was uploaded correctly, but its records could not be imported into the table {}", + file_path.display(), + table_name + ) + }) +} + +/// This function does not parse the CSV file, it only sends it to postgres. +/// This is the fastest way to import a CSV file into postgres +async fn run_csv_import_postgres( + db: &mut PgConnection, + csv_import: &CsvImport, + file: impl AsyncRead + Unpin + Send, +) -> anyhow::Result<()> { + log::debug!("Running CSV import with postgres"); + let mut copy_transact = db + .copy_in_raw(csv_import.query.as_str()) + .await + .with_context(|| "The postgres COPY FROM STDIN command failed.")?; + log::debug!("Copy transaction created"); + match copy_transact.read_from(file).await { + Ok(_) => { + log::debug!("Copy transaction finished successfully"); + copy_transact.finish().await?; + Ok(()) + } + Err(e) => { + log::debug!("Copy transaction failed with error: {e}"); + copy_transact + .abort("The COPY FROM STDIN command failed.") + .await?; + Err(e.into()) + } + } +} + +async fn run_csv_import_insert( + db: &mut AnyConnection, + csv_import: &CsvImport, + file: impl AsyncRead + Unpin + Send, +) -> anyhow::Result<()> { + let insert_stmt = create_insert_stmt(db.kind(), csv_import); + log::debug!("CSV data insert statement: {insert_stmt}"); + let mut reader = make_csv_reader(csv_import, file); + let col_idxs = compute_column_indices(&mut reader, csv_import).await?; + let mut records = reader.into_records(); + while let Some(record) = records.next().await { + let r = record.with_context(|| "reading csv record")?; + process_csv_record(r, db, &insert_stmt, csv_import, &col_idxs).await?; + } + Ok(()) +} + +async fn compute_column_indices( + reader: &mut csv_async::AsyncReader, + csv_import: &CsvImport, +) -> anyhow::Result> { + let mut col_idxs = Vec::with_capacity(csv_import.columns.len()); + if csv_import.header.unwrap_or(true) { + let headers = reader + .headers() + .await? + .iter() + .enumerate() + .map(|(i, h)| (h, i)) + .collect::>(); + for column in &csv_import.columns { + let &idx = headers + .get(column.as_str()) + .ok_or_else(|| anyhow::anyhow!("CSV Column not found: {column}"))?; + col_idxs.push(idx); + } + } else { + col_idxs.extend(0..csv_import.columns.len()); + } + Ok(col_idxs) +} + +fn create_insert_stmt(db_kind: AnyKind, csv_import: &CsvImport) -> String { + let columns = csv_import.columns.join(", "); + let placeholders = csv_import + .columns + .iter() + .enumerate() + .map(|(i, _)| make_placeholder(db_kind, i + 1)) + .fold(String::new(), |mut acc, f| { + if !acc.is_empty() { + acc.push_str(", "); + } + acc.push_str(&f); + acc + }); + let table_name = &csv_import.table_name; + format!("INSERT INTO {table_name} ({columns}) VALUES ({placeholders})") +} + +async fn process_csv_record( + record: csv_async::StringRecord, + db: &mut AnyConnection, + insert_stmt: &str, + csv_import: &CsvImport, + column_indices: &[usize], +) -> anyhow::Result<()> { + let mut arguments = AnyArguments::default(); + let null_str = csv_import.null_str.as_deref().unwrap_or_default(); + for (&i, column) in column_indices.iter().zip(csv_import.columns.iter()) { + let value = record.get(i).unwrap_or_default(); + let value = if value == null_str { None } else { Some(value) }; + log::trace!("CSV value: {column}={value:?}"); + arguments.add(value); + } + db.execute((insert_stmt, Some(arguments))).await?; + Ok(()) +} + +fn make_csv_reader( + csv_import: &CsvImport, + file: R, +) -> csv_async::AsyncReader { + let delimiter = csv_import + .delimiter + .and_then(|c| u8::try_from(c).ok()) + .unwrap_or(b','); + let quote = csv_import + .quote + .and_then(|c| u8::try_from(c).ok()) + .unwrap_or(b'"'); + let has_headers = csv_import.header.unwrap_or(true); + let escape = csv_import.escape.and_then(|c| u8::try_from(c).ok()); + csv_async::AsyncReaderBuilder::new() + .delimiter(delimiter) + .quote(quote) + .has_headers(has_headers) + .escape(escape) + .create_reader(file) +} + +#[test] +fn test_make_statement() { + let csv_import = CsvImport { + query: "COPY my_table (col1, col2) FROM 'my_file.csv' WITH (DELIMITER ';', HEADER)".into(), + table_name: "my_table".into(), + columns: vec!["col1".into(), "col2".into()], + delimiter: Some(';'), + quote: None, + header: Some(true), + null_str: None, + escape: None, + uploaded_file: "my_file.csv".into(), + }; + let insert_stmt = create_insert_stmt(AnyKind::Postgres, &csv_import); + assert_eq!( + insert_stmt, + "INSERT INTO my_table (col1, col2) VALUES ($1, $2)" + ); +} + +#[actix_web::test] +async fn test_end_to_end() { + use sqlx::ConnectOptions; + + let mut copy_stmt = sqlparser::parser::Parser::parse_sql( + &sqlparser::dialect::GenericDialect {}, + "COPY my_table (col1, col2) FROM 'my_file.csv' (DELIMITER ';', HEADER)", + ) + .unwrap() + .into_iter() + .next() + .unwrap(); + let csv_import = extract_csv_copy_statement(&mut copy_stmt).unwrap(); + assert_eq!( + csv_import, + CsvImport { + query: "COPY my_table (col1, col2) FROM STDIN (DELIMITER ';', HEADER)".into(), + table_name: "my_table".into(), + columns: vec!["col1".into(), "col2".into()], + delimiter: Some(';'), + quote: None, + header: Some(true), + null_str: None, + escape: None, + uploaded_file: "my_file.csv".into(), + } + ); + let mut conn = "sqlite::memory:" + .parse::() + .unwrap() + .connect() + .await + .unwrap(); + conn.execute("CREATE TABLE my_table (col1 TEXT, col2 TEXT)") + .await + .unwrap(); + let csv = "col2;col1\na;b\nc;d"; // order is different from the table + let file = csv.as_bytes(); + run_csv_import_insert(&mut conn, &csv_import, file) + .await + .unwrap(); + let rows: Vec<(String, String)> = sqlx::query_as("SELECT * FROM my_table") + .fetch_all(&mut conn) + .await + .unwrap(); + assert_eq!( + rows, + vec![("b".into(), "a".into()), ("d".into(), "c".into())] + ); +} diff --git a/src/webserver/database/error_highlighting.rs b/src/webserver/database/error_highlighting.rs new file mode 100644 index 0000000..c2b7595 --- /dev/null +++ b/src/webserver/database/error_highlighting.rs @@ -0,0 +1,192 @@ +use std::{ + fmt::Write, + path::{Path, PathBuf}, +}; + +use super::sql::{SourceSpan, StmtWithParams}; + +#[derive(Debug)] +struct NiceDatabaseError { + /// The source file that contains the query. + source_file: PathBuf, + /// The error that occurred. + db_err: sqlx::error::Error, + /// The query that was executed. + query: String, + /// The start location of the query in the source file, if the query was extracted from a larger file. + query_position: Option, +} + +fn write_source_position_info( + f: &mut std::fmt::Formatter<'_>, + source_file: &Path, + query_position: Option, +) -> Result<(), std::fmt::Error> { + write!(f, "\n{}", source_file.display())?; + if let Some(query_position) = query_position { + let start_line = query_position.start.line; + let end_line = query_position.end.line; + if start_line == end_line { + write!(f, ": line {start_line}")?; + } else { + write!(f, ": lines {start_line} to {end_line}")?; + } + } + Ok(()) +} + +impl std::fmt::Display for NiceDatabaseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "In \"{}\": The following error occurred while executing an SQL statement:\n{}\n\nThe SQL statement sent by SQLPage was:\n", + self.source_file.display(), + self.db_err + )?; + if let sqlx::error::Error::Database(db_err) = &self.db_err { + let Some(mut offset) = db_err.offset() else { + write!(f, "{}", self.query)?; + self.show_position_info(f)?; + return Ok(()); + }; + for line in self.query.lines() { + if offset > line.len() { + offset -= line.len() + 1; + } else { + highlight_line_offset(f, line, offset); + self.show_position_info(f)?; + break; + } + } + Ok(()) + } else { + write!(f, "{}", self.query)?; + self.show_position_info(f)?; + Ok(()) + } + } +} + +impl NiceDatabaseError { + fn show_position_info(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { + write_source_position_info(f, &self.source_file, self.query_position) + } +} + +impl std::error::Error for NiceDatabaseError {} + +#[derive(Debug)] +struct NicePositionedError { + source_file: PathBuf, + query_position: SourceSpan, + error: anyhow::Error, +} + +impl std::fmt::Display for NicePositionedError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "In \"{}\": {}", self.source_file.display(), self.error)?; + write_source_position_info(f, &self.source_file, Some(self.query_position)) + } +} + +impl std::error::Error for NicePositionedError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(self.error.as_ref()) + } +} + +/// Display a database error without any position information +#[must_use] +pub fn display_db_error( + source_file: &Path, + query: &str, + db_err: sqlx::error::Error, +) -> anyhow::Error { + anyhow::Error::new(NiceDatabaseError { + source_file: source_file.to_path_buf(), + db_err, + query: query.to_string(), + query_position: None, + }) +} + +/// Display a database error with a highlighted line and character offset. +#[must_use] +pub fn display_stmt_db_error( + source_file: &Path, + stmt: &StmtWithParams, + db_err: sqlx::error::Error, +) -> anyhow::Error { + anyhow::Error::new(NiceDatabaseError { + source_file: source_file.to_path_buf(), + db_err, + query: stmt.query.clone(), + query_position: Some(stmt.query_position), + }) +} + +#[must_use] +pub fn display_stmt_error( + source_file: &Path, + query_position: SourceSpan, + error: anyhow::Error, +) -> anyhow::Error { + anyhow::Error::new(NicePositionedError { + source_file: source_file.to_path_buf(), + query_position, + error, + }) +} + +/// Highlight a line with a character offset. +pub fn highlight_line_offset(msg: &mut W, line: &str, offset: usize) { + writeln!(msg, "{line}").unwrap(); + writeln!(msg, "{}⬆️", " ".repeat(offset)).unwrap(); +} + +/// Highlight an error given a line and a character offset +/// line and `col_num` are 1-based +pub fn quote_source_with_highlight(source: &str, line_num: u64, col_num: u64) -> String { + let mut msg = String::new(); + let col_num_usize = usize::try_from(col_num) + .unwrap_or_default() + .saturating_sub(1); + for (current_line_num, line) in (1_u64..).zip(source.lines()) { + if current_line_num + 1 == line_num || current_line_num == line_num + 1 { + writeln!(msg, "{line}").unwrap(); + } else if current_line_num == line_num { + highlight_line_offset(&mut msg, line, col_num_usize); + } else if current_line_num > line_num + 1 { + break; + } + } + msg +} + +#[test] +fn test_display_stmt_error_includes_file_and_line() { + let err = display_stmt_error( + Path::new("example.sql"), + SourceSpan { + start: super::sql::SourceLocation { + line: 12, + column: 3, + }, + end: super::sql::SourceLocation { + line: 12, + column: 17, + }, + }, + anyhow::anyhow!("boom"), + ); + let message = err.to_string(); + assert!(message.contains("In \"example.sql\": boom")); + assert!(message.contains("example.sql: line 12")); +} + +#[test] +fn test_quote_source_with_highlight() { + let source = "SELECT *\nFROM table\nWHERE "; + let expected = "FROM table\nWHERE \n ⬆️\n"; + assert_eq!(quote_source_with_highlight(source, 3, 6), expected); +} diff --git a/src/webserver/database/execute_queries.rs b/src/webserver/database/execute_queries.rs new file mode 100644 index 0000000..c06d60d --- /dev/null +++ b/src/webserver/database/execute_queries.rs @@ -0,0 +1,961 @@ +use anyhow::{Context, anyhow}; +use futures_util::StreamExt; +use futures_util::stream::Stream; +use serde_json::Value; +use std::borrow::Cow; +use std::path::Path; +use std::pin::Pin; +use tracing::Instrument; + +use super::csv_import::run_csv_import; +use super::error_highlighting::{display_stmt_db_error, display_stmt_error}; +use super::sql::{ + DelayedFunctionCall, ParsedSqlFile, ParsedStatement, SimpleSelectValue, StmtWithParams, +}; +use crate::dynamic_component::parse_dynamic_rows; +use crate::utils::add_value_to_map; +use crate::webserver::ErrorWithStatus; +use crate::webserver::database::sql_to_json::row_to_string; +use crate::webserver::http_request_info::ExecutionContext; +use crate::webserver::request_variables::SetVariablesMap; +use crate::webserver::single_or_vec::SingleOrVec; + +use super::syntax_tree::{StmtParam, extract_req_param}; +use super::{Database, DbItem, error_highlighting::display_db_error}; +use sqlx::any::{AnyArguments, AnyQueryResult, AnyRow, AnyStatement, AnyTypeInfo}; +use sqlx::pool::PoolConnection; +use sqlx::{ + Any, AnyConnection, Arguments, Column, Either, Executor, Row as _, Statement, ValueRef, +}; + +pub type DbConn = Option>; + +fn source_line_number(line: usize) -> i64 { + i64::try_from(line).unwrap_or(i64::MAX) +} + +use crate::telemetry_metrics::TelemetryMetrics; +use opentelemetry_semantic_conventions::attribute as otel; + +fn record_query_params(span: &tracing::Span, params: &[Option]) { + use tracing_opentelemetry::OpenTelemetrySpanExt; + for (idx, value) in params.iter().enumerate() { + let key = opentelemetry::Key::new(format!("{}.{idx}", otel::DB_QUERY_PARAMETER)); + let otel_value = match value { + Some(v) => opentelemetry::Value::String(v.clone().into()), + None => opentelemetry::Value::String("NULL".into()), + }; + span.set_attribute(key, otel_value); + } +} + +struct DbQueryMetricsContext<'a> { + span: tracing::Span, + duration: std::time::Duration, + db_system_name: &'static str, + operation_name: String, + metrics: &'a TelemetryMetrics, +} + +impl<'a> DbQueryMetricsContext<'a> { + fn new( + span: tracing::Span, + operation_name: String, + db_system_name: &'static str, + metrics: &'a TelemetryMetrics, + ) -> Self { + Self { + span, + duration: std::time::Duration::ZERO, + db_system_name, + operation_name, + metrics, + } + } + + fn add_duration(&mut self, duration: std::time::Duration) { + self.duration += duration; + } + + fn record_success(&self, returned_rows: i64) { + self.span + .record(otel::DB_RESPONSE_RETURNED_ROWS, returned_rows); + self.span.record(otel::OTEL_STATUS_CODE, "OK"); + let attributes = [ + opentelemetry::KeyValue::new(otel::DB_SYSTEM_NAME, self.db_system_name), + opentelemetry::KeyValue::new(otel::DB_OPERATION_NAME, self.operation_name.clone()), + opentelemetry::KeyValue::new(otel::OTEL_STATUS_CODE, "OK"), + ]; + self.metrics + .db_query_duration + .record(self.duration.as_secs_f64(), &attributes); + } + + fn record_error(&self, returned_rows: i64, error: &anyhow::Error) { + self.span + .record(otel::DB_RESPONSE_RETURNED_ROWS, returned_rows); + self.span.record(otel::OTEL_STATUS_CODE, "ERROR"); + self.span + .record(otel::EXCEPTION_MESSAGE, tracing::field::display(error)); + self.span + .record("sqlpage.exception.details", tracing::field::debug(error)); + let attributes = [ + opentelemetry::KeyValue::new(otel::DB_SYSTEM_NAME, self.db_system_name), + opentelemetry::KeyValue::new(otel::DB_OPERATION_NAME, self.operation_name.clone()), + opentelemetry::KeyValue::new(otel::OTEL_STATUS_CODE, "ERROR"), + opentelemetry::KeyValue::new(otel::ERROR_TYPE, error.to_string()), + ]; + self.metrics + .db_query_duration + .record(self.duration.as_secs_f64(), &attributes); + } +} + +fn create_db_query_span( + sql: &str, + source_file: &Path, + line: usize, + db_system_name: &'static str, +) -> (tracing::Span, String) { + let operation_name = sql.split_whitespace().next().unwrap_or("").to_uppercase(); + let span = tracing::info_span!( + "db.query", + "otel.kind" = "client", + "otel.name" = %operation_name, + { otel::DB_QUERY_TEXT } = sql, + { otel::DB_SYSTEM_NAME } = db_system_name, + { otel::DB_OPERATION_NAME } = operation_name, + { otel::CODE_FILE_PATH } = %source_file.display(), + { otel::CODE_LINE_NUMBER } = source_line_number(line), + { otel::OTEL_STATUS_CODE } = tracing::field::Empty, + { otel::EXCEPTION_MESSAGE } = tracing::field::Empty, + "sqlpage.exception.details" = tracing::field::Empty, + { otel::DB_RESPONSE_RETURNED_ROWS } = tracing::field::Empty, + ); + (span, operation_name) +} + +impl Database { + pub(crate) async fn prepare_with( + &self, + query: &str, + param_types: &[AnyTypeInfo], + ) -> anyhow::Result> { + self.connection + .prepare_with(query, param_types) + .await + .map(|s| s.to_owned()) + .map_err(|e| display_db_error(Path::new("autogenerated sqlpage query"), query, e)) + } +} + +pub fn stream_query_results_with_conn<'a>( + sql_file: &'a ParsedSqlFile, + request: &'a ExecutionContext, + db_connection: &'a mut DbConn, +) -> impl Stream + 'a { + let source_file = &sql_file.source_path; + async_stream::try_stream! { + for res in &sql_file.statements { + match res { + ParsedStatement::CsvImport(csv_import) => { + let connection = take_connection(&request.app_state.db, db_connection, request).await?; + log::debug!("Executing CSV import: {csv_import:?}"); + run_csv_import(connection, csv_import, request).await.with_context(|| format!("Failed to import the CSV file {:?} into the table {:?}", csv_import.uploaded_file, csv_import.table_name))?; + }, + ParsedStatement::StmtWithParams(stmt) => { + let query = bind_parameters(stmt, request, db_connection) + .await + .map_err(|e| with_stmt_position(source_file, stmt.query_position, e))?; + request.server_timing.record("bind_params"); + let connection = take_connection(&request.app_state.db, db_connection, request).await?; + log::trace!("Executing query {:?}", query.sql); + let db_system_name = request.app_state.db.info.database_type.otel_name(); + let (query_span, operation_name) = create_db_query_span( + query.sql, + source_file, + stmt.query_position.start.line, + db_system_name, + ); + let mut query_metrics = DbQueryMetricsContext::new( + query_span.clone(), + operation_name, + db_system_name, + &request.app_state.telemetry_metrics, + ); + record_query_params(&query_metrics.span, &query.param_values); + let mut stream = connection.fetch_many(query); + let mut error = None; + let mut returned_rows: i64 = 0; + loop { + let start_next = std::time::Instant::now(); + let next_elem = stream.next().instrument(query_span.clone()).await; + query_metrics.add_duration(start_next.elapsed()); + let Some(elem) = next_elem else { break; }; + + let mut query_result = parse_single_sql_result(source_file, stmt, elem); + if let DbItem::Error(e) = query_result { + error = Some(e); + break; + } + if matches!(query_result, DbItem::Row(_)) { + returned_rows += 1; + } + apply_json_columns(&mut query_result, &stmt.json_columns); + if let Err(err) = apply_delayed_functions(request, &stmt.delayed_functions, &mut query_result) + .instrument(query_span.clone()) + .await + { + error = Some(err); + break; + } + for db_item in parse_dynamic_rows(query_result) { + yield db_item; + } + } + drop(stream); + if let Some(error) = error { + query_metrics.record_error(returned_rows, &error); + try_rollback_transaction(connection).await; + yield DbItem::Error(error); + } else { + query_metrics.record_success(returned_rows); + } + }, + ParsedStatement::SetVariable { variable, value} => { + execute_set_variable_query(db_connection, request, variable, value, source_file).await + .with_context(|| + format!("Failed to set the {variable} variable to {value:?}") + )?; + }, + ParsedStatement::StaticSimpleSet { variable, value} => { + execute_set_simple_static(db_connection, request, variable, value, source_file).await + .with_context(|| + format!("Failed to set the {variable} variable to {value:?}") + )?; + }, + ParsedStatement::StaticSimpleSelect { values, query_position } => { + let row = exec_static_simple_select(values, request, db_connection) + .await + .map_err(|e| with_stmt_position(source_file, *query_position, e))?; + for i in parse_dynamic_rows(DbItem::Row(row)) { + yield i; + } + } + ParsedStatement::Error(e) => yield DbItem::Error(clone_anyhow_err(source_file, e)), + } + } + } + .map(|res| res.unwrap_or_else(DbItem::Error)) +} + +fn with_stmt_position( + source_file: &Path, + query_position: super::sql::SourceSpan, + error: anyhow::Error, +) -> anyhow::Error { + if error.downcast_ref::().is_some() { + error + } else { + display_stmt_error(source_file, query_position, error) + } +} + +/// Transforms a stream of database items to stop processing after encountering the first error. +/// The error item itself is still emitted before stopping. +pub fn stop_at_first_error( + results_stream: impl Stream, +) -> impl Stream { + // We need a oneshot channel rather than a simple boolean flag because + // take_while would poll the stream one extra time after the error, + // while take_until stops immediately when the future completes + let (error_tx, error_rx) = tokio::sync::oneshot::channel(); + let mut error_tx = Some(error_tx); + + results_stream + .inspect(move |item| { + if let DbItem::Error(err) = item { + log::error!("{err:?}"); + if let Some(tx) = error_tx.take() { + let _ = tx.send(()); + } + } + }) + .take_until(error_rx) +} + +/// Executes the sqlpage pseudo-functions contained in a static simple select +async fn exec_static_simple_select( + columns: &[(String, SimpleSelectValue)], + req: &ExecutionContext, + db_connection: &mut DbConn, +) -> anyhow::Result { + let mut map = serde_json::Map::with_capacity(columns.len()); + for (name, value) in columns { + let value = match value { + SimpleSelectValue::Static(s) => s.clone(), + SimpleSelectValue::Dynamic(p) => { + extract_req_param_as_json(p, req, db_connection).await? + } + }; + map = add_value_to_map(map, (name.clone(), value)); + } + Ok(serde_json::Value::Object(map)) +} + +async fn try_rollback_transaction(db_connection: &mut AnyConnection) { + log::debug!("Attempting to rollback transaction"); + match db_connection.execute("ROLLBACK").await { + Ok(_) => log::debug!("Rolled back transaction"), + Err(e) => { + log::debug!("There was probably no transaction in progress when this happened: {e:?}"); + } + } +} + +/// Extracts the value of a parameter from the request. +/// Returns `Ok(None)` when NULL should be used as the parameter value. +async fn extract_req_param_as_json( + param: &StmtParam, + request: &ExecutionContext, + db_connection: &mut DbConn, +) -> anyhow::Result { + if let Some(val) = extract_req_param(param, request, db_connection).await? { + Ok(serde_json::Value::String(val.into_owned())) + } else { + Ok(serde_json::Value::Null) + } +} + +/// This function is used to create a pinned boxed stream of query results. +/// This allows recursive calls. +pub fn stream_query_results_boxed<'a>( + sql_file: &'a ParsedSqlFile, + request: &'a ExecutionContext, + db_connection: &'a mut DbConn, +) -> Pin + 'a>> { + Box::pin(stream_query_results_with_conn( + sql_file, + request, + db_connection, + )) +} + +async fn execute_set_variable_query<'a>( + db_connection: &'a mut DbConn, + request: &'a ExecutionContext, + variable: &StmtParam, + statement: &StmtWithParams, + source_file: &Path, +) -> anyhow::Result<()> { + let query = bind_parameters(statement, request, db_connection).await?; + let connection = take_connection(&request.app_state.db, db_connection, request).await?; + log::debug!( + "Executing query to set the {variable:?} variable: {:?}", + query.sql + ); + + let db_system_name = request.app_state.db.info.database_type.otel_name(); + let (query_span, operation_name) = create_db_query_span( + query.sql, + source_file, + statement.query_position.start.line, + db_system_name, + ); + let mut query_metrics = DbQueryMetricsContext::new( + query_span.clone(), + operation_name, + db_system_name, + &request.app_state.telemetry_metrics, + ); + record_query_params(&query_metrics.span, &query.param_values); + let start_time = std::time::Instant::now(); + let value = match connection + .fetch_optional(query) + .instrument(query_span.clone()) + .await + { + Ok(Some(row)) => { + query_metrics.add_duration(start_time.elapsed()); + query_metrics.record_success(1_i64); + row_to_string(&row) + } + Ok(None) => { + query_metrics.add_duration(start_time.elapsed()); + query_metrics.record_success(0_i64); + None + } + Err(e) => { + query_metrics.add_duration(start_time.elapsed()); + try_rollback_transaction(connection).await; + let err = display_stmt_db_error(source_file, statement, e); + query_metrics.record_error(0_i64, &err); + return Err(err); + } + }; + + let (mut vars, name) = vars_and_name(request, variable)?; + + log::debug!("Setting variable {name} to {value:?}"); + vars.insert(name.to_owned(), value.map(SingleOrVec::Single)); + + Ok(()) +} + +async fn execute_set_simple_static<'a>( + db_connection: &'a mut DbConn, + request: &'a ExecutionContext, + variable: &StmtParam, + value: &SimpleSelectValue, + _source_file: &Path, +) -> anyhow::Result<()> { + let value_str = match value { + SimpleSelectValue::Static(json_value) => match json_value { + serde_json::Value::Null => None, + serde_json::Value::String(s) => Some(s.clone()), + other => Some(other.to_string()), + }, + SimpleSelectValue::Dynamic(stmt_param) => { + extract_req_param(stmt_param, request, db_connection) + .await? + .map(std::borrow::Cow::into_owned) + } + }; + + let (mut vars, name) = vars_and_name(request, variable)?; + + log::debug!("Setting variable {name} to static value {value_str:?}"); + vars.insert(name.to_owned(), value_str.map(SingleOrVec::Single)); + Ok(()) +} + +fn vars_and_name<'a, 'b>( + request: &'a ExecutionContext, + variable: &'b StmtParam, +) -> anyhow::Result<(std::cell::RefMut<'a, SetVariablesMap>, &'b str)> { + match variable { + StmtParam::PostOrGet(name) | StmtParam::Get(name) => { + if request.post_variables.contains_key(name) { + log::warn!( + "Deprecation warning! Setting the value of ${name}, but there is already a form field named :{name}. This will stop working soon. Please rename the variable, or use :{name} directly if you intended to overwrite the posted form field value." + ); + } + Ok((request.set_variables.borrow_mut(), name)) + } + StmtParam::Post(name) => Ok((request.set_variables.borrow_mut(), name)), + _ => Err(anyhow!( + "Only GET and POST variables can be set, not {variable:?}" + )), + } +} + +async fn take_connection<'a>( + db: &'a Database, + conn: &'a mut DbConn, + request: &ExecutionContext, +) -> anyhow::Result<&'a mut PoolConnection> { + if let Some(c) = conn { + return Ok(c); + } + let pool_size = db.connection.size(); + let acquire_span = tracing::info_span!( + "db.pool.acquire", + { otel::DB_SYSTEM_NAME } = db.info.database_type.otel_name(), + { otel::DB_CLIENT_CONNECTION_POOL_NAME } = "sqlpage", + sqlpage.db.pool.size = pool_size, + ); + match db.connection.acquire().instrument(acquire_span).await { + Ok(c) => { + log::debug!("Acquired a database connection"); + request.server_timing.record("db_conn"); + *conn = Some(c); + let connection = conn.as_mut().unwrap(); + set_trace_context(connection, db).await; + Ok(connection) + } + Err(e) => { + let db_name = db.connection.any_kind(); + let active_count = db.connection.size(); + let err_msg = format!( + "Unable to connect to {db_name:?}. The connection pool currently has {active_count} active connections." + ); + Err(anyhow::Error::new(e).context(err_msg)) + } + } +} + +/// Sets the current `OTel` trace context on the database connection so it is visible +/// in `pg_stat_activity.application_name` (`PostgreSQL`) or as a session variable (`MySQL`). +/// This allows correlating `SQLPage` traces with database-side monitoring. +async fn set_trace_context(connection: &mut AnyConnection, db: &Database) { + use opentelemetry::trace::TraceContextExt; + use tracing_opentelemetry::OpenTelemetrySpanExt; + + let span = tracing::Span::current(); + let context = span.context(); + let otel_span = context.span(); + let span_context = otel_span.span_context(); + if !span_context.is_valid() { + return; + } + let traceparent = format!( + "00-{}-{}-{:02x}", + span_context.trace_id(), + span_context.span_id(), + span_context.trace_flags() + ); + let sql = match db.info.kind { + sqlx::any::AnyKind::Postgres => { + // postgresqlreceiver expects application_name to be a raw W3C traceparent value. + format!("SET application_name = '{traceparent}'") + } + sqlx::any::AnyKind::MySql => { + format!("SET @traceparent = '{traceparent}'") + } + _ => return, + }; + if let Err(e) = connection.execute(sql.as_str()).await { + log::debug!("Failed to set trace context on connection: {e}"); + } +} + +#[inline] +fn parse_single_sql_result( + source_file: &Path, + stmt: &StmtWithParams, + res: sqlx::Result>, +) -> DbItem { + match res { + Ok(Either::Right(r)) => { + if log::log_enabled!(log::Level::Trace) { + debug_row(&r); + } + DbItem::Row(super::sql_to_json::row_to_json(&r)) + } + Ok(Either::Left(res)) => { + log::debug!("Finished query with result: {res:?}"); + DbItem::FinishedQuery + } + Err(err) => { + let nice_err = display_stmt_db_error(source_file, stmt, err); + DbItem::Error(nice_err) + } + } +} + +fn debug_row(r: &AnyRow) { + use std::fmt::Write; + let columns = r.columns(); + let mut row_str = String::new(); + for (i, col) in columns.iter().enumerate() { + if let Ok(value) = r.try_get_raw(i) { + write!( + &mut row_str, + "[{:?} ({}): {:?}: {:?}]", + col.name(), + if value.is_null() { "NULL" } else { "NOT NULL" }, + col, + value.type_info() + ) + .unwrap(); + } + } + log::trace!("Received db row: {row_str}"); +} + +fn clone_anyhow_err(source_file: &Path, err: &anyhow::Error) -> anyhow::Error { + if let Some(func_err) = err.downcast_ref::() { + let line = func_err.line; + let loc = if line > 0 { + format!(":{line}") + } else { + String::new() + }; + return anyhow::anyhow!("{}{loc} {}", source_file.display(), func_err); + } + + let mut e = anyhow!( + "{} contains a syntax error preventing SQLPage from parsing and preparing its SQL statements.", + source_file.display() + ); + for c in err.chain().rev() { + e = e.context(c.to_string()); + } + e +} + +async fn bind_parameters<'a>( + stmt: &'a StmtWithParams, + request: &'a ExecutionContext, + db_connection: &mut DbConn, +) -> anyhow::Result> { + let sql = stmt.query.as_str(); + log::debug!("Preparing statement: {sql}"); + let mut arguments = AnyArguments::default(); + let mut param_values = Vec::with_capacity(stmt.params.len()); + for (param_idx, param) in stmt.params.iter().enumerate() { + log::trace!("\tevaluating parameter {}: {}", param_idx + 1, param); + let argument = extract_req_param(param, request, db_connection).await?; + log::debug!( + "\tparameter {}: {}", + param_idx + 1, + argument.as_ref().unwrap_or(&Cow::Borrowed("NULL")) + ); + param_values.push(argument.as_deref().map(str::to_owned)); + match argument { + None => arguments.add(None::), + Some(Cow::Owned(s)) => arguments.add(s), + Some(Cow::Borrowed(v)) => arguments.add(v), + } + } + let has_arguments = !stmt.params.is_empty(); + Ok(StatementWithParams { + sql, + arguments, + has_arguments, + param_values, + }) +} + +async fn apply_delayed_functions( + request: &ExecutionContext, + delayed_functions: &[DelayedFunctionCall], + item: &mut DbItem, +) -> anyhow::Result<()> { + // We need to open new connections for each delayed function call, because we are still fetching the results of the current query in the main connection. + let mut db_conn = None; + if let DbItem::Row(serde_json::Value::Object(results)) = item { + for f in delayed_functions { + log::trace!("Applying delayed function {} to {:?}", f.function, results); + apply_single_delayed_function(request, &mut db_conn, f, results).await?; + log::trace!( + "Delayed function applied {}. Result: {:?}", + f.function, + results + ); + } + } + Ok(()) +} + +async fn apply_single_delayed_function( + request: &ExecutionContext, + db_connection: &mut DbConn, + f: &DelayedFunctionCall, + row: &mut serde_json::Map, +) -> anyhow::Result<()> { + let mut params = Vec::new(); + for arg in &f.argument_col_names { + let Some(arg_value) = row.remove(arg) else { + anyhow::bail!( + "The column {arg} is missing in the result set, but it is required by the {} function.", + f.function + ); + }; + params.push(json_to_fn_param(arg_value)); + } + let result_str = f.function.evaluate(request, db_connection, params).await?; + let result_json = result_str + .map(Cow::into_owned) + .map_or(serde_json::Value::Null, serde_json::Value::String); + row.insert(f.target_col_name.clone(), result_json); + Ok(()) +} + +fn json_to_fn_param(json: serde_json::Value) -> Option> { + match json { + serde_json::Value::String(s) => Some(Cow::Owned(s)), + serde_json::Value::Null => None, + _ => Some(Cow::Owned(json.to_string())), + } +} + +fn apply_json_columns(item: &mut DbItem, json_columns: &[String]) { + if let DbItem::Row(Value::Object(row)) = item { + for column in json_columns { + if let Some(value) = row.get_mut(column) { + if let Value::String(json_str) = value { + if let Ok(parsed_json) = serde_json::from_str(json_str) { + log::trace!("Parsed JSON column {column}: {parsed_json}"); + *value = parsed_json; + } else { + log::warn!("The column {column} contains invalid JSON: {json_str}"); + } + } else if let Value::Array(array) = value { + for item in array { + if let Value::String(json_str) = item + && let Ok(parsed_json) = serde_json::from_str(json_str) + { + log::trace!("Parsed JSON array item: {parsed_json}"); + *item = parsed_json; + } + } + } + } else { + log::warn!( + "The column {column} is missing from the result set, so it cannot be converted to JSON." + ); + } + } + } +} + +pub struct StatementWithParams<'a> { + sql: &'a str, + arguments: AnyArguments<'a>, + has_arguments: bool, + param_values: Vec>, +} + +impl<'q> sqlx::Execute<'q, Any> for StatementWithParams<'q> { + fn sql(&self) -> &'q str { + self.sql + } + + fn statement(&self) -> Option<&>::Statement> { + None + } + + fn take_arguments(&mut self) -> Option<>::Arguments> { + if self.has_arguments { + Some(std::mem::take(&mut self.arguments)) + } else { + None + } + } + + fn persistent(&self) -> bool { + // Let sqlx create a prepared statement the first time it is executed, and then reuse it. + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use std::sync::{Arc, Mutex}; + + use serde_json::{Value, json}; + use tracing::field::{Field, Visit}; + use tracing_subscriber::Layer; + use tracing_subscriber::layer::Context; + use tracing_subscriber::prelude::*; + use tracing_subscriber::registry::LookupSpan; + + fn create_row_item(value: Value) -> DbItem { + DbItem::Row(value) + } + + fn assert_json_value(item: &DbItem, key: &str, expected: Value) { + let DbItem::Row(Value::Object(row)) = item else { + panic!("Expected DbItem::Row"); + }; + assert_eq!(row[key], expected); + drop(expected); + } + + #[test] + fn test_basic_json_string_conversion() { + let mut item = create_row_item(json!({ + "json_col": "{\"key\": \"value\"}", + "normal_col": "text" + })); + apply_json_columns(&mut item, &["json_col".to_string()]); + assert_json_value(&item, "json_col", json!({"key": "value"})); + assert_json_value(&item, "normal_col", json!("text")); + } + + #[test] + fn test_json_array_conversion() { + let mut item = create_row_item(json!({ + "array_col": ["{\"a\": 1}", "{\"b\": 2}"], + "normal_array": ["text"] + })); + apply_json_columns(&mut item, &["array_col".to_string()]); + assert_json_value(&item, "array_col", json!([{"a": 1}, {"b": 2}])); + assert_json_value(&item, "normal_array", json!(["text"])); + } + + #[test] + fn test_invalid_json_handling() { + let mut item = create_row_item(json!({ + "invalid_json": "{not valid json}", + "normal_col": "text" + })); + apply_json_columns(&mut item, &["invalid_json".to_string()]); + assert_json_value(&item, "invalid_json", json!("{not valid json}")); + assert_json_value(&item, "normal_col", json!("text")); + } + + #[test] + fn test_missing_column_handling() { + let mut item = create_row_item(json!({ + "existing_col": "text" + })); + apply_json_columns(&mut item, &["missing_col".to_string()]); + assert_json_value(&item, "existing_col", json!("text")); + } + + #[test] + fn test_non_row_dbitem_handling() { + let mut item = DbItem::FinishedQuery; + apply_json_columns(&mut item, &["json_col".to_string()]); + assert!(matches!(item, DbItem::FinishedQuery)); + } + + #[test] + fn test_duplicate_json_column_names() { + let mut item = create_row_item(json!({ + "json_col": "{\"key\": \"value\"}", + "normal_col": "text" + })); + apply_json_columns(&mut item, &["json_col".to_string(), "json_col".to_string()]); + assert_json_value(&item, "json_col", json!({"key": "value"})); + assert_json_value(&item, "normal_col", json!("text")); + } + + #[derive(Default)] + struct RecordedFields(HashMap<&'static str, String>); + + #[derive(Clone, Default)] + struct TestSpanLayer { + closed_spans: Arc>>>, + } + + struct TestFieldVisitor<'a>(&'a mut HashMap<&'static str, String>); + + impl Visit for TestFieldVisitor<'_> { + fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { + self.0.insert(field.name(), format!("{value:?}")); + } + + fn record_str(&mut self, field: &Field, value: &str) { + self.0.insert(field.name(), value.to_owned()); + } + + fn record_i64(&mut self, field: &Field, value: i64) { + self.0.insert(field.name(), value.to_string()); + } + } + + impl Layer for TestSpanLayer + where + S: tracing::Subscriber + for<'a> LookupSpan<'a>, + { + fn on_new_span( + &self, + attrs: &tracing::span::Attributes<'_>, + id: &tracing::span::Id, + ctx: Context<'_, S>, + ) { + let mut fields = RecordedFields::default(); + attrs.record(&mut TestFieldVisitor(&mut fields.0)); + if let Some(span) = ctx.span(id) { + span.extensions_mut().insert(fields); + } + } + + fn on_record( + &self, + id: &tracing::span::Id, + values: &tracing::span::Record<'_>, + ctx: Context<'_, S>, + ) { + if let Some(span) = ctx.span(id) { + let mut extensions = span.extensions_mut(); + let fields = extensions + .get_mut::() + .expect("recorded fields"); + values.record(&mut TestFieldVisitor(&mut fields.0)); + } + } + + fn on_close(&self, id: tracing::span::Id, ctx: Context<'_, S>) { + if let Some(span) = ctx.span(&id) { + let extensions = span.extensions(); + let fields = extensions.get::().expect("recorded fields"); + self.closed_spans.lock().unwrap().push(fields.0.clone()); + } + } + } + + fn with_recorded_span_fields( + f: impl FnOnce() + Send + 'static, + ) -> HashMap<&'static str, String> { + let layer = TestSpanLayer::default(); + let closed_spans = layer.closed_spans.clone(); + let subscriber = tracing_subscriber::registry().with(layer); + tracing::subscriber::with_default(subscriber, f); + closed_spans + .lock() + .unwrap() + .pop() + .expect("closed span fields") + } + + #[test] + fn db_query_span_uses_otel_database_client_semantics() { + let fields = with_recorded_span_fields(|| { + let (span, operation_name) = + create_db_query_span("select * from users", Path::new("index.sql"), 7, "sqlite"); + assert_eq!(operation_name, "SELECT"); + drop(span); + }); + + assert_eq!(fields["otel.kind"], "client"); + assert_eq!(fields["otel.name"], "SELECT"); + assert_eq!(fields[otel::DB_QUERY_TEXT], "select * from users"); + assert_eq!(fields[otel::DB_SYSTEM_NAME], "sqlite"); + assert_eq!(fields[otel::DB_OPERATION_NAME], "SELECT"); + assert_eq!(fields[otel::CODE_FILE_PATH], "index.sql"); + assert_eq!(fields[otel::CODE_LINE_NUMBER], "7"); + } + + #[test] + fn db_query_success_records_ok_status_and_row_count() { + let fields = with_recorded_span_fields(|| { + let span = tracing::info_span!( + "db.query", + otel.status_code = tracing::field::Empty, + exception.message = tracing::field::Empty, + sqlpage.exception.details = tracing::field::Empty, + db.response.returned_rows = tracing::field::Empty, + ); + let metrics = crate::telemetry_metrics::TelemetryMetrics::default(); + let query_metrics = + DbQueryMetricsContext::new(span.clone(), "SELECT".to_string(), "sqlite", &metrics); + query_metrics.record_success(3); + drop(span); + }); + + assert_eq!(fields[otel::OTEL_STATUS_CODE], "OK"); + assert_eq!(fields[otel::DB_RESPONSE_RETURNED_ROWS], "3"); + assert!(!fields.contains_key(otel::EXCEPTION_MESSAGE)); + assert!(!fields.contains_key("sqlpage.exception.details")); + } + + #[test] + fn db_query_error_records_error_status_and_exception_fields() { + let fields = with_recorded_span_fields(|| { + let span = tracing::info_span!( + "db.query", + otel.status_code = tracing::field::Empty, + exception.message = tracing::field::Empty, + sqlpage.exception.details = tracing::field::Empty, + db.response.returned_rows = tracing::field::Empty, + ); + let error = anyhow!("query failed").context("while executing SELECT 1"); + let metrics = crate::telemetry_metrics::TelemetryMetrics::default(); + let query_metrics = + DbQueryMetricsContext::new(span.clone(), "SELECT".to_string(), "sqlite", &metrics); + query_metrics.record_error(2, &error); + drop(span); + }); + + assert_eq!(fields[otel::OTEL_STATUS_CODE], "ERROR"); + assert_eq!(fields[otel::DB_RESPONSE_RETURNED_ROWS], "2"); + assert!(fields[otel::EXCEPTION_MESSAGE].contains("while executing SELECT 1")); + assert!(fields["sqlpage.exception.details"].contains("query failed")); + } +} diff --git a/src/webserver/database/migrations.rs b/src/webserver/database/migrations.rs new file mode 100644 index 0000000..426045b --- /dev/null +++ b/src/webserver/database/migrations.rs @@ -0,0 +1,85 @@ +use super::Database; +use super::error_highlighting::display_db_error; +use crate::MIGRATIONS_DIR; +use anyhow; +use anyhow::Context; +use sqlx::migrate::MigrateError; +use sqlx::migrate::Migration; +use sqlx::migrate::Migrator; + +pub async fn apply(config: &crate::app_config::AppConfig, db: &Database) -> anyhow::Result<()> { + let migrations_dir = config.configuration_directory.join(MIGRATIONS_DIR); + if !migrations_dir.exists() { + log::info!( + "Not applying database migrations because '{}' does not exist", + migrations_dir.display() + ); + return Ok(()); + } + log::debug!("Applying migrations from '{}'", migrations_dir.display()); + let migrator = Migrator::new(migrations_dir.clone()) + .await + .with_context(|| migration_err("preparing the database migration"))?; + if migrator.migrations.is_empty() { + log::debug!( + "No migration found in {}. \ + You can specify database operations to apply when the server first starts by creating files \ + in {MIGRATIONS_DIR}/_.sql \ + where is a number and is a short string.", + migrations_dir.display() + ); + return Ok(()); + } + log::info!("Found {} migrations:", migrator.migrations.len()); + for m in migrator.iter() { + log::info!("\t{}", DisplayMigration(m)); + } + migrator.run(&db.connection).await.map_err(|err| { + match err { + MigrateError::Execute(n, source) => { + let migration = migrator.iter().find(|&m| m.version == n).unwrap(); + let source_file = + migrations_dir.join(format!("{:04}_{}.sql", n, migration.description)); + display_db_error(&source_file, &migration.sql, source).context(format!( + "Failed to apply {} migration {}", + db, + DisplayMigration(migration) + )) + } + source => anyhow::Error::new(source), + } + .context(format!( + "Failed to apply database migrations from {MIGRATIONS_DIR:?}" + )) + })?; + Ok(()) +} + +struct DisplayMigration<'a>(&'a Migration); + +impl std::fmt::Display for DisplayMigration<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Migration { + version, + migration_type, + description, + .. + } = &self.0; + write!(f, "[{version:04}]")?; + if migration_type != &sqlx::migrate::MigrationType::Simple { + write!(f, " ({migration_type:?})")?; + } + write!(f, " {description}")?; + Ok(()) + } +} + +fn migration_err(operation: &'static str) -> String { + format!( + "An error occurred while {operation}. + The path '{MIGRATIONS_DIR}' has to point to a directory, which contains valid SQL files + with names using the format '_.sql', + where is a positive number, and is a string. + The current state of migrations will be stored in a table called _sqlx_migrations." + ) +} diff --git a/src/webserver/database/mod.rs b/src/webserver/database/mod.rs new file mode 100644 index 0000000..b73e0cb --- /dev/null +++ b/src/webserver/database/mod.rs @@ -0,0 +1,142 @@ +pub mod blob_to_data_url; +mod connect; +mod csv_import; +pub mod execute_queries; +pub mod migrations; +mod sql; +mod sqlpage_functions; +mod syntax_tree; + +mod error_highlighting; +mod sql_to_json; + +pub use sql::ParsedSqlFile; +use sql::{DB_PLACEHOLDERS, DbPlaceHolder}; +use sqlx::any::AnyKind; +// SupportedDatabase is defined in this module + +/// Supported database types in `SQLPage`. Represents an actual DBMS, not a sqlx backend kind (like "Odbc") +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SupportedDatabase { + Sqlite, + Duckdb, + Oracle, + Postgres, + MySql, + Mssql, + Snowflake, + Generic, +} + +impl SupportedDatabase { + /// Detect the database type from a connection's `dbms_name` + #[must_use] + pub fn from_dbms_name(dbms_name: &str) -> Self { + match dbms_name.to_lowercase().as_str() { + "sqlite" | "sqlite3" => Self::Sqlite, + "duckdb" | "d\0\0\0\0\0" => Self::Duckdb, // ducksdb incorrectly truncates the db name: https://github.com/duckdb/duckdb-odbc/issues/350 + "oracle" => Self::Oracle, + "postgres" | "postgresql" => Self::Postgres, + "mysql" | "mariadb" => Self::MySql, + "mssql" | "sql server" | "microsoft sql server" => Self::Mssql, + "snowflake" => Self::Snowflake, + _ => Self::Generic, + } + } + + /// Get the display name for the database + #[must_use] + pub fn display_name(self) -> &'static str { + match self { + Self::Sqlite => "SQLite", + Self::Duckdb => "DuckDB", + Self::Oracle => "Oracle", + Self::Postgres => "PostgreSQL", + Self::MySql => "MySQL", + Self::Mssql => "Microsoft SQL Server", + Self::Snowflake => "Snowflake", + Self::Generic => "Generic", + } + } + + /// Returns the `OTel` `db.system.name` well-known value. + /// See + #[must_use] + pub fn otel_name(self) -> &'static str { + Self::otel_name_from_kind(self) + } + + #[must_use] + pub fn otel_name_from_kind(kind: impl Into) -> &'static str { + match kind.into() { + Self::Sqlite => "sqlite", + Self::Duckdb => "duckdb", + Self::Oracle => "oracle.db", + Self::Postgres => "postgresql", + Self::MySql => "mysql", + Self::Mssql => "microsoft.sql_server", + Self::Snowflake => "snowflake", + Self::Generic => "other_sql", + } + } +} + +impl From for SupportedDatabase { + fn from(kind: AnyKind) -> Self { + match kind { + AnyKind::Postgres => Self::Postgres, + AnyKind::MySql => Self::MySql, + AnyKind::Sqlite => Self::Sqlite, + AnyKind::Mssql => Self::Mssql, + AnyKind::Odbc => Self::Generic, + } + } +} + +pub struct Database { + pub connection: sqlx::AnyPool, + pub info: DbInfo, +} + +#[derive(Debug, Clone)] +pub struct DbInfo { + pub dbms_name: String, + /// The actual database we are connected to. Can be "Generic" when using an unknown ODBC driver + pub database_type: SupportedDatabase, + /// The sqlx database backend we are using. Can be "Odbc", in which case we need to use `database_type` to know what database we are actually using. + pub kind: AnyKind, +} + +impl Database { + pub async fn close(&self) -> anyhow::Result<()> { + log::info!("Closing all database connections..."); + self.connection.close().await; + Ok(()) + } +} + +#[derive(Debug)] +pub enum DbItem { + Row(serde_json::Value), + FinishedQuery, + Error(anyhow::Error), +} + +impl std::fmt::Display for Database { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", self.connection.any_kind()) + } +} + +#[inline] +#[must_use] +pub fn make_placeholder(dbms: AnyKind, arg_number: usize) -> String { + if let Some((_, placeholder)) = DB_PLACEHOLDERS.iter().find(|(kind, _)| *kind == dbms) { + match *placeholder { + DbPlaceHolder::PrefixedNumber { prefix } => format!("{prefix}{arg_number}"), + DbPlaceHolder::Positional { placeholder } => placeholder.to_string(), + } + } else { + unreachable!("missing dbms: {dbms:?} in DB_PLACEHOLDERS ({DB_PLACEHOLDERS:?})") + } +} diff --git a/src/webserver/database/sql.rs b/src/webserver/database/sql.rs new file mode 100644 index 0000000..0d4bdfe --- /dev/null +++ b/src/webserver/database/sql.rs @@ -0,0 +1,1373 @@ +use super::SupportedDatabase; +use super::csv_import::{CsvImport, extract_csv_copy_statement}; +use super::sqlpage_functions::functions::SqlPageFunctionName; +use super::syntax_tree::StmtParam; +use crate::file_cache::AsyncFromStrWithState; +use crate::webserver::database::DbInfo; +use crate::webserver::database::error_highlighting::quote_source_with_highlight; +use crate::{AppState, Database}; +use async_trait::async_trait; +use sqlparser::ast::helpers::attached_token::AttachedToken; +use sqlparser::ast::{ + CastKind, DataType, Expr, Function, FunctionArg, FunctionArgExpr, FunctionArgumentList, + FunctionArguments, Ident, ObjectName, ObjectNamePart, SelectFlavor, SelectItem, Set, SetExpr, + Spanned, Statement, Value, ValueWithSpan, +}; +use sqlparser::dialect::{ + Dialect, DuckDbDialect, GenericDialect, MsSqlDialect, MySqlDialect, OracleDialect, + PostgreSqlDialect, SQLiteDialect, SnowflakeDialect, +}; +use sqlparser::parser::{Parser, ParserError}; +use sqlparser::tokenizer::Token::{self, EOF, SemiColon}; +use sqlparser::tokenizer::{Location, Span, TokenWithSpan, Tokenizer}; +use sqlx::any::AnyKind; +use std::fmt::Write; +use std::path::{Path, PathBuf}; +use std::str::FromStr; + +mod parameter_extraction; +pub(super) use self::parameter_extraction::{ + DB_PLACEHOLDERS, DbPlaceHolder, ParamExtractContext, SqlPageFunctionError, + function_args_to_stmt_params, +}; +use self::parameter_extraction::{ + ParameterExtractor, TEMP_PLACEHOLDER_PREFIX, extract_ident_param, validate_function_calls, +}; + +#[derive(Default)] +pub struct ParsedSqlFile { + pub(super) statements: Vec, + pub source_path: PathBuf, +} + +impl ParsedSqlFile { + #[must_use] + pub fn new(db: &Database, sql: &str, source_path: &Path) -> ParsedSqlFile { + let dialect = dialect_for_db(db.info.database_type); + log::debug!( + "Parsing SQL file {} using dialect {:?}", + source_path.display(), + dialect + ); + let parsed_statements = match parse_sql(&db.info, dialect.as_ref(), sql) { + Ok(parsed) => parsed, + Err(err) => return Self::from_err(err, source_path), + }; + let statements = parsed_statements.collect(); + ParsedSqlFile { + statements, + source_path: source_path.to_path_buf(), + } + } + + fn from_err(e: impl Into, source_path: &Path) -> Self { + Self { + statements: vec![ParsedStatement::Error( + e.into() + .context(format!("Error parsing file {}", source_path.display())), + )], + source_path: source_path.to_path_buf(), + } + } +} + +#[async_trait(? Send)] +impl AsyncFromStrWithState for ParsedSqlFile { + async fn from_str_with_state( + app_state: &AppState, + source: &str, + source_path: &Path, + ) -> anyhow::Result { + Ok(ParsedSqlFile::new(&app_state.db, source, source_path)) + } +} + +/// A single SQL statement that has been parsed from a SQL file. +#[derive(Debug, PartialEq)] +pub(super) struct StmtWithParams { + /// The SQL query with placeholders for parameters. + pub query: String, + /// The line and column of the first token in the query. + pub query_position: SourceSpan, + /// Parameters that should be bound to the query. + /// They can contain functions that will be called before the query is executed, + /// the result of which will be bound to the query. + pub params: Vec, + /// Functions that are called on the result set after the query has been executed, + /// and which can be passed the result of the query as an argument. + pub delayed_functions: Vec, + /// Columns that are JSON columns, and which should be converted to JSON objects after the query is executed. + /// Only relevant for databases that do not have a native JSON type, and which return JSON values as text. + pub json_columns: Vec, +} + +/// A location in the source code. +#[derive(Debug, PartialEq, Clone, Copy)] +pub(super) struct SourceSpan { + pub start: SourceLocation, + pub end: SourceLocation, +} + +/// A location in the source code. +#[derive(Debug, PartialEq, Clone, Copy)] +pub(super) struct SourceLocation { + pub line: usize, + pub column: usize, +} + +#[derive(Debug)] +pub(super) enum ParsedStatement { + StmtWithParams(StmtWithParams), + StaticSimpleSelect { + values: Vec<(String, SimpleSelectValue)>, + query_position: SourceSpan, + }, + SetVariable { + variable: StmtParam, + value: StmtWithParams, + }, + StaticSimpleSet { + variable: StmtParam, + value: SimpleSelectValue, + }, + CsvImport(CsvImport), + Error(anyhow::Error), +} + +#[derive(Debug, PartialEq)] +pub(super) enum SimpleSelectValue { + Static(serde_json::Value), + Dynamic(StmtParam), +} + +fn parse_sql<'a>( + db_info: &'a DbInfo, + dialect: &'a dyn Dialect, + sql: &'a str, +) -> anyhow::Result + 'a> { + log::trace!("Parsing {} SQL: {sql}", db_info.dbms_name); + + let tokens = Tokenizer::new(dialect, sql) + .tokenize_with_location() + .map_err(|err| { + let location = err.location; + anyhow::Error::new(err).context(format!("The SQLPage parser could not understand the SQL file. Tokenization failed. Please check for syntax errors:\n{}", quote_source_with_highlight(sql, location.line, location.column))) + })?; + let mut parser = Parser::new(dialect).with_tokens_with_locations(tokens); + let mut has_error = false; + Ok(std::iter::from_fn(move || { + if has_error { + // Return the first error and ignore the rest + return None; + } + let statement = parse_single_statement(&mut parser, db_info, sql); + log::debug!("Parsed statement: {statement:?}"); + if let Some(ParsedStatement::Error(_)) = &statement { + has_error = true; + } + statement + })) +} + +fn transform_to_positional_placeholders(stmt: &mut StmtWithParams, kind: AnyKind) { + if let Some((_, DbPlaceHolder::Positional { placeholder })) = DB_PLACEHOLDERS + .iter() + .find(|(placeholder_kind, _)| *placeholder_kind == kind) + { + let mut new_params = Vec::new(); + let mut query = stmt.query.clone(); + while let Some(pos) = query.find(TEMP_PLACEHOLDER_PREFIX) { + let start_of_number = pos + TEMP_PLACEHOLDER_PREFIX.len(); + let end = query[start_of_number..] + .find(|c: char| !c.is_ascii_digit()) + .map_or(query.len(), |i| start_of_number + i); + let param_idx = query[start_of_number..end].parse::().unwrap_or(1) - 1; + query.replace_range(pos..end, placeholder); + new_params.push(stmt.params[param_idx].clone()); + } + stmt.query = query; + stmt.params = new_params; + } +} + +fn parse_single_statement( + parser: &mut Parser<'_>, + db_info: &DbInfo, + source_sql: &str, +) -> Option { + if parser.peek_token() == EOF { + return None; + } + let mut stmt = match parser.parse_statement() { + Ok(stmt) => stmt, + Err(err) => return Some(syntax_error(err, parser, source_sql)), + }; + let mut semicolon = false; + while parser.consume_token(&SemiColon) { + semicolon = true; + } + + let mut params = match ParameterExtractor::extract_parameters(&mut stmt, db_info.clone()) { + Ok(p) => p, + Err(err) => return Some(ParsedStatement::Error(err)), + }; + let dbms = db_info.database_type; + if let Some(parsed) = extract_set_variable(&mut stmt, &mut params, db_info) { + return Some(parsed); + } + if let Some(csv_import) = extract_csv_copy_statement(&mut stmt) { + return Some(ParsedStatement::CsvImport(csv_import)); + } + if let Some(static_statement) = extract_static_simple_select(&stmt, ¶ms) { + log::debug!( + "Optimised a static simple select to avoid a trivial database query: {stmt} optimized to {static_statement:?}" + ); + return Some(ParsedStatement::StaticSimpleSelect { + values: static_statement, + query_position: extract_query_start(&stmt), + }); + } + + let delayed_functions = extract_toplevel_functions(&mut stmt); + + if let Err(err) = validate_function_calls(&stmt) { + return Some(ParsedStatement::Error(err)); + } + let json_columns = extract_json_columns(&stmt, dbms); + let query = format!( + "{stmt}{semicolon}", + semicolon = if semicolon { ";" } else { "" } + ); + let mut stmt_with_params = StmtWithParams { + query, + query_position: extract_query_start(&stmt), + params, + delayed_functions, + json_columns, + }; + transform_to_positional_placeholders(&mut stmt_with_params, db_info.kind); + log::debug!("Final transformed statement: {}", stmt_with_params.query); + Some(ParsedStatement::StmtWithParams(stmt_with_params)) +} + +fn extract_query_start(stmt: &impl Spanned) -> SourceSpan { + let location = stmt.span(); + SourceSpan { + start: SourceLocation { + line: usize::try_from(location.start.line).unwrap_or(0), + column: usize::try_from(location.start.column).unwrap_or(0), + }, + end: SourceLocation { + line: usize::try_from(location.end.line).unwrap_or(0), + column: usize::try_from(location.end.column).unwrap_or(0), + }, + } +} + +fn syntax_error(err: ParserError, parser: &Parser, sql: &str) -> ParsedStatement { + let Span { + start: Location { + line: start_line, + column: start_column, + }, + end: Location { line: end_line, .. }, + } = parser.peek_token_no_skip().span; + + let mut msg = String::from( + "Parsing failed: SQLPage couldn't understand the SQL file. Please check for syntax errors on ", + ); + if start_line == end_line { + write!(&mut msg, "line {start_line}:").unwrap(); + } else { + write!(&mut msg, "lines {start_line} to {end_line}:").unwrap(); + } + write!( + &mut msg, + "\n{}", + quote_source_with_highlight(sql, start_line, start_column) + ) + .unwrap(); + ParsedStatement::Error(anyhow::Error::from(err).context(msg)) +} + +fn dialect_for_db(dbms: SupportedDatabase) -> Box { + match dbms { + SupportedDatabase::Duckdb => Box::new(DuckDbDialect {}), + SupportedDatabase::Oracle => Box::new(OracleDialect {}), + SupportedDatabase::Postgres => Box::new(PostgreSqlDialect {}), + SupportedDatabase::Generic => Box::new(GenericDialect {}), + SupportedDatabase::Mssql => Box::new(MsSqlDialect {}), + SupportedDatabase::MySql => Box::new(MySqlDialect {}), + SupportedDatabase::Sqlite => Box::new(SQLiteDialect {}), + SupportedDatabase::Snowflake => Box::new(SnowflakeDialect {}), + } +} + +#[derive(Debug, PartialEq)] +pub struct DelayedFunctionCall { + pub function: SqlPageFunctionName, + pub argument_col_names: Vec, + pub target_col_name: String, +} + +/// The execution of top-level functions is delayed until after the query has been executed. +/// For instance, `SELECT sqlpage.fetch(x) FROM t` will be executed as `SELECT x as _sqlpage_f0_a0 FROM t` +/// and the `sqlpage.fetch` function will be called with the value of `_sqlpage_f0_a0` after the query has been executed, +/// on each row of the result set. +fn extract_toplevel_functions(stmt: &mut Statement) -> Vec { + struct SelectItemToAdd { + expr_to_insert: SelectItem, + position: usize, + } + let mut delayed_function_calls: Vec = Vec::new(); + let set_expr = match stmt { + Statement::Query(q) => q.body.as_mut(), + _ => return delayed_function_calls, + }; + let select_items = match set_expr { + sqlparser::ast::SetExpr::Select(s) => &mut s.projection, + _ => return delayed_function_calls, + }; + let mut select_items_to_add: Vec = Vec::new(); + + for (position, select_item) in select_items.iter_mut().enumerate() { + let SelectItem::ExprWithAlias { + expr: + Expr::Function(Function { + name: ObjectName(func_name_parts), + args: + FunctionArguments::List(FunctionArgumentList { + args, + duplicate_treatment: None, + .. + }), + .. + }), + alias, + } = select_item + else { + continue; + }; + let Some(func_name) = extract_sqlpage_function_name(func_name_parts) else { + continue; + }; + func_name_parts.clear(); // mark the function for deletion + let mut argument_col_names = Vec::with_capacity(args.len()); + for (arg_idx, arg) in args.iter_mut().enumerate() { + match arg { + FunctionArg::Unnamed(FunctionArgExpr::Expr(expr)) + | FunctionArg::Named { + arg: FunctionArgExpr::Expr(expr), + .. + } => { + let func_idx = delayed_function_calls.len(); + let argument_col_name = format!("_sqlpage_f{func_idx}_a{arg_idx}"); + argument_col_names.push(argument_col_name.clone()); + let expr_to_insert = SelectItem::ExprWithAlias { + expr: std::mem::replace(expr, Expr::value(Value::Null)), + alias: Ident::with_quote('"', argument_col_name), + }; + select_items_to_add.push(SelectItemToAdd { + expr_to_insert, + position, + }); + } + other => { + log::error!("Unsupported argument to {func_name}: {other}"); + } + } + } + delayed_function_calls.push(DelayedFunctionCall { + function: func_name, + argument_col_names, + target_col_name: alias.value.clone(), + }); + } + // Insert the new select items (the function arguments) at the positions where the function calls were + let mut it = select_items_to_add.into_iter().peekable(); + *select_items = std::mem::take(select_items) + .into_iter() + .enumerate() + .flat_map(|(position, item)| { + let mut items = Vec::with_capacity(1); + while it.peek().is_some_and(|x| x.position == position) { + items.push(it.next().unwrap().expr_to_insert); + } + if items.is_empty() { + items.push(item); + } + items + }) + .collect(); + delayed_function_calls +} + +fn extract_static_simple_select( + stmt: &Statement, + params: &[StmtParam], +) -> Option> { + let set_expr = match stmt { + Statement::Query(q) + if q.limit_clause.is_none() + && q.fetch.is_none() + && q.order_by.is_none() + && q.with.is_none() + && q.locks.is_empty() => + { + q.body.as_ref() + } + _ => return None, + }; + let select_items = match set_expr { + sqlparser::ast::SetExpr::Select(s) + if s.cluster_by.is_empty() + && s.distinct.is_none() + && s.distribute_by.is_empty() + && s.from.is_empty() + && s.group_by == sqlparser::ast::GroupByExpr::Expressions(vec![], vec![]) + && s.having.is_none() + && s.into.is_none() + && s.lateral_views.is_empty() + && s.named_window.is_empty() + && s.qualify.is_none() + && s.selection.is_none() + && s.sort_by.is_empty() + && s.top.is_none() => + { + &s.projection + } + _ => return None, + }; + let mut items = Vec::with_capacity(select_items.len()); + let mut params_iter = params.iter().cloned(); + for select_item in select_items { + let sqlparser::ast::SelectItem::ExprWithAlias { expr, alias } = select_item else { + return None; + }; + let value = expr_to_simple_select_val(&mut params_iter, expr)?; + let key = alias.value.clone(); + items.push((key, value)); + } + if let Some(p) = params_iter.next() { + log::error!("static select extraction failed because of extraneous parameter: {p:?}"); + return None; + } + Some(items) +} + +fn expr_to_simple_select_val( + params_iter: &mut impl Iterator, + expr: &Expr, +) -> Option { + use SimpleSelectValue::{Dynamic, Static}; + use serde_json::Value::{Bool, Null, Number, String}; + Some(match expr { + Expr::Value(ValueWithSpan { + value: Value::Boolean(b), + .. + }) => Static(Bool(*b)), + Expr::Value(ValueWithSpan { + value: Value::Number(n, _), + .. + }) => Static(Number(n.parse().ok()?)), + Expr::Value(ValueWithSpan { + value: Value::SingleQuotedString(s), + .. + }) => Static(String(s.clone())), + Expr::Value(ValueWithSpan { + value: Value::Null, .. + }) => Static(Null), + e if is_simple_select_placeholder(e) => { + if let Some(p) = params_iter.next() { + Dynamic(p) + } else { + log::error!("Parameter not extracted for placehorder: {expr:?}"); + return None; + } + } + other => { + log::trace!("Cancelling simple select optimization because of expr: {other:?}"); + return None; + } + }) +} + +fn is_simple_select_placeholder(e: &Expr) -> bool { + match e { + Expr::Value(ValueWithSpan { + value: Value::Placeholder(_), + .. + }) => true, + Expr::Cast { + expr, + data_type: DataType::Text | DataType::Varchar(_) | DataType::Char(_), + format: None, + kind: CastKind::Cast, + .. + } if is_simple_select_placeholder(expr) => true, + _ => false, + } +} + +fn extract_set_variable( + stmt: &mut Statement, + params: &mut Vec, + db_info: &DbInfo, +) -> Option { + if let Statement::Set(Set::SingleAssignment { + variable: ObjectName(name), + values, + scope: None, + hivevar: false, + }) = stmt + && let ([ObjectNamePart::Identifier(ident)], [value]) = + (name.as_mut_slice(), values.as_mut_slice()) + { + let variable = if let Some(variable) = extract_ident_param(ident) { + variable + } else { + StmtParam::PostOrGet(std::mem::take(&mut ident.value)) + }; + let owned_expr = std::mem::replace(value, Expr::value(Value::Null)); + let mut params_iter = params.iter().cloned(); + if let Some(value) = expr_to_simple_select_val(&mut params_iter, &owned_expr) { + return Some(ParsedStatement::StaticSimpleSet { variable, value }); + } + + let mut select_stmt: Statement = expr_to_statement(owned_expr); + let delayed_functions = extract_toplevel_functions(&mut select_stmt); + if let Err(err) = validate_function_calls(&select_stmt) { + return Some(ParsedStatement::Error(err)); + } + let json_columns = extract_json_columns(&select_stmt, db_info.database_type); + let mut value = StmtWithParams { + query: select_stmt.to_string(), + query_position: extract_query_start(&select_stmt), + params: std::mem::take(params), + delayed_functions, + json_columns, + }; + transform_to_positional_placeholders(&mut value, db_info.kind); + return Some(ParsedStatement::SetVariable { variable, value }); + } + None +} + +const SQLPAGE_FUNCTION_NAMESPACE: &str = "sqlpage"; + +fn is_sqlpage_func(func_name_parts: &[ObjectNamePart]) -> bool { + if let [ + ObjectNamePart::Identifier(Ident { value, .. }), + ObjectNamePart::Identifier(Ident { .. }), + ] = func_name_parts + { + value == SQLPAGE_FUNCTION_NAMESPACE + } else { + false + } +} + +fn extract_sqlpage_function_name( + func_name_parts: &[ObjectNamePart], +) -> Option { + if let [ + ObjectNamePart::Identifier(Ident { + value: namespace, .. + }), + ObjectNamePart::Identifier(Ident { value, .. }), + ] = func_name_parts + && namespace == SQLPAGE_FUNCTION_NAMESPACE + { + return SqlPageFunctionName::from_str(value).ok(); + } + None +} + +fn sqlpage_func_name(func_name_parts: &[ObjectNamePart]) -> &str { + if let [ + ObjectNamePart::Identifier(Ident { .. }), + ObjectNamePart::Identifier(Ident { value, .. }), + ] = func_name_parts + { + value + } else { + debug_assert!( + false, + "sqlpage function name should have been checked by is_sqlpage_func" + ); + "" + } +} + +fn extract_json_columns(stmt: &Statement, dbms: SupportedDatabase) -> Vec { + // Only extract JSON columns for databases without native JSON support + if matches!(dbms, SupportedDatabase::Postgres | SupportedDatabase::Mssql) { + return Vec::new(); + } + + let mut json_columns = Vec::new(); + + if let Statement::Query(query) = stmt + && let SetExpr::Select(select) = query.body.as_ref() + { + for item in &select.projection { + if let SelectItem::ExprWithAlias { expr, alias } = item + && is_json_function(expr) + { + json_columns.push(alias.value.clone()); + log::trace!("Found JSON column: {alias}"); + } + } + } + + json_columns +} + +fn is_json_function(expr: &Expr) -> bool { + match expr { + Expr::Function(function) => { + if let [ObjectNamePart::Identifier(Ident { value, .. })] = function.name.0.as_slice() { + [ + "json_object", + "json_array", + "json_build_object", + "json_build_array", + "to_json", + "to_jsonb", + "json_agg", + "jsonb_agg", + "json_arrayagg", + "json_objectagg", + "json_group_array", + "json_group_object", + "json", + "jsonb", + ] + .iter() + .any(|&func| value.eq_ignore_ascii_case(func)) + } else { + false + } + } + Expr::Cast { data_type, .. } => { + if matches!(data_type, DataType::JSON | DataType::JSONB) { + true + } else if let DataType::Custom(ObjectName(parts), _) = data_type { + if let [ObjectNamePart::Identifier(ident)] = parts.as_slice() { + ident.value.eq_ignore_ascii_case("json") + } else { + false + } + } else { + false + } + } + _ => false, + } +} + +fn expr_to_statement(expr: Expr) -> Statement { + Statement::Query(Box::new(sqlparser::ast::Query { + with: None, + body: Box::new(sqlparser::ast::SetExpr::Select(Box::new( + sqlparser::ast::Select { + select_token: AttachedToken(TokenWithSpan::new( + Token::make_keyword("SELECT"), + expr.span(), + )), + distinct: None, + top: None, + projection: vec![SelectItem::ExprWithAlias { + expr, + alias: Ident::new("sqlpage_set_expr"), + }], + into: None, + from: vec![], + lateral_views: vec![], + selection: None, + group_by: sqlparser::ast::GroupByExpr::Expressions(vec![], vec![]), + cluster_by: vec![], + distribute_by: vec![], + sort_by: vec![], + having: None, + named_window: vec![], + qualify: None, + top_before_distinct: false, + prewhere: None, + window_before_qualify: false, + value_table_mode: None, + connect_by: Vec::new(), + optimizer_hints: vec![], + select_modifiers: None, + flavor: SelectFlavor::Standard, + exclude: None, + }, + ))), + order_by: None, + limit_clause: None, + fetch: None, + locks: vec![], + for_clause: None, + settings: None, + format_clause: None, + pipe_operators: Vec::new(), + })) +} + +#[cfg(test)] +mod test { + use super::super::sqlpage_functions::functions::SqlPageFunctionName; + use super::super::syntax_tree::SqlPageFunctionCall; + + use super::*; + + fn parse_stmt(sql: &str, dialect: &dyn Dialect) -> Statement { + let mut ast = Parser::parse_sql(dialect, sql).unwrap(); + assert_eq!(ast.len(), 1); + ast.pop().unwrap() + } + + fn parse_postgres_stmt(sql: &str) -> Statement { + parse_stmt(sql, &PostgreSqlDialect {}) + } + + #[test] + fn test_statement_rewrite() { + let mut ast = + parse_postgres_stmt("select $a from t where $x > $a OR $x = sqlpage.cookie('cookoo')"); + let db_info = create_test_db_info(SupportedDatabase::Postgres); + let parameters = ParameterExtractor::extract_parameters(&mut ast, db_info).unwrap(); + // $a -> $1 + // $x -> $2 + // sqlpage.cookie(...) -> $3 + assert_eq!( + ast.to_string(), + "SELECT CAST($1 AS TEXT) FROM t WHERE CAST($2 AS TEXT) > CAST($1 AS TEXT) OR CAST($2 AS TEXT) = CAST($3 AS TEXT)" + ); + assert_eq!( + parameters, + [ + StmtParam::PostOrGet("a".to_string()), + StmtParam::PostOrGet("x".to_string()), + StmtParam::FunctionCall(SqlPageFunctionCall { + function: SqlPageFunctionName::cookie, + arguments: vec![StmtParam::Literal("cookoo".to_string())] + }), + ] + ); + } + + #[test] + fn test_statement_rewrite_sqlite() { + let mut ast = parse_stmt("select $x, :y from t", &SQLiteDialect {}); + let db_info = create_test_db_info(SupportedDatabase::Sqlite); + let parameters = ParameterExtractor::extract_parameters(&mut ast, db_info).unwrap(); + assert_eq!( + ast.to_string(), + "SELECT CAST(?1 AS TEXT), CAST(?2 AS TEXT) FROM t" + ); + assert_eq!( + parameters, + [ + StmtParam::PostOrGet("x".to_string()), + StmtParam::Post("y".to_string()), + ] + ); + } + + const ALL_DIALECTS: &[(&dyn Dialect, SupportedDatabase)] = &[ + (&PostgreSqlDialect {}, SupportedDatabase::Postgres), + (&MsSqlDialect {}, SupportedDatabase::Mssql), + (&MySqlDialect {}, SupportedDatabase::MySql), + (&SQLiteDialect {}, SupportedDatabase::Sqlite), + ]; + + fn create_test_db_info(database_type: SupportedDatabase) -> DbInfo { + let kind = match database_type { + SupportedDatabase::Postgres => AnyKind::Postgres, + SupportedDatabase::Mssql => AnyKind::Mssql, + SupportedDatabase::MySql => AnyKind::MySql, + SupportedDatabase::Sqlite => AnyKind::Sqlite, + _ => AnyKind::Odbc, + }; + DbInfo { + dbms_name: database_type.display_name().to_string(), + database_type, + kind, + } + } + + #[test] + fn test_duckdb_odbc_dialect_selection() { + use std::any::Any; + + let dbms = SupportedDatabase::from_dbms_name("DuckDB"); + assert_eq!(dbms, SupportedDatabase::Duckdb); + let dialect = dialect_for_db(dbms); + assert_eq!(dialect.as_ref().type_id(), (DuckDbDialect {}).type_id()); + + let sql = "select {'a': 1, 'b': 2} as payload"; + let db_info = create_test_db_info(dbms); + let mut parsed = parse_sql(&db_info, dialect.as_ref(), sql).unwrap(); + let stmt = parsed.next().expect("expected one statement"); + assert!( + !matches!(stmt, ParsedStatement::Error(_)), + "duckdb dictionary literals should parse" + ); + + let pg_info = create_test_db_info(SupportedDatabase::Postgres); + let mut parsed = parse_sql(&pg_info, &PostgreSqlDialect {}, sql).unwrap(); + let stmt = parsed.next().expect("expected one statement"); + assert!( + matches!(stmt, ParsedStatement::Error(_)), + "postgres should reject duckdb dictionary literals" + ); + } + + #[test] + fn test_extract_toplevel_delayed_functions() { + let mut ast = parse_stmt( + "select sqlpage.fetch($x) as x, sqlpage.persist_uploaded_file('a', 'b') as y from t", + &PostgreSqlDialect {}, + ); + let functions = extract_toplevel_functions(&mut ast); + assert_eq!( + ast.to_string(), + "SELECT $x AS \"_sqlpage_f0_a0\", 'a' AS \"_sqlpage_f1_a0\", 'b' AS \"_sqlpage_f1_a1\" FROM t" + ); + assert_eq!( + functions, + vec![ + DelayedFunctionCall { + function: SqlPageFunctionName::fetch, + argument_col_names: vec!["_sqlpage_f0_a0".to_string()], + target_col_name: "x".to_string() + }, + DelayedFunctionCall { + function: SqlPageFunctionName::persist_uploaded_file, + argument_col_names: vec![ + "_sqlpage_f1_a0".to_string(), + "_sqlpage_f1_a1".to_string() + ], + target_col_name: "y".to_string() + } + ] + ); + } + + #[test] + fn test_extract_toplevel_delayed_functions_parameter_order() { + // The order of the function arguments should be preserved + // Otherwise the statement parameters will be bound to the wrong arguments + let sql = "select $a as a, sqlpage.exec('xxx', x = $b) as b, $c as c from t"; + let mut ast = parse_postgres_stmt(sql); + let delayed_functions = extract_toplevel_functions(&mut ast); + assert_eq!( + ast.to_string(), + "SELECT $a AS a, 'xxx' AS \"_sqlpage_f0_a0\", x = $b AS \"_sqlpage_f0_a1\", $c AS c FROM t" + ); + assert_eq!( + delayed_functions, + &[DelayedFunctionCall { + function: SqlPageFunctionName::exec, + argument_col_names: vec![ + "_sqlpage_f0_a0".to_string(), + "_sqlpage_f0_a1".to_string() + ], + target_col_name: "b".to_string() + }] + ); + } + + #[test] + fn test_sqlpage_function_with_argument() { + for &(dialect, _kind) in ALL_DIALECTS { + let sql = "select sqlpage.fetch($x)"; + let mut ast = parse_stmt(sql, dialect); + let db_info = create_test_db_info(SupportedDatabase::Postgres); + let parameters = ParameterExtractor::extract_parameters(&mut ast, db_info).unwrap(); + assert_eq!( + parameters, + [StmtParam::FunctionCall(SqlPageFunctionCall { + function: SqlPageFunctionName::fetch, + arguments: vec![StmtParam::PostOrGet("x".to_string())] + })], + "Failed for dialect {dialect:?}" + ); + } + } + + #[test] + fn test_parse_sql_unsupported_expr_in_sqlpage_arg() { + let sql = "SELECT sqlpage.link('x', json_build_object('k', c)) FROM (SELECT 1 AS c) t"; + let db_info = create_test_db_info(SupportedDatabase::Postgres); + let mut parsed = parse_sql(&db_info, &PostgreSqlDialect {}, sql).unwrap(); + let stmt = parsed.next().expect("one statement"); + let ParsedStatement::Error(err) = stmt else { + panic!("expected ParsedStatement::Error: {stmt:?}"); + }; + let err_msg = format!("{err:#}"); + assert!( + err_msg.contains("Unsupported sqlpage function argument:"), + "{err_msg}" + ); + assert!(err_msg.contains("\"c\" is an sql expression, which cannot be passed as a nested sqlpage function argument."), "{err_msg}"); + } + + #[test] + fn test_parse_sql_unemulated_function_in_sqlpage_arg() { + let sql = "SELECT sqlpage.link('x', upper('a')) FROM (SELECT 1) t"; + let db_info = create_test_db_info(SupportedDatabase::Postgres); + let mut parsed = parse_sql(&db_info, &PostgreSqlDialect {}, sql).unwrap(); + let stmt = parsed.next().expect("one statement"); + let ParsedStatement::Error(err) = stmt else { + panic!("expected ParsedStatement::Error: {stmt:?}"); + }; + let err_msg = format!("{err:#}"); + assert!( + err_msg.contains("Unsupported sqlpage function argument:"), + "{err_msg}" + ); + assert!( + err_msg.contains("\"upper\" is not a supported sqlpage function"), + "{err_msg}" + ); + } + + #[test] + fn test_set_variable_to_other_variable() { + let sql = "set x = $y"; + for &(dialect, dbms) in ALL_DIALECTS { + let mut parser = Parser::new(dialect).try_with_sql(sql).unwrap(); + let db_info = create_test_db_info(dbms); + match parse_single_statement(&mut parser, &db_info, sql) { + Some(ParsedStatement::StaticSimpleSet { variable, value }) => { + assert_eq!( + variable, + StmtParam::PostOrGet("x".to_string()), + "{dialect:?}" + ); + assert_eq!( + value, + SimpleSelectValue::Dynamic(StmtParam::PostOrGet("y".to_string())) + ); + } + other => panic!("Failed for dialect {dialect:?}: {other:#?}"), + } + } + } + + #[test] + fn is_own_placeholder() { + assert!( + ParameterExtractor { + db_info: create_test_db_info(SupportedDatabase::Postgres), + parameters: vec![], + extract_error: None, + } + .is_own_placeholder("$1") + ); + + assert!( + ParameterExtractor { + db_info: create_test_db_info(SupportedDatabase::Postgres), + parameters: vec![StmtParam::Get("x".to_string())], + extract_error: None, + } + .is_own_placeholder("$2") + ); + + assert!( + !ParameterExtractor { + db_info: create_test_db_info(SupportedDatabase::Postgres), + parameters: vec![], + extract_error: None, + } + .is_own_placeholder("$2") + ); + + assert!( + ParameterExtractor { + db_info: create_test_db_info(SupportedDatabase::Sqlite), + parameters: vec![], + extract_error: None, + } + .is_own_placeholder("?1") + ); + + assert!( + !ParameterExtractor { + db_info: create_test_db_info(SupportedDatabase::Sqlite), + parameters: vec![], + extract_error: None, + } + .is_own_placeholder("$1") + ); + } + + #[test] + fn test_mssql_statement_rewrite() { + let mut ast = parse_stmt( + "select '' || $1 from [a schema].[a table]", + &MsSqlDialect {}, + ); + let db_info = create_test_db_info(SupportedDatabase::Mssql); + let parameters = ParameterExtractor::extract_parameters(&mut ast, db_info).unwrap(); + assert_eq!( + ast.to_string(), + "SELECT CONCAT('', CAST(@p1 AS VARCHAR(MAX))) FROM [a schema].[a table]" + ); + assert_eq!(parameters, [StmtParam::PostOrGet("1".to_string()),]); + } + + #[test] + fn test_static_extract() { + use SimpleSelectValue::Static; + + assert_eq!( + extract_static_simple_select( + &parse_postgres_stmt( + "select 'hello' as hello, 42 as answer, null as nothing, 'world' as hello" + ), + &[] + ), + Some(vec![ + ("hello".into(), Static("hello".into())), + ("answer".into(), Static(42.into())), + ("nothing".into(), Static(().into())), + ("hello".into(), Static("world".into())), + ]) + ); + } + + #[test] + fn test_simple_select_with_sqlpage_pseudofunction() { + let sql = "select 'text' as component, $x as contents, $y as title"; + let dialects: &[&dyn Dialect] = &[ + &PostgreSqlDialect {}, + &SQLiteDialect {}, + &MySqlDialect {}, + &MsSqlDialect {}, + ]; + for &dialect in dialects { + use SimpleSelectValue::{Dynamic, Static}; + use StmtParam::PostOrGet; + use std::any::Any; + + let db_info = if dialect.type_id() == (PostgreSqlDialect {}).type_id() { + create_test_db_info(SupportedDatabase::Postgres) + } else if dialect.type_id() == (SQLiteDialect {}).type_id() { + create_test_db_info(SupportedDatabase::Sqlite) + } else if dialect.type_id() == (MySqlDialect {}).type_id() { + create_test_db_info(SupportedDatabase::MySql) + } else if dialect.type_id() == (MsSqlDialect {}).type_id() { + create_test_db_info(SupportedDatabase::Mssql) + } else { + create_test_db_info(SupportedDatabase::Generic) + }; + let parsed: Vec = parse_sql(&db_info, dialect, sql).unwrap().collect(); + match &parsed[..] { + [ParsedStatement::StaticSimpleSelect { values: q, .. }] => assert_eq!( + q, + &[ + ("component".into(), Static("text".into())), + ("contents".into(), Dynamic(PostOrGet("x".into()))), + ("title".into(), Dynamic(PostOrGet("y".into()))), + ] + ), + other => panic!("failed to extract simple select in {dialect:?}: {other:?}"), + } + } + } + + #[test] + fn test_simple_select_only_extraction() { + use SimpleSelectValue::{Dynamic, Static}; + use StmtParam::PostOrGet; + assert_eq!( + extract_static_simple_select( + &parse_postgres_stmt("select 'text' as component, $1 as contents"), + &[PostOrGet("cook".into())] + ), + Some(vec![ + ("component".into(), Static("text".into())), + ("contents".into(), Dynamic(PostOrGet("cook".into()))), + ]) + ); + } + + #[test] + fn test_extract_set_variable() { + let sql = "set x = CURRENT_TIMESTAMP"; + for &(dialect, dbms) in ALL_DIALECTS { + let mut parser = Parser::new(dialect).try_with_sql(sql).unwrap(); + let db_info = create_test_db_info(dbms); + let stmt = parse_single_statement(&mut parser, &db_info, sql); + if let Some(ParsedStatement::SetVariable { + variable, + value: StmtWithParams { query, params, .. }, + }) = stmt + { + assert_eq!( + variable, + StmtParam::PostOrGet("x".to_string()), + "{dialect:?}" + ); + assert_eq!(query, "SELECT CURRENT_TIMESTAMP AS sqlpage_set_expr"); + assert!(params.is_empty()); + } else { + panic!("Failed for dialect {dialect:?}: {stmt:#?}"); + } + } + } + + #[test] + fn test_extract_set_variable_static() { + let sql = "set x = 'hello'"; + for &(dialect, dbms) in ALL_DIALECTS { + let mut parser = Parser::new(dialect).try_with_sql(sql).unwrap(); + let db_info = create_test_db_info(dbms); + match parse_single_statement(&mut parser, &db_info, sql) { + Some(ParsedStatement::StaticSimpleSet { + variable: StmtParam::PostOrGet(var_name), + value: SimpleSelectValue::Static(value), + }) => { + assert_eq!(var_name, "x"); + assert_eq!(value, "hello"); + } + other => panic!("Failed for dialect {dialect:?}: {other:#?}"), + } + } + } + + #[test] + fn test_static_extract_doesnt_match() { + assert_eq!( + extract_static_simple_select( + &parse_postgres_stmt("select 'hello' as hello, 42 as answer limit 0"), + &[] + ), + None + ); + assert_eq!( + extract_static_simple_select( + &parse_postgres_stmt("select 'hello' as hello, 42 as answer order by 1"), + &[] + ), + None + ); + assert_eq!( + extract_static_simple_select( + &parse_postgres_stmt("select 'hello' as hello, 42 as answer offset 1"), + &[] + ), + None + ); + assert_eq!( + extract_static_simple_select( + &parse_postgres_stmt("select 'hello' as hello, 42 as answer where 1 = 0"), + &[] + ), + None + ); + assert_eq!( + extract_static_simple_select( + &parse_postgres_stmt("select 'hello' as hello, 42 as answer FROM t"), + &[] + ), + None + ); + assert_eq!( + extract_static_simple_select( + &parse_postgres_stmt("select x'CAFEBABE' as hello, 42 as answer"), + &[] + ), + None + ); + } + + #[test] + fn test_extract_json_columns() { + let sql = r" + WITH json_cte AS ( + SELECT json_build_object('a', x, 'b', y) AS cte_json + FROM generate_series(1, 3) x + JOIN generate_series(4, 6) y ON true + ) + SELECT + json_object('key', 'value') AS json_col1, + json_array(1, 2, 3) AS json_col2, + (SELECT json_build_object('nested', subq.val) + FROM (SELECT AVG(x) AS val FROM generate_series(1, 5) x) subq + ) AS json_col3, -- not supported because of the subquery + CASE + WHEN EXISTS (SELECT 1 FROM json_cte WHERE cte_json->>'a' = '2') + THEN to_json(ARRAY(SELECT cte_json FROM json_cte)) + ELSE json_build_array() + END AS json_col4, -- not supported because of the CASE + json_unknown_fn(regular_column) AS non_json_col, + CAST(json_col1 AS json) AS json_col6 + FROM some_table + CROSS JOIN json_cte + WHERE json_typeof(json_col1) = 'object' + "; + + let stmt = parse_postgres_stmt(sql); + let json_columns = extract_json_columns(&stmt, SupportedDatabase::Sqlite); + + assert_eq!( + json_columns, + vec![ + "json_col1".to_string(), + "json_col2".to_string(), + "json_col6".to_string() + ] + ); + } + + #[test] + fn test_set_variable_with_sqlpage_function() { + let sql = "set x = sqlpage.url_encode(some_db_function())"; + for &(dialect, dbms) in ALL_DIALECTS { + let mut parser = Parser::new(dialect).try_with_sql(sql).unwrap(); + let db_info = create_test_db_info(dbms); + let stmt = parse_single_statement(&mut parser, &db_info, sql); + let Some(ParsedStatement::SetVariable { + variable, + value: + StmtWithParams { + query, + params, + delayed_functions, + json_columns, + .. + }, + }) = stmt + else { + panic!("for dialect {dialect:?}: {stmt:#?} instead of SetVariable"); + }; + assert_eq!( + variable, + StmtParam::PostOrGet("x".to_string()), + "{dialect:?}" + ); + assert_eq!( + delayed_functions, + [DelayedFunctionCall { + function: SqlPageFunctionName::url_encode, + argument_col_names: vec!["_sqlpage_f0_a0".to_string()], + target_col_name: "sqlpage_set_expr".to_string() + }] + ); + assert_eq!(query, "SELECT some_db_function() AS \"_sqlpage_f0_a0\""); + assert_eq!(params, []); + assert_eq!(json_columns, Vec::::new()); + } + } + + #[test] + fn test_extract_json_columns_from_literal() { + let sql = r#" + SELECT + 'Pro Plan' as title, + JSON('{"icon":"database","color":"blue","description":"1GB Database"}') as item, + JSON('{"icon":"headset","color":"green","description":"Priority Support"}') as item + "#; + + let stmt = parse_stmt(sql, &SQLiteDialect {}); + let json_columns = extract_json_columns(&stmt, SupportedDatabase::Sqlite); + + assert!(json_columns.contains(&"item".to_string())); + assert!(!json_columns.contains(&"title".to_string())); + } + + #[test] + fn test_positional_placeholders() { + let sql = "select \ + @SQLPAGE_TEMP10 as a1, \ + @SQLPAGE_TEMP9 as a2, \ + @SQLPAGE_TEMP8 as a3, \ + @SQLPAGE_TEMP7 as a4, \ + @SQLPAGE_TEMP6 as a5, \ + @SQLPAGE_TEMP5 as a6, \ + @SQLPAGE_TEMP4 as a7, \ + @SQLPAGE_TEMP3 as a8, \ + @SQLPAGE_TEMP2 as a9, \ + @SQLPAGE_TEMP1 as a10 \ + @SQLPAGE_TEMP10 as a1bis \ + from t"; + let mut stmt = StmtWithParams { + query: sql.to_string(), + query_position: SourceSpan { + start: SourceLocation { line: 1, column: 1 }, + end: SourceLocation { line: 1, column: 1 }, + }, + params: vec![ + StmtParam::PostOrGet("x1".to_string()), + StmtParam::PostOrGet("x2".to_string()), + StmtParam::PostOrGet("x3".to_string()), + StmtParam::PostOrGet("x4".to_string()), + StmtParam::PostOrGet("x5".to_string()), + StmtParam::PostOrGet("x6".to_string()), + StmtParam::PostOrGet("x7".to_string()), + StmtParam::PostOrGet("x8".to_string()), + StmtParam::PostOrGet("x9".to_string()), + StmtParam::PostOrGet("x10".to_string()), + ], + delayed_functions: vec![], + json_columns: vec![], + }; + transform_to_positional_placeholders(&mut stmt, AnyKind::MySql); + assert_eq!( + stmt.query, + "select \ + ? as a1, \ + ? as a2, \ + ? as a3, \ + ? as a4, \ + ? as a5, \ + ? as a6, \ + ? as a7, \ + ? as a8, \ + ? as a9, \ + ? as a10 \ + ? as a1bis \ + from t" + ); + assert_eq!( + stmt.params, + vec![ + StmtParam::PostOrGet("x10".to_string()), + StmtParam::PostOrGet("x9".to_string()), + StmtParam::PostOrGet("x8".to_string()), + StmtParam::PostOrGet("x7".to_string()), + StmtParam::PostOrGet("x6".to_string()), + StmtParam::PostOrGet("x5".to_string()), + StmtParam::PostOrGet("x4".to_string()), + StmtParam::PostOrGet("x3".to_string()), + StmtParam::PostOrGet("x2".to_string()), + StmtParam::PostOrGet("x1".to_string()), + StmtParam::PostOrGet("x10".to_string()), + ] + ); + } + + #[test] + fn test_set_variable_error_handling() { + let sql = "set x = db_function(sqlpage.fetch(other_db_function()))"; + for &(dialect, dbms) in ALL_DIALECTS { + let mut parser = Parser::new(dialect).try_with_sql(sql).unwrap(); + let db_info = create_test_db_info(dbms); + let stmt = parse_single_statement(&mut parser, &db_info, sql); + if let Some(ParsedStatement::Error(err)) = stmt { + assert!( + err.to_string() + .contains("Unsupported sqlpage function argument:"), + "Expected error for invalid function, got: {err}" + ); + } else { + panic!("Expected error for invalid function, got: {stmt:#?}"); + } + } + } +} diff --git a/src/webserver/database/sql/parameter_extraction.rs b/src/webserver/database/sql/parameter_extraction.rs new file mode 100644 index 0000000..57bb08c --- /dev/null +++ b/src/webserver/database/sql/parameter_extraction.rs @@ -0,0 +1,591 @@ +use super::super::{DbInfo, SupportedDatabase}; +use super::{is_sqlpage_func, sqlpage_func_name}; +use crate::webserver::database::sqlpage_functions::func_call_to_param; +use crate::webserver::database::syntax_tree::StmtParam; +use sqlparser::ast::{ + BinaryOperator, CastKind, CharacterLength, DataType, Expr, Function, FunctionArg, + FunctionArgExpr, FunctionArgumentList, FunctionArguments, Ident, ObjectName, ObjectNamePart, + Spanned, Statement, Value, ValueWithSpan, Visit, VisitMut, Visitor, VisitorMut, +}; +use sqlx::any::AnyKind; +use std::ops::ControlFlow; + +pub(super) struct ParameterExtractor { + pub(super) db_info: DbInfo, + pub(super) parameters: Vec, + pub(super) extract_error: Option, +} + +#[derive(Debug)] +pub(crate) enum DbPlaceHolder { + PrefixedNumber { prefix: &'static str }, + Positional { placeholder: &'static str }, +} + +pub(crate) const DB_PLACEHOLDERS: [(AnyKind, DbPlaceHolder); 5] = [ + ( + AnyKind::Sqlite, + DbPlaceHolder::PrefixedNumber { prefix: "?" }, + ), + ( + AnyKind::Postgres, + DbPlaceHolder::PrefixedNumber { prefix: "$" }, + ), + ( + AnyKind::MySql, + DbPlaceHolder::Positional { placeholder: "?" }, + ), + ( + AnyKind::Mssql, + DbPlaceHolder::PrefixedNumber { prefix: "@p" }, + ), + ( + AnyKind::Odbc, + DbPlaceHolder::Positional { placeholder: "?" }, + ), +]; + +/// For positional parameters, we use a temporary placeholder during parameter extraction, +/// And then replace it with the actual placeholder during statement rewriting. +pub(crate) const TEMP_PLACEHOLDER_PREFIX: &str = "@SQLPAGE_TEMP"; + +fn get_placeholder_prefix(kind: AnyKind) -> &'static str { + if let Some((_, DbPlaceHolder::PrefixedNumber { prefix })) = DB_PLACEHOLDERS + .iter() + .find(|(placeholder_kind, _prefix)| *placeholder_kind == kind) + { + prefix + } else { + TEMP_PLACEHOLDER_PREFIX + } +} + +impl ParameterExtractor { + pub(super) fn extract_parameters( + sql_ast: &mut Statement, + db_info: DbInfo, + ) -> anyhow::Result> { + let mut this = Self { + db_info, + parameters: vec![], + extract_error: None, + }; + let _ = sql_ast.visit(&mut this); + if let Some(e) = this.extract_error { + return Err(e); + } + Ok(this.parameters) + } + + fn replace_with_placeholder(&mut self, value: &mut Expr, param: StmtParam) { + let placeholder = + if let Some(existing_idx) = self.parameters.iter().position(|p| *p == param) { + // Parameter already exists, use its index + self.make_placeholder_for_index(existing_idx + 1) + } else { + // New parameter, add it to the list + let placeholder = self.make_placeholder(); + log::trace!("Replacing {param} with {placeholder}"); + self.parameters.push(param); + placeholder + }; + *value = placeholder; + } + + fn make_placeholder_for_index(&self, index: usize) -> Expr { + let name = make_tmp_placeholder(self.db_info.kind, index); + let data_type = match self.db_info.database_type { + SupportedDatabase::MySql => DataType::Char(None), + SupportedDatabase::Mssql => DataType::Varchar(Some(CharacterLength::Max)), + SupportedDatabase::Postgres | SupportedDatabase::Sqlite => DataType::Text, + SupportedDatabase::Oracle => DataType::Varchar(Some(CharacterLength::IntegerLength { + length: 4000, + unit: None, + })), + _ => DataType::Varchar(None), + }; + let value = Expr::value(Value::Placeholder(name)); + Expr::Cast { + expr: Box::new(value), + data_type, + format: None, + kind: CastKind::Cast, + array: false, + } + } + + fn make_placeholder(&self) -> Expr { + self.make_placeholder_for_index(self.parameters.len() + 1) + } + + pub(super) fn is_own_placeholder(&self, param: &str) -> bool { + let prefix = get_placeholder_prefix(self.db_info.kind); + if let Some(param) = param.strip_prefix(prefix) + && let Ok(index) = param.parse::() + { + return index <= self.parameters.len() + 1; + } + false + } +} + +struct InvalidFunctionFinder; +impl Visitor for InvalidFunctionFinder { + type Break = (String, Vec); + fn pre_visit_expr(&mut self, value: &Expr) -> ControlFlow { + match value { + Expr::Function(Function { + name: ObjectName(func_name_parts), + args: + FunctionArguments::List(FunctionArgumentList { + args, + duplicate_treatment: None, + .. + }), + .. + }) if is_sqlpage_func(func_name_parts) => { + let func_name = sqlpage_func_name(func_name_parts); + let arguments = args.clone(); + return ControlFlow::Break((func_name.to_string(), arguments)); + } + _ => (), + } + ControlFlow::Continue(()) + } +} + +pub(super) fn validate_function_calls(stmt: &Statement) -> anyhow::Result<()> { + let mut finder = InvalidFunctionFinder; + if let ControlFlow::Break((func_name, mut args)) = stmt.visit(&mut finder) { + let ctx = ParamExtractContext { + parent_func: Some(func_name.clone()), + }; + function_args_to_stmt_params(&mut args, &ctx)?; + + let args_str = FormatArguments(&args); + let error_msg = format!( + "Invalid SQLPage function call: sqlpage.{func_name}({args_str})\n\n\ + Arbitrary SQL expressions as function arguments are not supported.\n\n\ + SQLPage functions can either:\n\ + 1. Run BEFORE the query (to provide input values)\n\ + 2. Run AFTER the query (to process the results)\n\ + But they can't run DURING the query - the database doesn't know how to call them!\n\n\ + To fix this, you can either:\n\ + 1. Store the function argument in a variable first:\n\ + SET {func_name}_arg = ...;\n\ + SET {func_name}_result = sqlpage.{func_name}(${func_name}_arg);\n\ + SELECT * FROM example WHERE xxx = ${func_name}_result;\n\n\ + 2. Or move the function to the top level to process results:\n\ + SELECT sqlpage.{func_name}(...) FROM example;" + ); + Err(anyhow::anyhow!(error_msg)) + } else { + Ok(()) + } +} + +/** This is a helper struct to format a list of arguments for an error message. */ +struct FormatArguments<'a>(&'a [FunctionArg]); +impl std::fmt::Display for FormatArguments<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut args = self.0.iter(); + if let Some(arg) = args.next() { + write!(f, "{arg}")?; + } + for arg in args { + write!(f, ", {arg}")?; + } + Ok(()) + } +} + +#[derive(Clone, Default)] +pub(crate) struct ParamExtractContext { + pub parent_func: Option, +} + +impl ParamExtractContext { + fn with_parent(parent: &str) -> Self { + Self { + parent_func: Some(parent.to_string()), + } + } + + fn build_error(&self, e: &ExprToParamError, arguments: &[FunctionArg]) -> SqlPageFunctionError { + let line = e.line.unwrap_or(0); + let func_name = self.parent_func.as_deref().unwrap_or("unknown").to_string(); + let arguments_str = FormatArguments(arguments).to_string(); + + let reason = match &e.kind { + ExprToParamErrorKind::UnsupportedExpr { summary } => { + format!( + "\"{summary}\" is an sql expression, which cannot be passed as a nested sqlpage function argument." + ) + } + ExprToParamErrorKind::UnemulatedFunction { name } => { + format!( + "\"{name}\" is not a supported sqlpage function. Only a few basic sql functions like concat or json_object can be used inside sqlpage functions." + ) + } + ExprToParamErrorKind::NamedArgs => "Named function arguments are not supported.\n\ + Please use positional arguments only." + .to_string(), + }; + + SqlPageFunctionError { + line, + func_name, + arguments_str, + reason, + } + } +} + +#[derive(Debug)] +pub(crate) struct SqlPageFunctionError { + pub line: u64, + pub func_name: String, + pub arguments_str: String, + pub reason: String, +} + +impl std::fmt::Display for SqlPageFunctionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "Unsupported sqlpage function argument:\n\ +sqlpage.{func}({args_str})\n\n\ +{reason}\n\n\ +SQLPage functions can either:\n\ +1. Run BEFORE the query (to provide input values)\n\ +2. Run AFTER the query (to process the results)\n\ +But they can't run DURING the query - the database doesn't know how to call them!\n\n\ +To fix this, you can either:\n\ +1. Store the function argument in a variable first:\n\ +SET {func}_arg = ...;\n\ +SET {func}_result = sqlpage.{func}(${func}_arg);\n\ +SELECT * FROM example WHERE xxx = ${func}_result;\n\n\ +2. Or move the function to the top level to process results:\n\ +SELECT sqlpage.{func}(...) FROM example;", + func = self.func_name, + args_str = self.arguments_str, + reason = self.reason + ) + } +} +impl std::error::Error for SqlPageFunctionError {} + +#[derive(Debug)] +struct ExprToParamError { + line: Option, + kind: ExprToParamErrorKind, +} + +#[derive(Debug)] +enum ExprToParamErrorKind { + UnsupportedExpr { summary: String }, + UnemulatedFunction { name: String }, + NamedArgs, +} + +fn expr_summary(expr: &Expr) -> String { + match expr { + Expr::CompoundIdentifier(idents) => { + let s = idents + .iter() + .map(|i| i.value.as_str()) + .collect::>() + .join("."); + format!("column/table reference '{s}'") + } + _ => format!("{expr}"), + } +} + +fn function_arg_to_stmt_param( + arg: &mut FunctionArg, + ctx: &ParamExtractContext, +) -> Result { + let expr = function_arg_expr(arg).ok_or(ExprToParamError { + line: None, + kind: ExprToParamErrorKind::NamedArgs, + })?; + expr_to_stmt_param(expr, ctx) +} + +pub(crate) fn function_args_to_stmt_params( + arguments: &mut [FunctionArg], + ctx: &ParamExtractContext, +) -> anyhow::Result> { + let mut params = Vec::with_capacity(arguments.len()); + // We iterate manually so we can pass the entire `arguments` slice to into_error on failure + for arg in arguments.iter_mut() { + match function_arg_to_stmt_param(arg, ctx) { + Ok(p) => params.push(p), + Err(e) => { + let func_err = ctx.build_error(&e, arguments); + return Err(anyhow::Error::new(func_err)); + } + } + } + Ok(params) +} + +fn emulated_func_args_to_param( + func_name: &str, + args: &mut [FunctionArg], + line: u64, +) -> Result { + let inner = ParamExtractContext::with_parent(func_name); + if func_name.eq_ignore_ascii_case("concat") { + let mut concat_args = Vec::with_capacity(args.len()); + for a in args { + concat_args.push(function_arg_to_stmt_param(a, &inner)?); + } + Ok(StmtParam::Concat(concat_args)) + } else if func_name.eq_ignore_ascii_case("json_object") + || func_name.eq_ignore_ascii_case("jsonb_object") + || func_name.eq_ignore_ascii_case("json_build_object") + || func_name.eq_ignore_ascii_case("jsonb_build_object") + { + let mut json_obj_args = Vec::with_capacity(args.len()); + for a in args { + json_obj_args.push(function_arg_to_stmt_param(a, &inner)?); + } + Ok(StmtParam::JsonObject(json_obj_args)) + } else if func_name.eq_ignore_ascii_case("json_array") + || func_name.eq_ignore_ascii_case("jsonb_array") + || func_name.eq_ignore_ascii_case("json_build_array") + || func_name.eq_ignore_ascii_case("jsonb_build_array") + { + let mut json_obj_args = Vec::with_capacity(args.len()); + for a in args { + json_obj_args.push(function_arg_to_stmt_param(a, &inner)?); + } + Ok(StmtParam::JsonArray(json_obj_args)) + } else if func_name.eq_ignore_ascii_case("coalesce") { + let mut coalesce_args = Vec::with_capacity(args.len()); + for a in args { + coalesce_args.push(function_arg_to_stmt_param(a, &inner)?); + } + Ok(StmtParam::Coalesce(coalesce_args)) + } else { + Err(ExprToParamError { + line: Some(line), + kind: ExprToParamErrorKind::UnemulatedFunction { + name: func_name.to_string(), + }, + }) + } +} + +fn expr_to_stmt_param( + arg: &mut Expr, + ctx: &ParamExtractContext, +) -> Result { + let line = arg.span().start.line; + match arg { + Expr::Value(ValueWithSpan { + value: Value::Placeholder(placeholder), + .. + }) => Ok(map_param(std::mem::take(placeholder))), + Expr::Identifier(ident) => extract_ident_param(ident).ok_or_else(|| ExprToParamError { + line: Some(line), + kind: ExprToParamErrorKind::UnsupportedExpr { + summary: expr_summary(arg), + }, + }), + Expr::Function(Function { + name: ObjectName(func_name_parts), + args: + FunctionArguments::List(FunctionArgumentList { + args, + duplicate_treatment: None, + .. + }), + .. + }) if is_sqlpage_func(func_name_parts) => Ok(func_call_to_param( + sqlpage_func_name(func_name_parts), + args.as_mut_slice(), + ctx, + )), + Expr::Value(ValueWithSpan { + value: Value::SingleQuotedString(param_value), + .. + }) => Ok(StmtParam::Literal(std::mem::take(param_value))), + Expr::Value(ValueWithSpan { + value: Value::Number(param_value, _is_long), + .. + }) => Ok(StmtParam::Literal(param_value.clone())), + Expr::Value(ValueWithSpan { + value: Value::Null, .. + }) => Ok(StmtParam::Null), + Expr::BinaryOp { + left, + op: BinaryOperator::StringConcat, + right, + } => { + let left = expr_to_stmt_param(left, ctx)?; + let right = expr_to_stmt_param(right, ctx)?; + Ok(StmtParam::Concat(vec![left, right])) + } + Expr::Function(Function { + name: ObjectName(func_name_parts), + args: + FunctionArguments::List(FunctionArgumentList { + args, + duplicate_treatment: None, + .. + }), + .. + }) if func_name_parts.len() == 1 => { + let func_name = func_name_parts[0] + .as_ident() + .map(|ident| ident.value.as_str()) + .unwrap_or_default(); + emulated_func_args_to_param(func_name, args.as_mut_slice(), line) + } + _ => Err(ExprToParamError { + line: Some(line), + kind: ExprToParamErrorKind::UnsupportedExpr { + summary: expr_summary(arg), + }, + }), + } +} + +fn function_arg_expr(arg: &mut FunctionArg) -> Option<&mut Expr> { + match arg { + FunctionArg::Unnamed(FunctionArgExpr::Expr(expr)) => Some(expr), + _ => None, + } +} + +#[inline] +#[must_use] +pub(super) fn make_tmp_placeholder(kind: AnyKind, arg_number: usize) -> String { + let prefix = if let Some((_, DbPlaceHolder::PrefixedNumber { prefix })) = + DB_PLACEHOLDERS.iter().find(|(db_typ, _)| *db_typ == kind) + { + prefix + } else { + TEMP_PLACEHOLDER_PREFIX + }; + format!("{prefix}{arg_number}") +} + +pub(super) fn extract_ident_param(Ident { value, .. }: &mut Ident) -> Option { + if value.starts_with('$') || value.starts_with(':') { + let name = std::mem::take(value); + Some(map_param(name)) + } else { + None + } +} + +fn map_param(mut name: String) -> StmtParam { + if name.is_empty() { + return StmtParam::PostOrGet(name); + } + let prefix = name.remove(0); + match prefix { + '$' => StmtParam::PostOrGet(name), + ':' => StmtParam::Post(name), + _ => StmtParam::Get(name), + } +} + +impl VisitorMut for ParameterExtractor { + type Break = (); + fn pre_visit_expr(&mut self, value: &mut Expr) -> ControlFlow { + match value { + Expr::Identifier(ident) => { + if let Some(param) = extract_ident_param(ident) { + self.replace_with_placeholder(value, param); + } + } + Expr::Value(ValueWithSpan { + value: Value::Placeholder(param), + .. + }) if !self.is_own_placeholder(param) => + // this check is to avoid recursively replacing placeholders in the form of '?', or '$1', '$2', which we emit ourselves + { + let name = std::mem::take(param); + self.replace_with_placeholder(value, map_param(name)); + } + Expr::Function(Function { + name: ObjectName(func_name_parts), + args: + FunctionArguments::List(FunctionArgumentList { + args, + duplicate_treatment: None, + .. + }), + filter: None, + null_treatment: None, + over: None, + .. + }) if is_sqlpage_func(func_name_parts) => { + let func_name = sqlpage_func_name(func_name_parts); + log::trace!("Handling builtin function: {func_name}"); + let arguments = std::mem::take(args); + let ctx = ParamExtractContext { + parent_func: Some(func_name.to_string()), + }; + let mut arguments_clone = arguments.clone(); + let param = func_call_to_param(func_name, &mut arguments_clone, &ctx); + if let StmtParam::Error(msg) = ¶m { + log::trace!("Skipping extraction of {func_name} due to: {msg}"); + *args = arguments; + return ControlFlow::Continue(()); + } + self.replace_with_placeholder(value, param); + } + // Replace 'str1' || 'str2' with CONCAT('str1', 'str2') for MSSQL + Expr::BinaryOp { + left, + op: BinaryOperator::StringConcat, + right, + } if self.db_info.database_type == SupportedDatabase::Mssql => { + let left = std::mem::replace(left.as_mut(), Expr::value(Value::Null)); + let right = std::mem::replace(right.as_mut(), Expr::value(Value::Null)); + *value = Expr::Function(Function { + name: ObjectName(vec![ObjectNamePart::Identifier(Ident::new("CONCAT"))]), + args: FunctionArguments::List(FunctionArgumentList { + args: vec![ + FunctionArg::Unnamed(FunctionArgExpr::Expr(left)), + FunctionArg::Unnamed(FunctionArgExpr::Expr(right)), + ], + duplicate_treatment: None, + clauses: Vec::new(), + }), + parameters: FunctionArguments::None, + over: None, + filter: None, + null_treatment: None, + within_group: Vec::new(), + uses_odbc_syntax: false, + }); + } + Expr::Cast { + kind: kind @ CastKind::DoubleColon, + .. + } if ![ + SupportedDatabase::Postgres, + SupportedDatabase::Duckdb, + SupportedDatabase::Snowflake, + SupportedDatabase::Generic, + ] + .contains(&self.db_info.database_type) => + { + log::warn!( + "Casting with '::' is not supported on your database. \ + For backwards compatibility with older SQLPage versions, we will transform it to CAST(... AS ...)." + ); + *kind = CastKind::Cast; + } + _ => (), + } + ControlFlow::<()>::Continue(()) + } +} diff --git a/src/webserver/database/sql_to_json.rs b/src/webserver/database/sql_to_json.rs new file mode 100644 index 0000000..4c93ddd --- /dev/null +++ b/src/webserver/database/sql_to_json.rs @@ -0,0 +1,712 @@ +use crate::utils::add_value_to_map; +use crate::webserver::database::blob_to_data_url; +use bigdecimal::BigDecimal; +use chrono::{DateTime, FixedOffset, NaiveDate, NaiveDateTime}; +use serde_json::{self, Map, Value}; +use sqlx::any::{AnyColumn, AnyRow, AnyTypeInfo, AnyTypeInfoKind}; +use sqlx::postgres::PgValueRef; +use sqlx::postgres::types::PgRange; +use sqlx::{Column, Row, TypeInfo, ValueRef}; +use sqlx::{Decode, Type}; + +pub fn row_to_json(row: &AnyRow) -> Value { + use Value::Object; + + let columns = row.columns(); + let mut map = Map::new(); + for col in columns { + let key = canonical_col_name(col); + let value: Value = sql_to_json(row, col); + map = add_value_to_map(map, (key, value)); + } + Object(map) +} + +fn canonical_col_name(col: &AnyColumn) -> String { + // Some databases fold all unquoted identifiers to uppercase but SQLPage uses lowercase property names + if matches!(col.type_info().0, AnyTypeInfoKind::Odbc(_)) + && col + .name() + .chars() + .all(|c| c.is_ascii_uppercase() || c == '_') + { + col.name().to_ascii_lowercase() + } else { + col.name().to_owned() + } +} + +pub fn sql_to_json(row: &AnyRow, col: &sqlx::any::AnyColumn) -> Value { + let raw_value_result = row.try_get_raw(col.ordinal()); + match raw_value_result { + Ok(raw_value) if !raw_value.is_null() => { + let mut raw_value = Some(raw_value); + let decoded = sql_nonnull_to_json(|| { + raw_value + .take() + .unwrap_or_else(|| row.try_get_raw(col.ordinal()).unwrap()) + }); + log::trace!("Decoded value: {decoded:?}"); + decoded + } + Ok(_null) => Value::Null, + Err(e) => { + log::warn!("Unable to extract value from row: {e:?}"); + Value::Null + } + } +} + +fn decode_raw<'a, T: Decode<'a, sqlx::any::Any> + Default>( + raw_value: sqlx::any::AnyValueRef<'a>, +) -> T { + match T::decode(raw_value) { + Ok(v) => v, + Err(e) => { + let type_name = std::any::type_name::(); + log::error!("Failed to decode {type_name} value: {e}"); + T::default() + } + } +} + +fn decode_pg_range<'r, T>(raw_value: sqlx::any::AnyValueRef<'r>) -> Value +where + T: std::fmt::Display + + Type + + for<'a> sqlx::Decode<'a, sqlx::postgres::Postgres>, +{ + let Ok(pg_val): Result, _> = raw_value.try_into() else { + log::error!("Only postgres range values are supported"); + return Value::Null; + }; + match as sqlx::Decode<'r, sqlx::postgres::Postgres>>::decode(pg_val) { + Ok(pg_range) => pg_range.to_string().into(), + Err(e) => { + log::error!("Failed to decode postgres range value: {e}"); + Value::Null + } + } +} + +fn decimal_to_json(decimal: &BigDecimal) -> Value { + // to_plain_string always returns a valid JSON string + Value::Number(serde_json::Number::from_string_unchecked( + decimal.normalized().to_plain_string(), + )) +} + +pub fn sql_nonnull_to_json<'r>(mut get_ref: impl FnMut() -> sqlx::any::AnyValueRef<'r>) -> Value { + use AnyTypeInfoKind::{Mssql, MySql}; + let raw_value = get_ref(); + let type_info = raw_value.type_info(); + let type_name = type_info.name(); + log::trace!("Decoding a value of type {type_name:?} (type info: {type_info:?})"); + let AnyTypeInfo(ref db_type) = *type_info; + match type_name { + "REAL" | "FLOAT" | "FLOAT4" | "FLOAT8" | "DOUBLE" => decode_raw::(raw_value).into(), + "NUMERIC" | "DECIMAL" => decimal_to_json(&decode_raw(raw_value)), + "INT8" | "BIGINT" | "SERIAL8" | "BIGSERIAL" | "IDENTITY" | "INT64" | "INTEGER8" + | "BIGINT SIGNED" => decode_raw::(raw_value).into(), + "INT" | "INT4" | "INTEGER" | "MEDIUMINT" | "YEAR" => decode_raw::(raw_value).into(), + "INT2" | "SMALLINT" | "TINYINT" => decode_raw::(raw_value).into(), + "BIGINT UNSIGNED" => decode_raw::(raw_value).into(), + "INT UNSIGNED" | "MEDIUMINT UNSIGNED" | "SMALLINT UNSIGNED" | "TINYINT UNSIGNED" => { + decode_raw::(raw_value).into() + } + "BOOL" | "BOOLEAN" => decode_raw::(raw_value).into(), + "BIT" if matches!(db_type, Mssql(_)) => decode_raw::(raw_value).into(), + "BIT" if matches!(db_type, MySql(mysql_type) if mysql_type.max_size() == Some(1)) => { + decode_raw::(raw_value).into() + } + "BIT" if matches!(db_type, MySql(_)) => decode_raw::(raw_value).into(), + "DATE" => decode_raw::(raw_value) + .to_string() + .into(), + "TIME" | "TIMETZ" => decode_raw::(raw_value) + .to_string() + .into(), + "DATETIMEOFFSET" | "TIMESTAMP" | "TIMESTAMPTZ" => { + decode_raw::>(raw_value) + .to_rfc3339() + .into() + } + "DATETIME" | "DATETIME2" => decode_raw::(raw_value) + .format("%FT%T%.f") + .to_string() + .into(), + "MONEY" | "SMALLMONEY" if matches!(db_type, Mssql(_)) => { + decode_raw::(raw_value).into() + } + "UUID" | "UNIQUEIDENTIFIER" => decode_raw::(raw_value) + .to_string() + .into(), + "JSON" | "JSON[]" | "JSONB" | "JSONB[]" => decode_raw::(raw_value), + "BLOB" | "BYTEA" | "FILESTREAM" | "VARBINARY" | "BIGVARBINARY" | "BINARY" | "IMAGE" => { + blob_to_data_url::vec_to_data_uri_value(&decode_raw::>(raw_value)) + } + "INT4RANGE" => decode_pg_range::(raw_value), + "INT8RANGE" => decode_pg_range::(raw_value), + "NUMRANGE" => decode_pg_range::(raw_value), + "DATERANGE" => decode_pg_range::(raw_value), + "TSRANGE" => decode_pg_range::(raw_value), + "TSTZRANGE" => decode_pg_range::>(raw_value), + // Deserialize as a string by default + _ => decode_raw::(raw_value).into(), + } +} + +/// Takes the first column of a row and converts it to a string. +pub fn row_to_string(row: &AnyRow) -> Option { + let col = row.columns().first()?; + match sql_to_json(row, col) { + serde_json::Value::String(s) => Some(s), + serde_json::Value::Null => None, + other => Some(other.to_string()), + } +} + +#[cfg(test)] +mod tests { + use crate::app_config::tests::test_database_url; + + use super::*; + use sqlx::Connection; + + fn setup_logging() { + crate::telemetry::init_test_logging(); + } + + fn db_specific_test(db_type: &str) -> Option { + setup_logging(); + let db_url = test_database_url(); + if db_url.starts_with(db_type) { + Some(db_url) + } else { + log::warn!("Skipping test because DATABASE_URL is not set to a {db_type} database"); + None + } + } + + #[actix_web::test] + async fn test_row_to_json() -> anyhow::Result<()> { + use sqlx::Connection; + let db_url = test_database_url(); + let mut c = sqlx::AnyConnection::connect(&db_url).await?; + let row = sqlx::query( + "SELECT + 123.456 as one_value, + 1 as two_values, + 2 as two_values, + 'x' as three_values, + 'y' as three_values, + 'z' as three_values + ", + ) + .fetch_one(&mut c) + .await?; + expect_json_object_equal( + &row_to_json(&row), + &serde_json::json!({ + "one_value": 123.456, + "two_values": [1,2], + "three_values": ["x","y","z"], + }), + ); + Ok(()) + } + + #[actix_web::test] + async fn test_postgres_types() -> anyhow::Result<()> { + let Some(db_url) = db_specific_test("postgres") else { + return Ok(()); + }; + let mut c = sqlx::AnyConnection::connect(&db_url).await?; + let row = sqlx::query( + "SELECT + 42::INT2 as small_int, + 42::INT4 as integer, + 42::INT8 as big_int, + 42.25::FLOAT4 as float4, + 42.25::FLOAT8 as float8, + 123456789123456789123456789::NUMERIC as numeric, + TRUE as boolean, + '2024-03-14'::DATE as date, + '13:14:15'::TIME as time, + '2024-03-14 13:14:15'::TIMESTAMP as timestamp, + '2024-03-14 13:14:15+02:00'::TIMESTAMPTZ as timestamptz, + INTERVAL '1 year 2 months 3 days' as complex_interval, + INTERVAL '4 hours' as hour_interval, + INTERVAL '1.5 days' as fractional_interval, + '{\"key\": \"value\"}'::JSON as json, + '{\"key\": \"value\"}'::JSONB as jsonb, + age('2024-03-14'::timestamp, '2024-01-01'::timestamp) as age_interval, + justify_interval(interval '1 year 2 months 3 days') as justified_interval, + 1234.56::MONEY as money_val, + '\\x68656c6c6f20776f726c64'::BYTEA as blob_data, + '550e8400-e29b-41d4-a716-446655440000'::UUID as uuid, + '[1,5)'::INT4RANGE as int4range, + '[1,5]'::INT8RANGE as int8range, + '[1.5,4.5)'::NUMRANGE as numrange, + -- '[2024-11-12 01:02:03,2024-11-12 23:00:00)'::TSRANGE as tsrange, + -- '[2024-11-12 01:02:03+01:00,2024-11-12 23:00:00+00:00)'::TSTZRANGE as tstzrange, + '[2024-11-12,2024-11-13)'::DATERANGE as daterange + ", + ) + .fetch_one(&mut c) + .await?; + + expect_json_object_equal( + &row_to_json(&row), + &serde_json::json!({ + "small_int": 42, + "integer": 42, + "big_int": 42, + "float4": 42.25, + "float8": 42.25, + "numeric": 123_456_789_123_456_789_123_456_789_u128, + "boolean": true, + "date": "2024-03-14", + "time": "13:14:15", + "timestamp": "2024-03-14T13:14:15+00:00", + "timestamptz": "2024-03-14T11:14:15+00:00", + "complex_interval": "1 year 2 mons 3 days", + "hour_interval": "04:00:00", + "fractional_interval": "1 day 12:00:00", + "json": {"key": "value"}, + "jsonb": {"key": "value"}, + "age_interval": "2 mons 13 days", + "justified_interval": "1 year 2 mons 3 days", + "money_val": "$1,234.56", + "blob_data": "data:application/octet-stream;base64,aGVsbG8gd29ybGQ=", + "uuid": "550e8400-e29b-41d4-a716-446655440000", + "int4range": "[1,5)", + "int8range": "[1,6)", + "numrange": "[1.5,4.5)", + //"tsrange": "[2024-11-12 01:02:03,2024-11-12 23:00:00)", // todo: bug in sqlx datetime range parsing + //"tstzrange": "[\"2024-11-12 02:00:00 +01:00\",\"2024-11-12 23:00:00 +00:00\")", // todo: tz info is lost in sqlx + "daterange": "[2024-11-12,2024-11-13)" + }), + ); + Ok(()) + } + + /// Postgres encodes values differently in prepared statements and in "simple" queries + /// + #[actix_web::test] + async fn test_postgres_prepared_types() -> anyhow::Result<()> { + let Some(db_url) = db_specific_test("postgres") else { + return Ok(()); + }; + let mut c = sqlx::AnyConnection::connect(&db_url).await?; + let row = sqlx::query( + "SELECT + '2024-03-14'::DATE as date, + '13:14:15'::TIME as time, + '2024-03-14 13:14:15+02:00'::TIMESTAMPTZ as timestamptz, + INTERVAL '-01:02:03' as time_interval, + '{\"key\": \"value\"}'::JSON as json, + 1234.56::MONEY as money_val, + '\\x74657374'::BYTEA as blob_data, + '550e8400-e29b-41d4-a716-446655440000'::UUID as uuid + where $1", + ) + .bind(true) + .fetch_one(&mut c) + .await?; + + expect_json_object_equal( + &row_to_json(&row), + &serde_json::json!({ + "date": "2024-03-14", + "time": "13:14:15", + "timestamptz": "2024-03-14T11:14:15+00:00", + "time_interval": "-01:02:03", + "json": {"key": "value"}, + "money_val": "", // TODO: fix this bug: https://github.com/sqlpage/SQLPage/issues/983 + "blob_data": "data:application/octet-stream;base64,dGVzdA==", + "uuid": "550e8400-e29b-41d4-a716-446655440000", + }), + ); + Ok(()) + } + + #[actix_web::test] + async fn test_postgres_prepared_range_types() -> anyhow::Result<()> { + let Some(db_url) = db_specific_test("postgres") else { + return Ok(()); + }; + let mut c = sqlx::AnyConnection::connect(&db_url).await?; + let row = sqlx::query( + "SELECT + '[1,5)'::INT4RANGE as int4range, + '[2024-11-12 01:02:03,2024-11-12 23:00:00)'::TSRANGE as tsrange, + '[2024-11-12 01:02:03+01:00,2024-11-12 23:00:00+00:00)'::TSTZRANGE as tstzrange, + '[2024-11-12,2024-11-13)'::DATERANGE as daterange + where $1", + ) + .bind(true) + .fetch_one(&mut c) + .await?; + + expect_json_object_equal( + &row_to_json(&row), + &serde_json::json!({ + "int4range": "[1,5)", + "tsrange": "[2024-11-12 01:02:03,2024-11-12 23:00:00)", + "tstzrange": "[2024-11-12 00:02:03 +00:00,2024-11-12 23:00:00 +00:00)", // todo: tz info is lost in sqlx + "daterange": "[2024-11-12,2024-11-13)" + }), + ); + Ok(()) + } + + #[actix_web::test] + async fn test_mysql_types() -> anyhow::Result<()> { + let db_url = db_specific_test("mysql").or_else(|| db_specific_test("mariadb")); + let Some(db_url) = db_url else { + return Ok(()); + }; + let mut c = sqlx::AnyConnection::connect(&db_url).await?; + + sqlx::query( + "CREATE TEMPORARY TABLE _sqlp_t ( + tiny_int TINYINT, + small_int SMALLINT, + medium_int MEDIUMINT, + signed_int INTEGER, + big_int BIGINT, + unsigned_int INTEGER UNSIGNED, + tiny_int_unsigned TINYINT UNSIGNED, + small_int_unsigned SMALLINT UNSIGNED, + medium_int_unsigned MEDIUMINT UNSIGNED, + big_int_unsigned BIGINT UNSIGNED, + decimal_num DECIMAL(10,2), + float_num FLOAT, + double_num DOUBLE, + bit_val BIT(1), + date_val DATE, + time_val TIME, + datetime_val DATETIME, + timestamp_val TIMESTAMP, + year_val YEAR, + char_val CHAR(10), + varchar_val VARCHAR(50), + text_val TEXT, + blob_val BLOB + ) AS + SELECT + 127 as tiny_int, + 32767 as small_int, + 8388607 as medium_int, + -1000000 as signed_int, + 9223372036854775807 as big_int, + 1000000 as unsigned_int, + 255 as tiny_int_unsigned, + 65535 as small_int_unsigned, + 16777215 as medium_int_unsigned, + 18446744073709551615 as big_int_unsigned, + 123.45 as decimal_num, + 42.25 as float_num, + 42.25 as double_num, + 1 as bit_val, + '2024-03-14' as date_val, + '13:14:15' as time_val, + '2024-03-14 13:14:15' as datetime_val, + '2024-03-14 13:14:15' as timestamp_val, + 2024 as year_val, + 'CHAR' as char_val, + 'VARCHAR' as varchar_val, + 'TEXT' as text_val, + x'626c6f62' as blob_val", + ) + .execute(&mut c) + .await?; + + let row = sqlx::query("SELECT * FROM _sqlp_t") + .fetch_one(&mut c) + .await?; + + expect_json_object_equal( + &row_to_json(&row), + &serde_json::json!({ + "tiny_int": 127, + "small_int": 32767, + "medium_int": 8_388_607, + "signed_int": -1_000_000, + "big_int": 9_223_372_036_854_775_807_u64, + "unsigned_int": 1_000_000, + "tiny_int_unsigned": 255, + "small_int_unsigned": 65_535, + "medium_int_unsigned": 16_777_215, + "big_int_unsigned": 18_446_744_073_709_551_615_u64, + "decimal_num": 123.45, + "float_num": 42.25, + "double_num": 42.25, + "bit_val": true, + "date_val": "2024-03-14", + "time_val": "13:14:15", + "datetime_val": "2024-03-14T13:14:15", + "timestamp_val": "2024-03-14T13:14:15+00:00", + "year_val": 2024, + "char_val": "CHAR", + "varchar_val": "VARCHAR", + "text_val": "TEXT", + "blob_val": "data:application/octet-stream;base64,YmxvYg==" + }), + ); + + sqlx::query("DROP TABLE _sqlp_t").execute(&mut c).await?; + + Ok(()) + } + + #[actix_web::test] + async fn test_sqlite_types() -> anyhow::Result<()> { + let Some(db_url) = db_specific_test("sqlite") else { + return Ok(()); + }; + let mut c = sqlx::AnyConnection::connect(&db_url).await?; + let row = sqlx::query( + "SELECT + 42 as integer, + 42.25 as real, + 'xxx' as string, + x'68656c6c6f20776f726c64' as blob", + ) + .fetch_one(&mut c) + .await?; + + expect_json_object_equal( + &row_to_json(&row), + &serde_json::json!({ + "integer": 42, + "real": 42.25, + "string": "xxx", + "blob": "data:application/octet-stream;base64,aGVsbG8gd29ybGQ=", + }), + ); + Ok(()) + } + + #[actix_web::test] + async fn test_mssql_types() -> anyhow::Result<()> { + let Some(db_url) = db_specific_test("mssql") else { + return Ok(()); + }; + let mut c = sqlx::AnyConnection::connect(&db_url).await?; + let row = sqlx::query( + "SELECT + CAST(1 AS BIT) as true_bit, + CAST(0 AS BIT) as false_bit, + CAST(NULL AS BIT) as null_bit, + CAST(255 AS TINYINT) as tiny_int, + CAST(42 AS SMALLINT) as small_int, + CAST(42 AS INT) as integer, + CAST(42 AS BIGINT) as big_int, + CAST(42.25 AS REAL) as real, + CAST(42.25 AS FLOAT) as float, + CAST(42.25 AS DECIMAL(10,2)) as decimal, + CAST('2024-03-14' AS DATE) as date, + CAST('13:14:15' AS TIME) as time, + CAST('2024-03-14 13:14:15' AS DATETIME) as datetime, + CAST('2024-03-14 13:14:15' AS DATETIME2) as datetime2, + CAST('2024-03-14 13:14:15 +02:00' AS DATETIMEOFFSET) as datetimeoffset, + N'Unicode String' as nvarchar, + 'ASCII String' as varchar, + CAST(1234.56 AS MONEY) as money_val, + CAST(12.34 AS SMALLMONEY) as small_money_val, + CAST(0x6D7373716C AS VARBINARY(10)) as blob_data, + CONVERT(UNIQUEIDENTIFIER, '6F9619FF-8B86-D011-B42D-00C04FC964FF') as unique_identifier + " + ) + .fetch_one(&mut c) + .await?; + + expect_json_object_equal( + &row_to_json(&row), + &serde_json::json!({ + "true_bit": true, + "false_bit": false, + "null_bit": null, + "tiny_int": 255, + "small_int": 42, + "integer": 42, + "big_int": 42, + "real": 42.25, + "float": 42.25, + "decimal": 42.25, + "date": "2024-03-14", + "time": "13:14:15", + "datetime": "2024-03-14T13:14:15", + "datetime2": "2024-03-14T13:14:15", + "datetimeoffset": "2024-03-14T13:14:15+02:00", + "nvarchar": "Unicode String", + "varchar": "ASCII String", + "money_val": 1234.56, + "small_money_val": 12.34, + "blob_data": "data:application/octet-stream;base64,bXNzcWw=", + "unique_identifier": "6f9619ff-8b86-d011-b42d-00c04fc964ff" + }), + ); + Ok(()) + } + + fn expect_json_object_equal(actual: &Value, expected: &Value) { + use std::fmt::Write; + + if json_values_equal(actual, expected) { + return; + } + let actual = actual.as_object().unwrap(); + let expected = expected.as_object().unwrap(); + + let all_keys: std::collections::BTreeSet<_> = + actual.keys().chain(expected.keys()).collect(); + let max_key_len = all_keys.iter().map(|k| k.len()).max().unwrap_or(0); + + let mut comparison_string = String::new(); + for key in all_keys { + let actual_value = actual.get(key).unwrap_or(&Value::Null); + let expected_value = expected.get(key).unwrap_or(&Value::Null); + if json_values_equal(actual_value, expected_value) { + continue; + } + writeln!( + &mut comparison_string, + "{key: anyhow::Result<()> { + let db_url = test_database_url(); + let mut c = sqlx::AnyConnection::connect(&db_url).await?; + + // Test various column name formats to ensure canonical_col_name works correctly + let row = sqlx::query( + r#"SELECT + 42 as "UPPERCASE_COL", + 42 as "lowercase_col", + 42 as "Mixed_Case_Col", + 42 as "COL_WITH_123_NUMBERS", + 42 as "col-with-dashes", + 42 as "col with spaces", + 42 as "_UNDERSCORE_PREFIX", + 42 as "123_NUMBER_PREFIX" + "#, + ) + .fetch_one(&mut c) + .await?; + + let json_result = row_to_json(&row); + + // For ODBC databases, uppercase columns should be converted to lowercase + // For other databases, names should remain as-is + let expected_json = if c.kind() == sqlx::any::AnyKind::Odbc { + // ODBC database - uppercase should be converted to lowercase + serde_json::json!({ + "uppercase_col": 42, + "lowercase_col": 42, + "Mixed_Case_Col": 42, + "COL_WITH_123_NUMBERS": 42, + "col-with-dashes": 42, + "col with spaces": 42, + "_underscore_prefix": 42, + "123_NUMBER_PREFIX": 42 + }) + } else { + // Non-ODBC database - names remain as-is + serde_json::json!({ + "UPPERCASE_COL": 42, + "lowercase_col": 42, + "Mixed_Case_Col": 42, + "COL_WITH_123_NUMBERS": 42, + "col-with-dashes": 42, + "col with spaces": 42, + "_UNDERSCORE_PREFIX": 42, + "123_NUMBER_PREFIX": 42 + }) + }; + + expect_json_object_equal(&json_result, &expected_json); + + Ok(()) + } + + #[actix_web::test] + async fn test_row_to_json_edge_cases() -> anyhow::Result<()> { + let db_url = test_database_url(); + let mut c = sqlx::AnyConnection::connect(&db_url).await?; + let dbms_name = c.dbms_name().await.expect("retrieve db name"); + + // Test edge cases for row_to_json + let row = sqlx::query( + "SELECT + NULL as null_col, + '' as empty_string, + 0 as zero_value, + -42 as negative_int, + 1.23456 as my_float, + 'special_chars_!@#$%^&*()' as special_chars, + 'line1 +line2' as multiline_string + ", + ) + .fetch_one(&mut c) + .await?; + + let json_result = row_to_json(&row); + + // For Oracle databases, empty string is treated as NULL. + let empty_str_is_null = dbms_name.to_lowercase().contains("oracle"); + + let expected_json = serde_json::json!({ + "null_col": null, + "empty_string": if empty_str_is_null { serde_json::Value::Null } else { serde_json::Value::String(String::new()) }, + "zero_value": 0, + "negative_int": -42, + "my_float": 1.23456, + "special_chars": "special_chars_!@#$%^&*()", + "multiline_string": "line1\nline2" + }); + + expect_json_object_equal(&json_result, &expected_json); + + Ok(()) + } + + /// Compare JSON values, treating integers and floats that are numerically equal as equal + fn json_values_equal(a: &Value, b: &Value) -> bool { + use Value::*; + + match (a, b) { + (Null, Null) => true, + (Bool(a), Bool(b)) => a == b, + (Number(a), Number(b)) => { + // Treat integers and floats as equal if they represent the same numerical value + a.as_f64() == b.as_f64() + } + (String(a), String(b)) => a == b, + (Array(a), Array(b)) => { + a.len() == b.len() && a.iter().zip(b.iter()).all(|(a, b)| json_values_equal(a, b)) + } + (Object(a), Object(b)) => { + if a.len() != b.len() { + return false; + } + a.iter().all(|(key, value)| { + b.get(key) + .is_some_and(|expected_value| json_values_equal(value, expected_value)) + }) + } + _ => false, + } + } +} diff --git a/src/webserver/database/sqlpage_functions/README.md b/src/webserver/database/sqlpage_functions/README.md new file mode 100644 index 0000000..0b01d12 --- /dev/null +++ b/src/webserver/database/sqlpage_functions/README.md @@ -0,0 +1,35 @@ +# Built-in SQL functions + +Each built-in `sqlpage.*` function is a plain `async fn` in its own file in +[`functions/`](functions). The file stem is the SQL function name and must match the Rust function it +exports: + +```rust +use std::borrow::Cow; + +use crate::webserver::http_request_info::RequestInfo; + +pub(super) async fn example(request: &RequestInfo, value: Option>) -> Option { + // ... +} +``` + +To add `sqlpage.example`, create `functions/example.rs` and add it to the +[`sqlpage_functions!`](function_traits.rs) call in [`functions.rs`](functions.rs): + +```rust +sqlpage_functions! { + // ... + example, +} +``` + +The [`sqlpage_functions!`](function_traits.rs) macro declares the modules and generates the +`SqlPageFunctionName` enum the SQL engine dispatches on. Per-function argument extraction, dispatch, +and return-value conversion are handled generically in [`function_traits.rs`](function_traits.rs) by +the `Extract`, `Handler`, and `IntoCowResult` traits. A function's argument and return types are read +straight from its signature, so supported argument types are the types that implement `Extract` there. +Functions can take up to five arguments. + +Keep helpers and unit tests that are specific to a function in that function's file. Shared helpers can +be made `pub(super)` and imported by name from sibling function modules. diff --git a/src/webserver/database/sqlpage_functions/function_traits.rs b/src/webserver/database/sqlpage_functions/function_traits.rs new file mode 100644 index 0000000..fc3c282 --- /dev/null +++ b/src/webserver/database/sqlpage_functions/function_traits.rs @@ -0,0 +1,264 @@ +//! Dispatch machinery for the built-in `sqlpage.*` SQL functions. +//! +//! Each function is a plain `async fn` in its own module under [`functions/`](super::functions). +//! [`sqlpage_functions!`] turns the module list in [`functions`](super::functions) into the +//! [`SqlPageFunctionName`](super::functions::SqlPageFunctionName) enum the engine dispatches on. +//! Adapting each signature to the uniform +//! `(request, db, args) -> Option` convention is done generically by [`Extract`] (per +//! argument type), [`Handler`] (per argument count, the trick `axum` uses) and [`IntoCowResult`] +//! (per return type); the macro itself carries no type-level glue. + +use std::borrow::Cow; +use std::future::Future; + +use anyhow::Context as _; + +use super::http_fetch_request::HttpFetchRequest; +use crate::webserver::database::execute_queries::DbConn; +use crate::webserver::http_request_info::{ExecutionContext, RequestInfo}; + +/// Renders a SQL argument as it would appear in a query, for error messages. +fn as_sql(param: Option>) -> String { + param.map_or_else(|| "NULL".into(), |x| format!("'{}'", x.replace('\'', "''"))) +} + +/// The request, optional database connection, and evaluated SQL arguments a function call works on. +pub(crate) struct FunctionContext<'a, 'c> { + request: &'a ExecutionContext, + db: Option<&'c mut DbConn>, + arguments: std::vec::IntoIter>>, +} + +impl<'a, 'c> FunctionContext<'a, 'c> { + pub(crate) fn new( + request: &'a ExecutionContext, + db_connection: &'c mut DbConn, + arguments: Vec>>, + ) -> Self { + Self { + request, + db: Some(db_connection), + arguments: arguments.into_iter(), + } + } + + /// The next argument, treating both a missing and an explicit `NULL` argument as `None`. + fn next_arg(&mut self) -> Option> { + self.arguments.next().flatten() + } + + fn next_required(&mut self) -> anyhow::Result> { + self.next_arg() + .ok_or_else(|| anyhow::anyhow!("Unexpected NULL value")) + } + + fn expect_no_extra_args(&mut self) -> anyhow::Result<()> { + match self.arguments.next() { + None => Ok(()), + Some(extra) => anyhow::bail!( + "Too many arguments. Remove extra argument {}", + as_sql(extra) + ), + } + } +} + +/// Obtains one function argument from the context. Implemented per concrete argument type, so the +/// context is only borrowed briefly and the extracted values can coexist while the function runs. +pub(crate) trait Extract<'a, 'c>: Sized { + fn extract(ctx: &mut FunctionContext<'a, 'c>) -> anyhow::Result; +} + +impl<'a, 'c> Extract<'a, 'c> for &'a RequestInfo { + fn extract(ctx: &mut FunctionContext<'a, 'c>) -> anyhow::Result { + Ok(ctx.request.into()) + } +} + +impl<'a, 'c> Extract<'a, 'c> for &'a ExecutionContext { + fn extract(ctx: &mut FunctionContext<'a, 'c>) -> anyhow::Result { + Ok(ctx.request) + } +} + +impl<'a, 'c> Extract<'a, 'c> for &'c mut DbConn { + fn extract(ctx: &mut FunctionContext<'a, 'c>) -> anyhow::Result { + ctx.db + .take() + .context("This function cannot be called in this context (no database connection)") + } +} + +impl<'a, 'c> Extract<'a, 'c> for Cow<'a, str> { + fn extract(ctx: &mut FunctionContext<'a, 'c>) -> anyhow::Result { + ctx.next_required() + } +} + +impl<'a, 'c> Extract<'a, 'c> for Option> { + fn extract(ctx: &mut FunctionContext<'a, 'c>) -> anyhow::Result { + Ok(ctx.next_arg()) + } +} + +impl<'a, 'c> Extract<'a, 'c> for Option { + fn extract(ctx: &mut FunctionContext<'a, 'c>) -> anyhow::Result { + Ok(ctx.next_arg().map(Cow::into_owned)) + } +} + +/// Collects the remaining arguments (dropping `NULL`s) for variadic functions. +impl<'a, 'c> Extract<'a, 'c> for Vec> { + fn extract(ctx: &mut FunctionContext<'a, 'c>) -> anyhow::Result { + Ok(ctx.arguments.by_ref().flatten().collect()) + } +} + +impl<'a, 'c> Extract<'a, 'c> for usize { + fn extract(ctx: &mut FunctionContext<'a, 'c>) -> anyhow::Result { + let arg = ctx.next_required()?; + arg.parse() + .with_context(|| format!("Unable to parse {arg:?} as a positive integer")) + } +} + +impl<'a, 'c> Extract<'a, 'c> for Option> { + fn extract(ctx: &mut FunctionContext<'a, 'c>) -> anyhow::Result { + ctx.next_arg() + .map(HttpFetchRequest::borrow_from_str) + .transpose() + } +} + +/// Like [`FromStr`](std::str::FromStr) but able to borrow from the input (see [`HttpFetchRequest`]). +pub(crate) trait BorrowFromStr<'a>: Sized { + fn borrow_from_str(s: Cow<'a, str>) -> anyhow::Result; +} + +/// Implemented for every `async fn` whose arguments all [`Extract`] and whose output +/// [`IntoCowResult`]. One `impl_handler!` line per argument count; adding an argument is one more. +pub(crate) trait Handler<'a, 'c, Args> { + fn call( + self, + ctx: FunctionContext<'a, 'c>, + ) -> impl Future>>>; +} + +macro_rules! impl_handler { + ($($arg:ident),*) => { + impl<'a, 'c, Func, Fut, Ret $(, $arg)*> Handler<'a, 'c, ($($arg,)*)> for Func + where + 'a: 'c, + Func: Fn($($arg),*) -> Fut, + Fut: Future, + Ret: IntoCowResult<'a>, + $($arg: Extract<'a, 'c>,)* + { + #[allow(non_snake_case, unused_mut)] + async fn call(self, mut ctx: FunctionContext<'a, 'c>) -> anyhow::Result>> { + $(let $arg = $arg::extract(&mut ctx)?;)* + ctx.expect_no_extra_args()?; + self($($arg),*).await.into_cow_result() + } + } + }; +} + +impl_handler!(); +impl_handler!(A0); +impl_handler!(A0, A1); +impl_handler!(A0, A1, A2); +impl_handler!(A0, A1, A2, A3); +impl_handler!(A0, A1, A2, A3, A4); + +/// Normalises a function's return value into what the SQL engine consumes. +pub(crate) trait IntoCowResult<'a> { + fn into_cow_result(self) -> anyhow::Result>>; +} + +impl<'a, T: IntoCow<'a>> IntoCowResult<'a> for anyhow::Result { + fn into_cow_result(self) -> anyhow::Result>> { + self.map(IntoCow::into_cow) + } +} + +impl<'a, T: IntoCow<'a>> IntoCowResult<'a> for T { + fn into_cow_result(self) -> anyhow::Result>> { + Ok(self.into_cow()) + } +} + +trait IntoCow<'a> { + fn into_cow(self) -> Option>; +} + +impl<'a> IntoCow<'a> for Cow<'a, str> { + fn into_cow(self) -> Option> { + Some(self) + } +} + +impl<'a> IntoCow<'a> for String { + fn into_cow(self) -> Option> { + Some(Cow::Owned(self)) + } +} + +impl<'a, 'b: 'a> IntoCow<'a> for &'b str { + fn into_cow(self) -> Option> { + Some(Cow::Borrowed(self)) + } +} + +impl<'a, T: IntoCow<'a>> IntoCow<'a> for Option { + fn into_cow(self) -> Option> { + self.and_then(IntoCow::into_cow) + } +} + +/// Declares the listed function modules and builds the [`SqlPageFunctionName`] dispatch enum from +/// them. +macro_rules! sqlpage_functions { + ($($func:ident),* $(,)?) => { + $( + mod $func; + )* + + /// One variant per built-in `sqlpage.*` function. + #[derive(Debug, PartialEq, Eq, Clone, Copy)] + #[allow(non_camel_case_types)] + pub enum SqlPageFunctionName { + $($func),* + } + + impl SqlPageFunctionName { + const ALL: &'static [Self] = &[$(Self::$func),*]; + + fn name(self) -> &'static str { + match self { + $(Self::$func => stringify!($func)),* + } + } + + pub(crate) async fn evaluate<'a, 'c>( + self, + request: &'a $crate::webserver::http_request_info::ExecutionContext, + db_connection: &'c mut $crate::webserver::database::execute_queries::DbConn, + arguments: Vec>>, + ) -> anyhow::Result>> + where + 'a: 'c, + { + use $crate::webserver::database::sqlpage_functions::function_traits::{ + FunctionContext, Handler, + }; + let ctx = FunctionContext::new(request, db_connection, arguments); + match self { + $(SqlPageFunctionName::$func => Handler::call($func::$func, ctx).await),* + } + } + } + }; +} + +pub(crate) use sqlpage_functions; diff --git a/src/webserver/database/sqlpage_functions/functions.rs b/src/webserver/database/sqlpage_functions/functions.rs new file mode 100644 index 0000000..e6709bd --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions.rs @@ -0,0 +1,83 @@ +//! Built-in `SQLPage` SQL functions. +//! +//! Every function is a plain `async fn` in its own module under [`functions/`](self). To add one, +//! create `functions/.rs` with an `async fn ` and add it to the +//! [`sqlpage_functions!`](super::function_traits::sqlpage_functions) call below. The macro declares +//! the module and adds it to the dispatch enum. Argument conversion and +//! dispatch are handled generically in [`super::function_traits`]. + +use std::fmt::Write; + +use super::function_traits::sqlpage_functions; + +sqlpage_functions! { + basic_auth_password, + basic_auth_username, + client_ip, + configuration_directory, + cookie, + current_working_directory, + environment_variable, + exec, + fetch, + fetch_with_meta, + hash_password, + header, + headers, + hmac, + link, + oidc_logout_url, + path, + persist_uploaded_file, + protocol, + random_string, + read_file_as_data_url, + read_file_as_text, + regex_match, + request_body, + request_body_base64, + request_method, + run_sql, + set_variable, + uploaded_file_mime_type, + uploaded_file_name, + uploaded_file_path, + url_encode, + user_info, + user_info_token, + variables, + version, + web_root, +} + +impl ::std::str::FromStr for SqlPageFunctionName { + type Err = anyhow::Error; + + fn from_str(name: &str) -> anyhow::Result { + SqlPageFunctionName::ALL + .iter() + .copied() + .find(|function| function.name() == name) + .ok_or_else(|| { + anyhow::anyhow!( + "Unknown function {name:?}. Supported functions:\n{}", + supported_function_list() + ) + }) + } +} + +impl ::std::fmt::Display for SqlPageFunctionName { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + f.write_str("sqlpage.")?; + f.write_str(self.name()) + } +} + +fn supported_function_list() -> String { + let mut supported = String::new(); + for function in SqlPageFunctionName::ALL { + writeln!(supported, " - {function}").expect("writing to a String cannot fail"); + } + supported +} diff --git a/src/webserver/database/sqlpage_functions/functions/basic_auth_password.rs b/src/webserver/database/sqlpage_functions/functions/basic_auth_password.rs new file mode 100644 index 0000000..a73149d --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/basic_auth_password.rs @@ -0,0 +1,27 @@ +use anyhow::Context; + +use crate::webserver::{ErrorWithStatus, http_request_info::RequestInfo}; + +/// Returns the password from the HTTP basic auth header, if present. +pub(super) async fn basic_auth_password(request: &RequestInfo) -> anyhow::Result<&str> { + let password = extract_basic_auth(request)?.password().ok_or_else(|| { + anyhow::Error::new(ErrorWithStatus { + status: actix_web::http::StatusCode::UNAUTHORIZED, + }) + })?; + Ok(password) +} + +pub(super) fn extract_basic_auth( + request: &RequestInfo, +) -> anyhow::Result<&actix_web_httpauth::headers::authorization::Basic> { + request + .basic_auth + .as_ref() + .ok_or_else(|| { + anyhow::Error::new(ErrorWithStatus { + status: actix_web::http::StatusCode::UNAUTHORIZED, + }) + }) + .with_context(|| "Expected the user to be authenticated with HTTP basic auth") +} diff --git a/src/webserver/database/sqlpage_functions/functions/basic_auth_username.rs b/src/webserver/database/sqlpage_functions/functions/basic_auth_username.rs new file mode 100644 index 0000000..cb37013 --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/basic_auth_username.rs @@ -0,0 +1,9 @@ +use crate::webserver::http_request_info::RequestInfo; + +use super::basic_auth_password::extract_basic_auth; + +/// Returns the username from the HTTP basic auth header, if present. +/// Otherwise, returns an HTTP 401 Unauthorized error. +pub(super) async fn basic_auth_username(request: &RequestInfo) -> anyhow::Result<&str> { + Ok(extract_basic_auth(request)?.user_id()) +} diff --git a/src/webserver/database/sqlpage_functions/functions/client_ip.rs b/src/webserver/database/sqlpage_functions/functions/client_ip.rs new file mode 100644 index 0000000..8dcf23d --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/client_ip.rs @@ -0,0 +1,5 @@ +use crate::webserver::http_request_info::RequestInfo; + +pub(super) async fn client_ip(request: &RequestInfo) -> Option { + Some(request.client_ip?.to_string()) +} diff --git a/src/webserver/database/sqlpage_functions/functions/configuration_directory.rs b/src/webserver/database/sqlpage_functions/functions/configuration_directory.rs new file mode 100644 index 0000000..20cb5d1 --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/configuration_directory.rs @@ -0,0 +1,11 @@ +use crate::webserver::http_request_info::RequestInfo; + +/// Returns the directory where the sqlpage.json configuration file, templates, and migrations are located. +pub(super) async fn configuration_directory(request: &RequestInfo) -> String { + request + .app_state + .config + .configuration_directory + .to_string_lossy() + .into_owned() +} diff --git a/src/webserver/database/sqlpage_functions/functions/cookie.rs b/src/webserver/database/sqlpage_functions/functions/cookie.rs new file mode 100644 index 0000000..5b0a210 --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/cookie.rs @@ -0,0 +1,7 @@ +use std::borrow::Cow; + +use crate::webserver::{http_request_info::RequestInfo, single_or_vec::SingleOrVec}; + +pub(super) async fn cookie<'a>(request: &'a RequestInfo, name: Cow<'a, str>) -> Option> { + request.cookies.get(&*name).map(SingleOrVec::as_json_str) +} diff --git a/src/webserver/database/sqlpage_functions/functions/current_working_directory.rs b/src/webserver/database/sqlpage_functions/functions/current_working_directory.rs new file mode 100644 index 0000000..13016f8 --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/current_working_directory.rs @@ -0,0 +1,7 @@ +use anyhow::Context; + +pub(super) async fn current_working_directory() -> anyhow::Result { + std::env::current_dir() + .with_context(|| "unable to access the current working directory") + .map(|x| x.to_string_lossy().into_owned()) +} diff --git a/src/webserver/database/sqlpage_functions/functions/environment_variable.rs b/src/webserver/database/sqlpage_functions/functions/environment_variable.rs new file mode 100644 index 0000000..4a94e91 --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/environment_variable.rs @@ -0,0 +1,17 @@ +use std::borrow::Cow; + +use anyhow::Context; + +/// Returns the value of an environment variable. +pub(super) async fn environment_variable(name: Cow<'_, str>) -> anyhow::Result>> { + match std::env::var(&*name) { + Ok(value) => Ok(Some(Cow::Owned(value))), + Err(std::env::VarError::NotPresent) if name.contains(['=', '\0']) => anyhow::bail!( + "Invalid environment variable name: {name:?}. Environment variable names cannot contain an equals sign or a null character." + ), + Err(std::env::VarError::NotPresent) => Ok(None), + Err(err) => { + Err(err).with_context(|| format!("unable to read the environment variable {name:?}")) + } + } +} diff --git a/src/webserver/database/sqlpage_functions/functions/exec.rs b/src/webserver/database/sqlpage_functions/functions/exec.rs new file mode 100644 index 0000000..f109d1d --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/exec.rs @@ -0,0 +1,46 @@ +use std::borrow::Cow; + +use anyhow::Context; +use tracing::Instrument; + +use crate::webserver::http_request_info::RequestInfo; + +/// Executes an external command and returns its output. +pub(super) async fn exec<'a>( + request: &'a RequestInfo, + program_name: Cow<'a, str>, + args: Vec>, +) -> anyhow::Result { + if !request.app_state.config.allow_exec { + anyhow::bail!("The sqlpage.exec() function is disabled in the configuration, for security reasons. + Make sure you understand the security implications before enabling it, and never allow user input to be passed as the first argument to this function. + You can enable it by setting the allow_exec option to true in the sqlpage.json configuration file.") + } + let exec_span = tracing::info_span!( + "subprocess", + otel.name = format!("EXEC {program_name}"), + process.command = %program_name, + process.args_count = args.len(), + ); + let res = tokio::process::Command::new(&*program_name) + .args(args.iter().map(|x| &**x)) + .output() + .instrument(exec_span) + .await + .with_context(|| { + let mut s = format!("Unable to execute command: {program_name}"); + for arg in args { + s.push(' '); + s.push_str(&arg); + } + s + })?; + if !res.status.success() { + anyhow::bail!( + "Command '{program_name}' failed with exit code {}: {}", + res.status, + String::from_utf8_lossy(&res.stderr) + ); + } + Ok(String::from_utf8_lossy(&res.stdout).into_owned()) +} diff --git a/src/webserver/database/sqlpage_functions/functions/fetch.rs b/src/webserver/database/sqlpage_functions/functions/fetch.rs new file mode 100644 index 0000000..7494575 --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/fetch.rs @@ -0,0 +1,177 @@ +use std::{fmt::Write, str::FromStr}; + +use anyhow::{Context, anyhow}; +use opentelemetry_semantic_conventions::attribute as otel; +use tracing::Instrument; + +use crate::webserver::{ + database::sqlpage_functions::http_fetch_request::HttpFetchRequest, + http_client::make_http_client, + http_request_info::RequestInfo, +}; + +pub(super) fn build_request<'a>( + client: &'a awc::Client, + http_request: &'a HttpFetchRequest<'_>, +) -> anyhow::Result { + use awc::http::Method; + let method = if let Some(method) = &http_request.method { + Method::from_str(method).with_context(|| format!("Invalid HTTP method: {method}"))? + } else { + Method::GET + }; + let mut req = client.request(method, http_request.url.as_ref()); + if let Some(timeout) = http_request.timeout_ms { + req = req.timeout(core::time::Duration::from_millis(timeout)); + } + for (k, v) in &http_request.headers { + req = req.insert_header((k.as_ref(), v.as_ref())); + } + if let Some(username) = &http_request.username { + let password = http_request.password.as_deref().unwrap_or_default(); + req = req.basic_auth(username, password); + } + Ok(req) +} + +pub(super) fn prepare_request_body( + body: &serde_json::value::RawValue, + mut req: awc::ClientRequest, +) -> anyhow::Result<(String, awc::ClientRequest)> { + let val = body.get(); + let body_str = if val.starts_with('"') { + serde_json::from_str::<'_, String>(val).with_context(|| { + format!("Invalid JSON string in the body of the HTTP request: {val}") + })? + } else { + req = req.content_type("application/json"); + val.to_owned() + }; + Ok((body_str, req)) +} + +pub(super) fn fetch_span(http_request: &HttpFetchRequest<'_>) -> tracing::Span { + let method = http_request.method.as_deref().unwrap_or("GET"); + tracing::info_span!( + "http.client", + "otel.name" = format!("{method}"), + { otel::HTTP_REQUEST_METHOD } = method, + { otel::URL_FULL } = %http_request.url, + { otel::HTTP_REQUEST_BODY_SIZE } = tracing::field::Empty, + { otel::HTTP_RESPONSE_STATUS_CODE } = tracing::field::Empty, + ) +} + +pub(super) fn send_request( + request: &RequestInfo, + http_request: &HttpFetchRequest<'_>, +) -> anyhow::Result { + let client = make_http_client(&request.app_state.config) + .with_context(|| "Unable to create an HTTP client")?; + let req = build_request(&client, http_request)?; + + log::info!("Fetching {}", http_request.url); + if let Some(body) = &http_request.body { + let (body, req) = prepare_request_body(body, req)?; + tracing::Span::current().record( + otel::HTTP_REQUEST_BODY_SIZE, + i64::try_from(body.len()).unwrap_or(i64::MAX), + ); + Ok(req.send_body(body)) + } else { + Ok(req.send()) + } +} + +pub(super) async fn fetch( + request: &RequestInfo, + http_request: Option>, +) -> anyhow::Result> { + let Some(http_request) = http_request else { + return Ok(None); + }; + let fetch_span = fetch_span(&http_request); + + async { + let response_result = send_request(request, &http_request)?.await; + let mut response = response_result + .map_err(|e| anyhow!("Unable to fetch {}: {e}", http_request.url))?; + + tracing::Span::current().record( + otel::HTTP_RESPONSE_STATUS_CODE, + i64::from(response.status().as_u16()), + ); + + log::debug!( + "Finished fetching {}. Status: {}", + http_request.url, + response.status() + ); + log::debug!( + "Fetch response headers for {}: content_type={:?}", + http_request.url, + response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + ); + + let body = response + .body() + .await + .with_context(|| { + format!( + "Unable to read the body of the response from {}", + http_request.url + ) + })? + .to_vec(); + log::debug!( + "Fetched {} response body: body_len={} bytes, encoding={:?}", + http_request.url, + body.len(), + http_request.response_encoding + ); + let response_str = decode_response(body, http_request.response_encoding.as_deref())?; + Ok(Some(response_str)) + } + .instrument(fetch_span) + .await +} + +pub(super) fn decode_response(response: Vec, encoding: Option<&str>) -> anyhow::Result { + match encoding { + Some("base64") => Ok(base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + response, + )), + Some("base64url") => Ok(base64::Engine::encode( + &base64::engine::general_purpose::URL_SAFE, + response, + )), + Some("hex") => Ok(response.into_iter().fold(String::new(), |mut acc, byte| { + write!(&mut acc, "{byte:02x}").unwrap(); + acc + })), + Some(encoding_label) => Ok(encoding_rs::Encoding::for_label(encoding_label.as_bytes()) + .with_context(|| format!("Invalid encoding name: {encoding_label}"))? + .decode(&response) + .0 + .into_owned()), + None => { + let body_str = String::from_utf8(response); + match body_str { + Ok(body_str) => Ok(body_str), + Err(decoding_error) => { + log::warn!( + "fetch(...) response is not UTF-8 and no encoding was specified. Decoding the response as base64. Please explicitly set the encoding to \"base64\" if this is the expected behavior." + ); + Ok(base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + decoding_error.into_bytes(), + )) + } + } + } + } +} diff --git a/src/webserver/database/sqlpage_functions/functions/fetch_with_meta.rs b/src/webserver/database/sqlpage_functions/functions/fetch_with_meta.rs new file mode 100644 index 0000000..8cb2908 --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/fetch_with_meta.rs @@ -0,0 +1,94 @@ +use opentelemetry_semantic_conventions::attribute as otel; +use tracing::Instrument; + +use crate::webserver::{ + database::sqlpage_functions::http_fetch_request::HttpFetchRequest, + http_request_info::RequestInfo, +}; + +use super::fetch::{decode_response, fetch_span, send_request}; + +pub(super) async fn fetch_with_meta( + request: &RequestInfo, + http_request: Option>, +) -> anyhow::Result> { + use serde::{Serializer, ser::SerializeMap}; + + let Some(http_request) = http_request else { + return Ok(None); + }; + + let fetch_span = fetch_span(&http_request); + + async { + let response_result = send_request(request, &http_request)?.await; + + let mut resp_str = Vec::new(); + let mut encoder = serde_json::Serializer::new(&mut resp_str); + let mut obj = encoder.serialize_map(Some(3))?; + match response_result { + Ok(mut response) => { + let status = response.status(); + tracing::Span::current() + .record(otel::HTTP_RESPONSE_STATUS_CODE, i64::from(status.as_u16())); + obj.serialize_entry("status", &status.as_u16())?; + let mut has_error = false; + if status.is_server_error() { + has_error = true; + obj.serialize_entry("error", &format!("Server error: {status}"))?; + } + + let headers = response.headers(); + + let is_json = headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or_default() + .starts_with("application/json"); + + obj.serialize_entry( + "headers", + &headers + .iter() + .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or_default())) + .collect::>(), + )?; + + match response.body().await { + Ok(body) => { + let body_bytes = body.to_vec(); + let body_str = + decode_response(body_bytes, http_request.response_encoding.as_deref())?; + if is_json { + obj.serialize_entry( + "json_body", + &serde_json::value::RawValue::from_string(body_str)?, + )?; + } else { + obj.serialize_entry("body", &body_str)?; + } + } + Err(e) => { + log::warn!("Failed to read response body: {e}"); + if !has_error { + obj.serialize_entry( + "error", + &format!("Failed to read response body: {e}"), + )?; + } + } + } + } + Err(e) => { + log::warn!("Request failed: {e}"); + obj.serialize_entry("error", &format!("Request failed: {e}"))?; + } + } + + obj.end()?; + let return_value = String::from_utf8(resp_str)?; + Ok(Some(return_value)) + } + .instrument(fetch_span) + .await +} diff --git a/src/webserver/database/sqlpage_functions/functions/hash_password.rs b/src/webserver/database/sqlpage_functions/functions/hash_password.rs new file mode 100644 index 0000000..6494565 --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/hash_password.rs @@ -0,0 +1,28 @@ +use anyhow::anyhow; + +pub(super) async fn hash_password(password: Option) -> anyhow::Result> { + let Some(password) = password else { + return Ok(None); + }; + actix_web::rt::task::spawn_blocking(move || { + // Hashes a password using Argon2. This is a CPU-intensive blocking operation. + let phf = argon2::Argon2::default(); + let salt = argon2::password_hash::SaltString::generate( + &mut argon2::password_hash::rand_core::OsRng, + ); + let password_hash = &argon2::password_hash::PasswordHash::generate(phf, password, &salt) + .map_err(|e| anyhow!("Unable to hash password: {e}"))?; + Ok(password_hash.to_string()) + }) + .await? + .map(Some) +} + +#[tokio::test] +pub(super) async fn test_hash_password() { + let s = hash_password(Some("password".to_string())) + .await + .unwrap() + .unwrap(); + assert!(s.starts_with("$argon2")); +} diff --git a/src/webserver/database/sqlpage_functions/functions/header.rs b/src/webserver/database/sqlpage_functions/functions/header.rs new file mode 100644 index 0000000..a1be99b --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/header.rs @@ -0,0 +1,11 @@ +use std::borrow::Cow; + +use crate::webserver::{http_request_info::RequestInfo, single_or_vec::SingleOrVec}; + +pub(super) async fn header<'a>(request: &'a RequestInfo, name: Cow<'a, str>) -> Option> { + let lower_name = name.to_ascii_lowercase(); + request + .headers + .get(&lower_name) + .map(SingleOrVec::as_json_str) +} diff --git a/src/webserver/database/sqlpage_functions/functions/headers.rs b/src/webserver/database/sqlpage_functions/functions/headers.rs new file mode 100644 index 0000000..062141b --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/headers.rs @@ -0,0 +1,5 @@ +use crate::webserver::http_request_info::RequestInfo; + +pub(super) async fn headers(request: &RequestInfo) -> String { + serde_json::to_string(&request.headers).unwrap_or_default() +} diff --git a/src/webserver/database/sqlpage_functions/functions/hmac.rs b/src/webserver/database/sqlpage_functions/functions/hmac.rs new file mode 100644 index 0000000..b277158 --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/hmac.rs @@ -0,0 +1,76 @@ +use std::{borrow::Cow, fmt::Write}; + +use anyhow::anyhow; + +/// Computes the HMAC (Hash-based Message Authentication Code) of the input data +/// using the specified key and hashing algorithm. +pub(super) async fn hmac<'a>( + data: Cow<'a, str>, + key: Cow<'a, str>, + algorithm: Option>, +) -> anyhow::Result> { + use ::hmac::{Hmac, KeyInit, Mac}; + use sha2::{Sha256, Sha512}; + + let algorithm = algorithm.as_deref().unwrap_or("sha256"); + + // Parse algorithm and output format (e.g., "sha256" or "sha256-base64") + let (hash_algo, output_format) = if let Some((algo, format)) = algorithm.split_once('-') { + (algo, format) + } else { + (algorithm, "hex") + }; + + let result = match hash_algo.to_lowercase().as_str() { + "sha256" => { + let mut mac = Hmac::::new_from_slice(key.as_bytes()) + .map_err(|e| anyhow!("Invalid HMAC key: {e}"))?; + mac.update(data.as_bytes()); + mac.finalize().into_bytes().to_vec() + } + "sha512" => { + let mut mac = Hmac::::new_from_slice(key.as_bytes()) + .map_err(|e| anyhow!("Invalid HMAC key: {e}"))?; + mac.update(data.as_bytes()); + mac.finalize().into_bytes().to_vec() + } + _ => { + anyhow::bail!( + "Unsupported HMAC algorithm: {hash_algo}. Supported algorithms: sha256, sha512" + ) + } + }; + + // Convert to requested output format + let output = match output_format.to_lowercase().as_str() { + "hex" => result.into_iter().fold(String::new(), |mut acc, byte| { + write!(&mut acc, "{byte:02x}").unwrap(); + acc + }), + "base64" => base64::Engine::encode(&base64::engine::general_purpose::STANDARD, result), + _ => { + anyhow::bail!( + "Unsupported output format: {output_format}. Supported formats: hex, base64" + ) + } + }; + + Ok(Some(output)) +} + +#[tokio::test] +pub(super) async fn test_hmac() { + // Test vector from RFC 4231 - HMAC-SHA256 + let result = hmac( + Cow::Borrowed("The quick brown fox jumps over the lazy dog"), + Cow::Borrowed("key"), + Some(Cow::Borrowed("sha256")), + ) + .await + .unwrap() + .unwrap(); + assert_eq!( + result, + "f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8" + ); +} diff --git a/src/webserver/database/sqlpage_functions/functions/link.rs b/src/webserver/database/sqlpage_functions/functions/link.rs new file mode 100644 index 0000000..f6bb280 --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/link.rs @@ -0,0 +1,26 @@ +use std::borrow::Cow; + +use anyhow::Context; + +use crate::webserver::database::sqlpage_functions::url_parameters::URLParameters; + +/// Builds a URL from a file name and a JSON object conatining URL parameters. +/// For instance, if the file is "index.sql" and the parameters are {"x": "hello world"}, +/// the result will be "index.sql?x=hello%20world". +pub(super) async fn link<'a>( + file: Cow<'a, str>, + parameters: Option>, + hash: Option>, +) -> anyhow::Result { + let mut url = file.into_owned(); + if let Some(parameters) = parameters { + let encoded = serde_json::from_str::(¶meters) + .with_context(|| format!("sqlpage.link: {parameters:?} is not a valid JSON object. The URL parameters should be passed as a json object with parameter names as keys."))?; + encoded.append_to_path(&mut url); + } + if let Some(hash) = hash { + url.push('#'); + url.push_str(&hash); + } + Ok(url) +} diff --git a/src/webserver/database/sqlpage_functions/functions/oidc_logout_url.rs b/src/webserver/database/sqlpage_functions/functions/oidc_logout_url.rs new file mode 100644 index 0000000..c690f08 --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/oidc_logout_url.rs @@ -0,0 +1,36 @@ +use std::borrow::Cow; + +use crate::webserver::{http_request_info::RequestInfo, single_or_vec::SingleOrVec}; + +pub(super) async fn oidc_logout_url<'a>( + request: &'a RequestInfo, + redirect_uri: Option>, +) -> anyhow::Result> { + let Some(oidc_state) = &request.app_state.oidc_state else { + return Ok(None); + }; + + let redirect_uri = redirect_uri.as_deref().unwrap_or("/"); + + if !crate::webserver::oidc::is_safe_relative_redirect(redirect_uri) { + anyhow::bail!( + "oidc_logout_url: redirect_uri must be a relative path starting with a single '/'. Got: {redirect_uri}" + ); + } + + // Bind the logout URL to the current session so that it can only log out + // the browser it was generated for, never a different user's session. Use + // the first cookie value, matching how verification reads the auth cookie + // (HttpRequest::cookie returns the first cookie of that name); signing a + // JSON array of duplicate cookies here would never match verification. + let session_token = request + .cookies + .get("sqlpage_auth") + .map(SingleOrVec::first_str); + + let logout_url = oidc_state + .config + .create_logout_url(redirect_uri, session_token); + + Ok(Some(logout_url)) +} diff --git a/src/webserver/database/sqlpage_functions/functions/path.rs b/src/webserver/database/sqlpage_functions/functions/path.rs new file mode 100644 index 0000000..c58e7fd --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/path.rs @@ -0,0 +1,6 @@ +use crate::webserver::http_request_info::RequestInfo; + +/// Returns the path component of the URL of the current request. +pub(super) async fn path(request: &RequestInfo) -> &str { + &request.path +} diff --git a/src/webserver/database/sqlpage_functions/functions/persist_uploaded_file.rs b/src/webserver/database/sqlpage_functions/functions/persist_uploaded_file.rs new file mode 100644 index 0000000..ddc766a --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/persist_uploaded_file.rs @@ -0,0 +1,92 @@ +use std::borrow::Cow; + +use anyhow::Context; + +use crate::webserver::http_request_info::RequestInfo; + +use super::random_string::random_string_sync; + +const DEFAULT_ALLOWED_EXTENSIONS: &str = + "jpg,jpeg,png,gif,bmp,webp,pdf,txt,doc,docx,xls,xlsx,csv,mp3,mp4,wav,avi,mov"; + +pub(super) async fn persist_uploaded_file<'a>( + request: &'a RequestInfo, + field_name: Cow<'a, str>, + folder: Option>, + allowed_extensions: Option>, + mode: Option>, +) -> anyhow::Result> { + let folder = folder.unwrap_or(Cow::Borrowed("uploads")); + let allowed_extensions_str = + allowed_extensions.unwrap_or(Cow::Borrowed(DEFAULT_ALLOWED_EXTENSIONS)); + let allowed_extensions = allowed_extensions_str.split(','); + let Some(uploaded_file) = request.uploaded_files.get(&field_name.to_string()) else { + return Ok(None); + }; + let file_name = uploaded_file.file_name.as_deref().unwrap_or_default(); + let extension = file_name.split('.').next_back().unwrap_or_default(); + if !allowed_extensions + .clone() + .any(|x| x.eq_ignore_ascii_case(extension)) + { + let exts = allowed_extensions.collect::>().join(", "); + anyhow::bail!("file extension {extension} is not allowed. Allowed extensions: {exts}"); + } + // Resolve the folder path relative to the web root. + // `folder` is trusted application input: it is expected to be a constant chosen by the + // app author in their SQL code, never attacker-controlled request data. It is joined + // directly to the web root, so a `folder` containing `..` or an absolute path would let + // the caller write the uploaded file outside the web root. Callers must not pass + // untrusted input (form fields, query parameters, headers, ...) as the folder. + let web_root = &request.app_state.config.web_root; + let target_folder = web_root.join(&*folder); + // create the folder if it doesn't exist + tokio::fs::create_dir_all(&target_folder) + .await + .with_context(|| format!("unable to create folder {}", target_folder.display()))?; + let date = chrono::Utc::now().format("%Y-%m-%d_%Hh%Mm%Ss"); + let random_part = random_string_sync(8); + let random_target_name = format!("{date}_{random_part}.{extension}"); + let target_path = target_folder.join(&random_target_name); + tokio::fs::copy(&uploaded_file.file.path(), &target_path) + .await + .with_context(|| { + format!( + "unable to copy uploaded file {field_name:?} to \"{}\"", + target_path.display() + ) + })?; + set_file_mode(&target_path, mode.as_deref()).await?; + // remove the WEB_ROOT prefix from the path, but keep the leading slash + let path = "/".to_string() + + target_path + .strip_prefix(web_root)? + .to_str() + .with_context(|| { + format!( + "unable to convert path \"{}\" to a string", + target_path.display() + ) + })?; + Ok(Some(path)) +} + +#[cfg(unix)] +pub(super) async fn set_file_mode(path: &std::path::Path, mode: Option<&str>) -> anyhow::Result<()> { + use std::os::unix::fs::PermissionsExt; + let mode = if let Some(mode) = mode { + u32::from_str_radix(mode, 8) + .with_context(|| format!("unable to parse file mode {mode:?} as an octal number"))? + } else { + 0o600 + }; + tokio::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)) + .await + .with_context(|| format!("unable to set permissions on {}", path.display()))?; + Ok(()) +} + +#[cfg(not(unix))] +pub(super) async fn set_file_mode(_path: &std::path::Path, _mode: Option<&str>) -> anyhow::Result<()> { + Ok(()) +} diff --git a/src/webserver/database/sqlpage_functions/functions/protocol.rs b/src/webserver/database/sqlpage_functions/functions/protocol.rs new file mode 100644 index 0000000..7fec3eb --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/protocol.rs @@ -0,0 +1,6 @@ +use crate::webserver::http_request_info::RequestInfo; + +/// Returns the protocol of the current request (http or https). +pub(super) async fn protocol(request: &RequestInfo) -> &str { + &request.protocol +} diff --git a/src/webserver/database/sqlpage_functions/functions/random_string.rs b/src/webserver/database/sqlpage_functions/functions/random_string.rs new file mode 100644 index 0000000..4446d34 --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/random_string.rs @@ -0,0 +1,22 @@ + +/// Returns a random string of the specified length. +pub(super) async fn random_string(len: usize) -> anyhow::Result { + // OsRng can block on Linux, so we run this on a blocking thread. + Ok(tokio::task::spawn_blocking(move || random_string_sync(len)).await?) +} + +/// Returns a random string of the specified length. +pub(crate) fn random_string_sync(len: usize) -> String { + use rand::{RngExt, distr::Alphanumeric}; + rand::rng() + .sample_iter(&Alphanumeric) + .take(len) + .map(char::from) + .collect() +} + +#[tokio::test] +pub(super) async fn test_random_string() { + let s = random_string(10).await.unwrap(); + assert_eq!(s.len(), 10); +} diff --git a/src/webserver/database/sqlpage_functions/functions/read_file_as_data_url.rs b/src/webserver/database/sqlpage_functions/functions/read_file_as_data_url.rs new file mode 100644 index 0000000..5645842 --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/read_file_as_data_url.rs @@ -0,0 +1,47 @@ +use std::borrow::Cow; + +use anyhow::Context; + +use crate::{ + filesystem::FileAccess, + webserver::{ + database::blob_to_data_url::vec_to_data_uri_with_mime, + http_request_info::RequestInfo, + }, +}; + +use super::uploaded_file_mime_type::{mime_from_upload_path, mime_guess_from_filename}; + +pub(super) async fn read_file_bytes(request: &RequestInfo, path_str: &str) -> Result, anyhow::Error> { + let path = std::path::Path::new(path_str); + // If the path is relative, it's relative to the web root, not the current working directory, + // and it can be fetched from the on-database filesystem table + if path.is_relative() { + request + .app_state + .file_system + .read_file(&request.app_state, FileAccess::privileged(path)) + .await + } else { + tokio::fs::read(path) + .await + .with_context(|| format!("Unable to read file \"{}\"", path.display())) + } +} + +pub(super) async fn read_file_as_data_url<'a>( + request: &'a RequestInfo, + file_path: Option>, +) -> Result>, anyhow::Error> { + let Some(file_path) = file_path else { + log::debug!("read_file: first argument is NULL, returning NULL"); + return Ok(None); + }; + let bytes = read_file_bytes(request, &file_path).await?; + let mime = mime_from_upload_path(request, &file_path).map_or_else( + || Cow::Owned(mime_guess_from_filename(&file_path)), + Cow::Borrowed, + ); + let data_url = vec_to_data_uri_with_mime(&bytes, &mime.to_string()); + Ok(Some(Cow::Owned(data_url))) +} diff --git a/src/webserver/database/sqlpage_functions/functions/read_file_as_text.rs b/src/webserver/database/sqlpage_functions/functions/read_file_as_text.rs new file mode 100644 index 0000000..7b44adb --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/read_file_as_text.rs @@ -0,0 +1,23 @@ +use std::borrow::Cow; + +use anyhow::Context; + +use crate::webserver::http_request_info::RequestInfo; + +use super::read_file_as_data_url::read_file_bytes; + +/// Returns the contents of a file as a string +pub(super) async fn read_file_as_text<'a>( + request: &'a RequestInfo, + file_path: Option>, +) -> Result>, anyhow::Error> { + let Some(file_path) = file_path else { + log::debug!("read_file: first argument is NULL, returning NULL"); + return Ok(None); + }; + let bytes = read_file_bytes(request, &file_path).await?; + let as_str = String::from_utf8(bytes).with_context(|| { + format!("read_file_as_text: {file_path} does not contain raw UTF8 text") + })?; + Ok(Some(Cow::Owned(as_str))) +} diff --git a/src/webserver/database/sqlpage_functions/functions/regex_match.rs b/src/webserver/database/sqlpage_functions/functions/regex_match.rs new file mode 100644 index 0000000..fc504d2 --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/regex_match.rs @@ -0,0 +1,50 @@ +use std::borrow::Cow; + +/// Returns a string containing a JSON-encoded match object, or `null` if no match was found. +/// The match object contains one key per capture group, with the value being the matched text. +/// For named capture groups (`(?pattern)`), the key is the name. +/// For unnamed capture groups (`(pattern)`), the key is the index of the capture group as a string. +pub(super) async fn regex_match<'a>( + pattern: Cow<'a, str>, + text: Option>, +) -> Result, anyhow::Error> { + use serde::{Serializer, ser::SerializeMap}; + let regex = regex::Regex::new(&pattern)?; + let Some(text) = text else { + return Ok(None); + }; + let Some(match_obj) = regex.captures(&text) else { + return Ok(None); + }; + let mut result = Vec::with_capacity(64); + let mut ser = serde_json::Serializer::new(&mut result); + let mut map = ser.serialize_map(Some(match_obj.len()))?; + for (idx, maybe_name) in regex.capture_names().enumerate() { + if let Some(match_group) = match_obj.get(idx) { + if let Some(name) = maybe_name { + map.serialize_entry(name, match_group.as_str())?; + } else { + let key = idx.to_string(); + map.serialize_entry(&key, match_group.as_str())?; + } + } + } + map.end()?; + Ok(Some(String::from_utf8(result)?)) +} + +#[tokio::test] +pub(super) async fn regex_match_serializes_named_and_unnamed_groups() { + use std::borrow::Cow; + let result = regex_match( + Cow::Borrowed(r"(?foo)(bar)"), + Some(Cow::Borrowed("_foobar_")), + ) + .await + .unwrap(); + + assert_eq!( + result.as_deref(), + Some(r#"{"0":"foobar","word":"foo","2":"bar"}"#) + ); +} diff --git a/src/webserver/database/sqlpage_functions/functions/request_body.rs b/src/webserver/database/sqlpage_functions/functions/request_body.rs new file mode 100644 index 0000000..1c74c77 --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/request_body.rs @@ -0,0 +1,10 @@ +use crate::webserver::http_request_info::RequestInfo; + +/// Returns the raw request body as a string. +/// If the request body is not valid UTF-8, invalid characters are replaced with the Unicode replacement character. +/// Returns NULL if there is no request body or if the request content type is +/// application/x-www-form-urlencoded or multipart/form-data (in this case, the body is accessible via the `post_variables` field). +pub(super) async fn request_body(request: &RequestInfo) -> Option { + let raw_body = request.raw_body.as_ref()?; + Some(String::from_utf8_lossy(raw_body).to_string()) +} diff --git a/src/webserver/database/sqlpage_functions/functions/request_body_base64.rs b/src/webserver/database/sqlpage_functions/functions/request_body_base64.rs new file mode 100644 index 0000000..2555caf --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/request_body_base64.rs @@ -0,0 +1,15 @@ +use crate::webserver::http_request_info::RequestInfo; + +/// Returns the raw request body encoded in base64. +/// Returns NULL if there is no request body or if the request content type is +/// application/x-www-form-urlencoded or multipart/form-data (in this case, the body is accessible via the `post_variables` field). +pub(super) async fn request_body_base64(request: &RequestInfo) -> Option { + let raw_body = request.raw_body.as_ref()?; + let mut base64_string = String::with_capacity((raw_body.len() * 4).div_ceil(3)); + base64::Engine::encode_string( + &base64::engine::general_purpose::STANDARD, + raw_body, + &mut base64_string, + ); + Some(base64_string) +} diff --git a/src/webserver/database/sqlpage_functions/functions/request_method.rs b/src/webserver/database/sqlpage_functions/functions/request_method.rs new file mode 100644 index 0000000..04229c8 --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/request_method.rs @@ -0,0 +1,5 @@ +use crate::webserver::http_request_info::RequestInfo; + +pub(super) async fn request_method(request: &RequestInfo) -> String { + request.method.to_string() +} diff --git a/src/webserver/database/sqlpage_functions/functions/run_sql.rs b/src/webserver/database/sqlpage_functions/functions/run_sql.rs new file mode 100644 index 0000000..6de31f5 --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/run_sql.rs @@ -0,0 +1,84 @@ +use std::borrow::Cow; + +use anyhow::Context; +use futures_util::StreamExt; +use tracing::Instrument; + +use crate::{ + filesystem::FileAccess, + webserver::{ + database::execute_queries::DbConn, http_request_info::ExecutionContext, + request_variables::SetVariablesMap, + }, +}; + +pub(super) async fn run_sql<'a>( + request: &'a ExecutionContext, + db_connection: &mut DbConn, + sql_file_path: Option>, + variables: Option>, +) -> anyhow::Result>> { + use serde::ser::{SerializeSeq, Serializer}; + let Some(sql_file_path) = sql_file_path else { + log::debug!("run_sql: first argument is NULL, returning NULL"); + return Ok(None); + }; + let run_sql_span = tracing::info_span!( + "sqlpage.file", + otel.name = format!("SQL {sql_file_path}"), + code.file.path = %sql_file_path, + ); + let app_state = &request.app_state; + let sql_file = app_state + .sql_file_cache + .get( + app_state, + FileAccess::privileged(std::path::Path::new(sql_file_path.as_ref())), + ) + .instrument(run_sql_span.clone()) + .await + .with_context(|| format!("run_sql: invalid path {sql_file_path:?}"))?; + let tmp_req = if let Some(variables) = variables { + let variables: SetVariablesMap = serde_json::from_str(&variables).with_context(|| { + format!("run_sql(\'{sql_file_path}\', \'{variables}\'): the second argument should be a JSON object with string keys and values") + })?; + request.fork_with_variables(variables) + } else { + request.fork() + }; + let max_recursion_depth = app_state.config.max_recursion_depth; + if tmp_req.clone_depth > max_recursion_depth { + anyhow::bail!( + "Too many nested inclusions. run_sql can include a file that includes another file, but the depth is limited to {max_recursion_depth} levels. \n\ + Executing sqlpage.run_sql('{sql_file_path}') would exceed this limit. \n\ + This is to prevent infinite loops and stack overflows.\n\ + Make sure that your SQL file does not try to run itself, directly or through a chain of other files.\n\ + If you need to include more files, you can increase max_recursion_depth in the configuration file.\ + " + ); + } + let mut results_stream = + crate::webserver::database::execute_queries::stream_query_results_boxed( + &sql_file, + &tmp_req, + db_connection, + ); + let mut json_results_bytes = Vec::new(); + let mut json_encoder = serde_json::Serializer::new(&mut json_results_bytes); + let mut seq = json_encoder.serialize_seq(None)?; + while let Some(db_item) = results_stream.next().instrument(run_sql_span.clone()).await { + use crate::webserver::database::DbItem::{Error, FinishedQuery, Row}; + match db_item { + Row(row) => { + log::debug!("run_sql: row: {row:?}"); + seq.serialize_element(&row)?; + } + FinishedQuery => log::trace!("run_sql: Finished query"), + Error(err) => { + return Err(err.context(format!("run_sql: unable to run {sql_file_path:?}"))); + } + } + } + seq.end()?; + Ok(Some(Cow::Owned(String::from_utf8(json_results_bytes)?))) +} diff --git a/src/webserver/database/sqlpage_functions/functions/set_variable.rs b/src/webserver/database/sqlpage_functions/functions/set_variable.rs new file mode 100644 index 0000000..ee33c74 --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/set_variable.rs @@ -0,0 +1,28 @@ +use std::borrow::Cow; + +use crate::webserver::{ + database::sqlpage_functions::url_parameters::URLParameters, + http_request_info::ExecutionContext, + single_or_vec::SingleOrVec, +}; + +pub(super) async fn set_variable<'a>( + context: &'a ExecutionContext, + name: Cow<'a, str>, + value: Option>, +) -> anyhow::Result { + let mut params = URLParameters::new(); + + for (k, v) in &context.url_params { + if k == &name { + continue; + } + params.push_single_or_vec(k, v.clone()); + } + + if let Some(value) = value { + params.push_single_or_vec(&name, SingleOrVec::Single(value.into_owned())); + } + + Ok(params.with_empty_path()) +} diff --git a/src/webserver/database/sqlpage_functions/functions/uploaded_file_mime_type.rs b/src/webserver/database/sqlpage_functions/functions/uploaded_file_mime_type.rs new file mode 100644 index 0000000..2105773 --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/uploaded_file_mime_type.rs @@ -0,0 +1,32 @@ +use std::{borrow::Cow, ffi::OsStr}; + +use mime_guess::mime; + +use crate::webserver::http_request_info::RequestInfo; + +pub(super) fn mime_from_upload_path<'a>(request: &'a RequestInfo, path: &str) -> Option<&'a mime_guess::Mime> { + request.uploaded_files.values().find_map(|uploaded_file| { + if uploaded_file.file.path() == OsStr::new(path) { + uploaded_file.content_type.as_ref() + } else { + None + } + }) +} + +pub(super) fn mime_guess_from_filename(filename: &str) -> mime_guess::Mime { + let maybe_mime = mime_guess::from_path(filename).first(); + maybe_mime.unwrap_or(mime::APPLICATION_OCTET_STREAM) +} + +pub(super) async fn uploaded_file_mime_type<'a>( + request: &'a RequestInfo, + upload_name: Cow<'a, str>, +) -> Option> { + let mime = request + .uploaded_files + .get(&*upload_name)? + .content_type + .as_ref()?; + Some(Cow::Borrowed(mime.as_ref())) +} diff --git a/src/webserver/database/sqlpage_functions/functions/uploaded_file_name.rs b/src/webserver/database/sqlpage_functions/functions/uploaded_file_name.rs new file mode 100644 index 0000000..5a09615 --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/uploaded_file_name.rs @@ -0,0 +1,15 @@ +use std::borrow::Cow; + +use crate::webserver::http_request_info::RequestInfo; + +pub(super) async fn uploaded_file_name<'a>( + request: &'a RequestInfo, + upload_name: Cow<'a, str>, +) -> Option> { + let fname = request + .uploaded_files + .get(&*upload_name)? + .file_name + .as_ref()?; + Some(Cow::Borrowed(fname.as_str())) +} diff --git a/src/webserver/database/sqlpage_functions/functions/uploaded_file_path.rs b/src/webserver/database/sqlpage_functions/functions/uploaded_file_path.rs new file mode 100644 index 0000000..a84abd8 --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/uploaded_file_path.rs @@ -0,0 +1,11 @@ +use std::borrow::Cow; + +use crate::webserver::http_request_info::RequestInfo; + +pub(super) async fn uploaded_file_path<'a>( + request: &'a RequestInfo, + upload_name: Cow<'a, str>, +) -> Option> { + let uploaded_file = request.uploaded_files.get(&*upload_name)?; + Some(uploaded_file.file.path().to_string_lossy()) +} diff --git a/src/webserver/database/sqlpage_functions/functions/url_encode.rs b/src/webserver/database/sqlpage_functions/functions/url_encode.rs new file mode 100644 index 0000000..95c6452 --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/url_encode.rs @@ -0,0 +1,25 @@ +use std::borrow::Cow; + +/// escapes a string for use in a URL using percent encoding +/// for example, spaces are replaced with %20, '/' with %2F, etc. +/// This is useful for constructing URLs in SQL queries. +/// If this function is passed a NULL value, it will return NULL (None in Rust), +/// rather than an empty string or an error. +pub(super) async fn url_encode(raw_text: Option>) -> Option> { + Some(match raw_text? { + Cow::Borrowed(inner) => { + let encoded = percent_encoding::percent_encode( + inner.as_bytes(), + percent_encoding::NON_ALPHANUMERIC, + ); + encoded.into() + } + Cow::Owned(inner) => { + let encoded = percent_encoding::percent_encode( + inner.as_bytes(), + percent_encoding::NON_ALPHANUMERIC, + ); + Cow::Owned(encoded.collect()) + } + }) +} diff --git a/src/webserver/database/sqlpage_functions/functions/user_info.rs b/src/webserver/database/sqlpage_functions/functions/user_info.rs new file mode 100644 index 0000000..7da9470 --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/user_info.rs @@ -0,0 +1,85 @@ +use std::borrow::Cow; + +use crate::webserver::http_request_info::RequestInfo; + +/// Returns a specific claim from the ID token. +pub(super) async fn user_info<'a>( + request: &'a RequestInfo, + claim: Cow<'a, str>, +) -> anyhow::Result> { + let Some(claims) = &request.oidc_claims else { + return Ok(None); + }; + + // Match against known OIDC claims accessible via direct methods. + let claim_value_str = match claim.as_ref() { + // Core Claims + "iss" => Some(claims.issuer().to_string()), + // aud requires serialization: handled separately if needed + "exp" => Some(claims.expiration().timestamp().to_string()), + "iat" => Some(claims.issue_time().timestamp().to_string()), + "sub" => Some(claims.subject().to_string()), + "auth_time" => claims.auth_time().map(|t| t.timestamp().to_string()), + "nonce" => claims.nonce().map(|n| n.secret().clone()), // Assuming Nonce has secret() + "acr" => claims.auth_context_ref().map(|acr| acr.to_string()), + // amr requires serialization: handled separately if needed + "azp" => claims.authorized_party().map(|azp| azp.to_string()), + "at_hash" => claims.access_token_hash().map(|h| h.to_string()), + "c_hash" => claims.code_hash().map(|h| h.to_string()), + + // Standard Claims (Profile Scope - subset) + "name" => claims + .name() + .and_then(|n| n.get(None)) + .map(|s| s.to_string()), + "given_name" => claims + .given_name() + .and_then(|n| n.get(None)) + .map(|s| s.to_string()), + "family_name" => claims + .family_name() + .and_then(|n| n.get(None)) + .map(|s| s.to_string()), + "middle_name" => claims + .middle_name() + .and_then(|n| n.get(None)) + .map(|s| s.to_string()), + "nickname" => claims + .nickname() + .and_then(|n| n.get(None)) + .map(|s| s.to_string()), + "preferred_username" => claims.preferred_username().map(|u| u.to_string()), + "profile" => claims + .profile() + .and_then(|n| n.get(None)) + .map(|url_claim| url_claim.as_str().to_string()), + "picture" => claims + .picture() + .and_then(|n| n.get(None)) + .map(|url_claim| url_claim.as_str().to_string()), + "website" => claims + .website() + .and_then(|n| n.get(None)) + .map(|url_claim| url_claim.as_str().to_string()), + "gender" => claims.gender().map(|g| g.to_string()), // Assumes GenderClaim impls ToString + "birthdate" => claims.birthdate().map(|b| b.to_string()), // Assumes Birthdate impls ToString + "zoneinfo" => claims.zoneinfo().map(|z| z.to_string()), // Assumes ZoneInfo impls ToString + "locale" => claims.locale().map(std::string::ToString::to_string), // Assumes Locale impls ToString + "updated_at" => claims.updated_at().map(|t| t.timestamp().to_string()), + + // Standard Claims (Email Scope) + "email" => claims.email().map(|e| e.to_string()), + "email_verified" => claims.email_verified().map(|b| b.to_string()), + + // Standard Claims (Phone Scope) + "phone_number" => claims.phone_number().map(|p| p.to_string()), + "phone_number_verified" => claims.phone_number_verified().map(|b| b.to_string()), + additional_claim => claims + .additional_claims() + .0 + .get(additional_claim) + .map(std::string::ToString::to_string), + }; + + Ok(claim_value_str) +} diff --git a/src/webserver/database/sqlpage_functions/functions/user_info_token.rs b/src/webserver/database/sqlpage_functions/functions/user_info_token.rs new file mode 100644 index 0000000..d108166 --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/user_info_token.rs @@ -0,0 +1,9 @@ +use crate::webserver::http_request_info::RequestInfo; + +/// Returns the ID token claims as a JSON object. +pub(super) async fn user_info_token(request: &RequestInfo) -> anyhow::Result> { + let Some(claims) = &request.oidc_claims else { + return Ok(None); + }; + Ok(Some(serde_json::to_string(claims)?)) +} diff --git a/src/webserver/database/sqlpage_functions/functions/variables.rs b/src/webserver/database/sqlpage_functions/functions/variables.rs new file mode 100644 index 0000000..d482b15 --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/variables.rs @@ -0,0 +1,49 @@ +use std::borrow::Cow; + +use anyhow::anyhow; + +use crate::webserver::http_request_info::ExecutionContext; + +/// Returns all variables in the request as a JSON object. +pub(super) async fn variables<'a>( + request: &'a ExecutionContext, + get_or_post: Option>, +) -> anyhow::Result { + Ok(if let Some(get_or_post) = get_or_post { + if get_or_post.eq_ignore_ascii_case("get") { + serde_json::to_string(&request.url_params)? + } else if get_or_post.eq_ignore_ascii_case("post") { + serde_json::to_string(&request.post_variables)? + } else if get_or_post.eq_ignore_ascii_case("set") { + serde_json::to_string(&*request.set_variables.borrow())? + } else { + return Err(anyhow!( + "Expected 'get', 'post', or 'set' as the argument to sqlpage.variables" + )); + } + } else { + use serde::{Serializer, ser::SerializeMap}; + let mut res = Vec::new(); + let mut serializer = serde_json::Serializer::new(&mut res); + let set_vars = request.set_variables.borrow(); + let len = request.url_params.len() + request.post_variables.len() + set_vars.len(); + let mut ser = serializer.serialize_map(Some(len))?; + let mut seen_keys = std::collections::HashSet::new(); + for (k, v) in &*set_vars { + seen_keys.insert(k); + ser.serialize_entry(k, v)?; + } + for (k, v) in &request.post_variables { + if seen_keys.insert(k) { + ser.serialize_entry(k, v)?; + } + } + for (k, v) in &request.url_params { + if seen_keys.insert(k) { + ser.serialize_entry(k, v)?; + } + } + ser.end()?; + String::from_utf8(res)? + }) +} diff --git a/src/webserver/database/sqlpage_functions/functions/version.rs b/src/webserver/database/sqlpage_functions/functions/version.rs new file mode 100644 index 0000000..6d0cde3 --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/version.rs @@ -0,0 +1,5 @@ + +/// Returns the version of the sqlpage that is running. +pub(super) async fn version() -> &'static str { + env!("CARGO_PKG_VERSION") +} diff --git a/src/webserver/database/sqlpage_functions/functions/web_root.rs b/src/webserver/database/sqlpage_functions/functions/web_root.rs new file mode 100644 index 0000000..a5a4103 --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/web_root.rs @@ -0,0 +1,11 @@ +use crate::webserver::http_request_info::RequestInfo; + +/// Returns the directory where the .sql files are located (the web root). +pub(super) async fn web_root(request: &RequestInfo) -> String { + request + .app_state + .config + .web_root + .to_string_lossy() + .into_owned() +} diff --git a/src/webserver/database/sqlpage_functions/http_fetch_request.rs b/src/webserver/database/sqlpage_functions/http_fetch_request.rs new file mode 100644 index 0000000..965342a --- /dev/null +++ b/src/webserver/database/sqlpage_functions/http_fetch_request.rs @@ -0,0 +1,112 @@ +use anyhow::Context; + +use super::function_traits::BorrowFromStr; +use std::borrow::Cow; + +type HeaderVec<'a> = Vec<(Cow<'a, str>, Cow<'a, str>)>; + +fn default_headers<'a>() -> HeaderVec<'a> { + vec![ + (Cow::Borrowed("Accept"), Cow::Borrowed("*/*")), + ( + Cow::Borrowed("User-Agent"), + Cow::Borrowed(concat!( + "SQLPage/v", + env!("CARGO_PKG_VERSION"), + " (+https://sql-page.com)" + )), + ), + ] +} + +#[derive(serde::Deserialize, Debug)] +#[serde(expecting = "an http request object, e.g. '{\"url\":\"http://example.com\"}'")] +#[serde(deny_unknown_fields)] +pub(super) struct HttpFetchRequest<'b> { + #[serde(borrow)] + pub url: Cow<'b, str>, + #[serde(borrow)] + pub method: Option>, + #[serde( + default = "default_headers", + borrow, + deserialize_with = "deserialize_map_to_vec_pairs" + )] + pub headers: HeaderVec<'b>, + pub username: Option>, + pub password: Option>, + #[serde(borrow)] + pub body: Option>, + pub timeout_ms: Option, + pub response_encoding: Option>, +} + +fn deserialize_map_to_vec_pairs<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + struct Visitor; + + impl<'de> serde::de::Visitor<'de> for Visitor { + type Value = Vec<(Cow<'de, str>, Cow<'de, str>)>; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a map") + } + + fn visit_map(self, mut map: A) -> Result + where + A: serde::de::MapAccess<'de>, + { + let mut vec = Vec::new(); + while let Some((key, value)) = map.next_entry()? { + vec.push((key, value)); + } + Ok(vec) + } + } + + deserializer.deserialize_map(Visitor) +} + +impl<'a> BorrowFromStr<'a> for HttpFetchRequest<'a> { + fn borrow_from_str(s: Cow<'a, str>) -> anyhow::Result { + Ok(if s.starts_with("http") { + HttpFetchRequest { + url: s, + method: None, + headers: default_headers(), + username: None, + password: None, + body: None, + timeout_ms: None, + response_encoding: None, + } + } else { + match s { + Cow::Borrowed(s) => serde_json::from_str(s), + Cow::Owned(ref s) => serde_json::from_str::>(s) + .map(HttpFetchRequest::into_owned), + } + .with_context(|| format!("Invalid http fetch request definition: {s}"))? + }) + } +} + +impl HttpFetchRequest<'_> { + fn into_owned(self) -> HttpFetchRequest<'static> { + HttpFetchRequest { + url: Cow::Owned(self.url.into_owned()), + method: self.method.map(Cow::into_owned).map(Cow::Owned), + headers: self + .headers + .into_iter() + .map(|(k, v)| (Cow::Owned(k.into_owned()), Cow::Owned(v.into_owned()))) + .collect(), + body: self.body.map(Cow::into_owned).map(Cow::Owned), + timeout_ms: self.timeout_ms, + username: self.username.map(Cow::into_owned).map(Cow::Owned), + password: self.password.map(Cow::into_owned).map(Cow::Owned), + response_encoding: self.response_encoding.map(Cow::into_owned).map(Cow::Owned), + } + } +} diff --git a/src/webserver/database/sqlpage_functions/mod.rs b/src/webserver/database/sqlpage_functions/mod.rs new file mode 100644 index 0000000..aea396b --- /dev/null +++ b/src/webserver/database/sqlpage_functions/mod.rs @@ -0,0 +1,21 @@ +mod function_traits; +pub(super) mod functions; +mod http_fetch_request; +mod url_parameters; + +use sqlparser::ast::FunctionArg; + +use super::sql::ParamExtractContext; +use super::syntax_tree::SqlPageFunctionCall; +use super::syntax_tree::StmtParam; + +pub(super) fn func_call_to_param( + func_name: &str, + arguments: &mut [FunctionArg], + ctx: &ParamExtractContext, +) -> StmtParam { + SqlPageFunctionCall::from_func_call(func_name, arguments, ctx).map_or_else( + |e| StmtParam::Error(format!("{e:#}")), + StmtParam::FunctionCall, + ) +} diff --git a/src/webserver/database/sqlpage_functions/url_parameters.rs b/src/webserver/database/sqlpage_functions/url_parameters.rs new file mode 100644 index 0000000..eec0ed6 --- /dev/null +++ b/src/webserver/database/sqlpage_functions/url_parameters.rs @@ -0,0 +1,203 @@ +use crate::webserver::single_or_vec::SingleOrVec; +use percent_encoding::{NON_ALPHANUMERIC, percent_encode}; +use serde::{Deserialize, Deserializer}; +use serde_json::Value; +use std::borrow::Cow; +use std::fmt; + +#[derive(Default)] +pub struct URLParameters(String); + +impl URLParameters { + pub fn new() -> Self { + Self(String::new()) + } + + fn encode_and_push(&mut self, v: &str) { + let val: Cow = percent_encode(v.as_bytes(), NON_ALPHANUMERIC).into(); + self.0.push_str(&val); + } + + fn start_new_pair(&mut self) { + let char = if self.0.is_empty() { '?' } else { '&' }; + self.0.push(char); + } + + fn push_kv(&mut self, key: &str, value: &str) { + self.start_new_pair(); + self.encode_and_push(key); + self.0.push('='); + self.encode_and_push(value); + } + + fn push_array_entry(&mut self, key: &str, value: &str) { + self.start_new_pair(); + self.encode_and_push(key); + self.0.push_str("[]="); + self.encode_and_push(value); + } + + fn push_array(&mut self, key: &str, values: Vec) { + for val in values { + let val_str = match val { + Value::String(s) => s, + other => other.to_string(), + }; + self.push_array_entry(key, &val_str); + } + } + + pub fn push_single_or_vec(&mut self, key: &str, val: SingleOrVec) { + match val { + SingleOrVec::Single(v) => self.push_kv(key, &v), + SingleOrVec::Vec(v) => { + for s in v { + self.push_array_entry(key, &s); + } + } + } + } + + fn add_from_json(&mut self, key: &str, raw_json_value: &str) { + if let Ok(str_val) = serde_json::from_str::>>(raw_json_value) { + if let Some(str_val) = str_val { + self.push_kv(key, &str_val); + } + } else if let Ok(vec_val) = serde_json::from_str::>(raw_json_value) { + self.push_array(key, vec_val); + } else { + self.push_kv(key, raw_json_value); + } + } + + pub fn with_empty_path(self) -> String { + if self.0.is_empty() { + "?".to_string() // Link to the current page without parameters + } else { + self.0 // Link to the current page with specific parameters + } + } + + pub fn append_to_path(self, url: &mut String) { + if url.is_empty() { + *url = self.with_empty_path(); + } else { + url.push_str(&self.0); + } + } +} + +impl<'de> Deserialize<'de> for URLParameters { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + // Visit an object and append keys and values to the string + struct URLParametersVisitor; + + impl<'de> serde::de::Visitor<'de> for URLParametersVisitor { + type Value = URLParameters; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a sequence") + } + + fn visit_map(self, mut map: A) -> Result + where + A: serde::de::MapAccess<'de>, + { + let mut out = URLParameters(String::new()); + while let Some((key, value)) = + map.next_entry::, Cow>()? + { + out.add_from_json(&key, value.get()); + } + + Ok(out) + } + } + + deserializer.deserialize_map(URLParametersVisitor) + } +} + +impl std::fmt::Display for URLParameters { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl From for String { + fn from(value: URLParameters) -> Self { + value.0 + } +} + +#[test] +fn test_url_parameters_deserializer() { + use serde_json::json; + let json = json!({ + "x": "hello world", + "num": 123, + "arr": [1, 2, 3], + }); + + let url_parameters: URLParameters = serde_json::from_value(json).unwrap(); + assert_eq!( + url_parameters.0, + "?x=hello%20world&num=123&arr[]=1&arr[]=2&arr[]=3" + ); +} + +#[test] +fn test_url_parameters_null() { + use serde_json::json; + let json = json!({ + "null_should_be_omitted": null, + "x": "hello", + }); + + let url_parameters: URLParameters = serde_json::from_value(json).unwrap(); + assert_eq!(url_parameters.0, "?x=hello"); +} + +#[test] +fn test_url_parameters_deserializer_special_chars() { + use serde_json::json; + let json = json!({ + "chars": ["\n", " ", "\""], + }); + + let url_parameters: URLParameters = serde_json::from_value(json).unwrap(); + assert_eq!(url_parameters.0, "?chars[]=%0A&chars[]=%20&chars[]=%22"); +} + +#[test] +fn test_url_parameters_deserializer_issue_879() { + use serde_json::json; + let json = json!({ + "name": "John Doe & Son's", + "items": [1, "item 2 & 3", true], + "special_char": "%&=+ ", + }); + + let url_parameters: URLParameters = serde_json::from_value(json).unwrap(); + assert_eq!( + url_parameters.0, + "?name=John%20Doe%20%26%20Son%27s&items[]=1&items[]=item%202%20%26%203&items[]=true&special%5Fchar=%25%26%3D%2B%20" + ); +} + +#[test] +fn test_push_single_or_vec() { + let mut params = URLParameters(String::new()); + params.push_single_or_vec("k", SingleOrVec::Single("v".to_string())); + assert_eq!(params.to_string(), "?k=v"); + + let mut params = URLParameters(String::new()); + params.push_single_or_vec( + "arr", + SingleOrVec::Vec(vec!["a".to_string(), "b".to_string()]), + ); + assert_eq!(params.to_string(), "?arr[]=a&arr[]=b"); +} diff --git a/src/webserver/database/syntax_tree.rs b/src/webserver/database/syntax_tree.rs new file mode 100644 index 0000000..31a8815 --- /dev/null +++ b/src/webserver/database/syntax_tree.rs @@ -0,0 +1,321 @@ +/// This module contains the syntax tree for sqlpage statement parameters. +/// In a query like `SELECT sqlpage.some_function($my_param)`, +/// The stored database statement will be just `SELECT $1`, +/// and the `StmtParam` will contain a the following tree: +/// +/// ```text +/// StmtParam::FunctionCall( +/// SqlPageFunctionCall { +/// function: SqlPageFunctionName::some_function, +/// arguments: vec![StmtParam::Get("$my_param")] +/// } +/// ) +/// ``` +use std::borrow::Cow; +use std::str::FromStr; + +use sqlparser::ast::FunctionArg; + +use crate::webserver::http_request_info::ExecutionContext; +use crate::webserver::single_or_vec::SingleOrVec; + +use super::{ + execute_queries::DbConn, sql::ParamExtractContext, sql::function_args_to_stmt_params, + sqlpage_functions::functions::SqlPageFunctionName, +}; +use anyhow::Context as _; + +/// Represents a parameter to a SQL statement. +/// Objects of this type are created during SQL parsing. +/// Every time a SQL statement is executed, the parameters are evaluated to produce the actual values that are passed to the database. +/// Parameter evaluation can involve asynchronous operations, and extracting values from the request. +#[derive(Debug, PartialEq, Eq, Clone)] +pub(crate) enum StmtParam { + Get(String), + Post(String), + PostOrGet(String), + Error(String), + Literal(String), + Null, + Concat(Vec), + Coalesce(Vec), + JsonObject(Vec), + JsonArray(Vec), + FunctionCall(SqlPageFunctionCall), +} + +impl std::fmt::Display for StmtParam { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + StmtParam::Get(name) => write!(f, "?{name}"), + StmtParam::Post(name) => write!(f, ":{name}"), + StmtParam::PostOrGet(name) => write!(f, "${name}"), + StmtParam::Literal(x) => write!(f, "'{}'", x.replace('\'', "''")), + StmtParam::Null => write!(f, "NULL"), + StmtParam::Concat(items) => { + write!(f, "CONCAT(")?; + for item in items { + write!(f, "{item}, ")?; + } + write!(f, ")") + } + StmtParam::Coalesce(items) => { + write!(f, "COALESCE(")?; + for item in items { + write!(f, "{item}, ")?; + } + write!(f, ")") + } + StmtParam::JsonObject(items) => { + write!(f, "JSON_OBJECT(")?; + for item in items { + write!(f, "{item}, ")?; + } + write!(f, ")") + } + StmtParam::JsonArray(items) => { + write!(f, "JSON_ARRAY(")?; + for item in items { + write!(f, "{item}, ")?; + } + write!(f, ")") + } + StmtParam::FunctionCall(call) => write!(f, "{call}"), + StmtParam::Error(x) => { + if let Some((i, _)) = x.char_indices().nth(21) { + write!(f, "## {}... ##", &x[..i]) + } else { + write!(f, "## {x} ##") + } + } + } + } +} + +/// Represents a call to a `sqlpage.` function. +/// Objects of this type are created during SQL parsing and used to evaluate the function at runtime. +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct SqlPageFunctionCall { + pub function: SqlPageFunctionName, + pub arguments: Vec, +} + +impl SqlPageFunctionCall { + pub fn from_func_call( + func_name: &str, + arguments: &mut [FunctionArg], + ctx: &ParamExtractContext, + ) -> anyhow::Result { + let function = SqlPageFunctionName::from_str(func_name)?; + let arguments = function_args_to_stmt_params(arguments, ctx)?; + Ok(Self { + function, + arguments, + }) + } + + pub async fn evaluate<'a>( + &self, + request: &'a ExecutionContext, + db_connection: &mut DbConn, + ) -> anyhow::Result>> { + let mut params = Vec::with_capacity(self.arguments.len()); + for param in &self.arguments { + params.push(Box::pin(extract_req_param(param, request, db_connection)).await?); + } + log::trace!("Starting function call to {self}"); + let result = self + .function + .evaluate(request, db_connection, params) + .await?; + log::trace!( + "Function call to {self} returned: {}", + result.as_deref().unwrap_or("NULL") + ); + Ok(result) + } +} + +impl std::fmt::Display for SqlPageFunctionCall { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "{}(", self.function)?; + // interleave the arguments with commas + let mut it = self.arguments.iter(); + if let Some(x) = it.next() { + write!(f, "{x}")?; + } + for x in it { + write!(f, ", {x}")?; + } + write!(f, ")") + } +} + +/// Extracts the value of a parameter from the request. +/// Returns `Ok(None)` when NULL should be used as the parameter value. +pub(super) async fn extract_req_param<'a>( + param: &StmtParam, + request: &'a ExecutionContext, + db_connection: &mut DbConn, +) -> anyhow::Result>> { + Ok(match param { + // sync functions + StmtParam::Get(x) => request.url_params.get(x).map(SingleOrVec::as_json_str), + StmtParam::Post(x) => { + if let Some(val) = request.set_variables.borrow().get(x) { + val.as_ref() + .map(|v| Cow::Owned(v.as_json_str().into_owned())) + } else { + request.post_variables.get(x).map(SingleOrVec::as_json_str) + } + } + StmtParam::PostOrGet(x) => { + if let Some(val) = request.set_variables.borrow().get(x) { + val.as_ref() + .map(|v| Cow::Owned(v.as_json_str().into_owned())) + } else { + let url_val = request.url_params.get(x); + if request.post_variables.contains_key(x) { + if url_val.is_some() { + log::warn!( + "Deprecation warning! There is both a URL parameter named '{x}' and a form field named '{x}'. \ + SQLPage is using the URL parameter for ${x}. Please use :{x} to reference the form field explicitly." + ); + } else { + log::warn!( + "Deprecation warning! ${x} was used to reference a form field value (a POST variable). \ + This now uses only URL parameters. Please use :{x} instead." + ); + } + } + url_val.map(SingleOrVec::as_json_str) + } + } + StmtParam::Error(x) => anyhow::bail!("{x}"), + StmtParam::Literal(x) => Some(Cow::Owned(x.clone())), + StmtParam::Null => None, + StmtParam::Concat(args) => concat_params(&args[..], request, db_connection).await?, + StmtParam::JsonObject(args) => { + json_object_params(&args[..], request, db_connection).await? + } + StmtParam::JsonArray(args) => json_array_params(&args[..], request, db_connection).await?, + StmtParam::Coalesce(args) => coalesce_params(&args[..], request, db_connection).await?, + StmtParam::FunctionCall(func) => { + func.evaluate(request, db_connection) + .await + .with_context(|| { + format!( + "Error in function call {func}.\nExpected {:#}", + func.function + ) + })? + } + }) +} + +async fn concat_params<'a>( + args: &[StmtParam], + request: &'a ExecutionContext, + db_connection: &mut DbConn, +) -> anyhow::Result>> { + let mut result = String::new(); + for arg in args { + let Some(arg) = Box::pin(extract_req_param(arg, request, db_connection)).await? else { + return Ok(None); + }; + result.push_str(&arg); + } + Ok(Some(Cow::Owned(result))) +} + +async fn coalesce_params<'a>( + args: &[StmtParam], + request: &'a ExecutionContext, + db_connection: &mut DbConn, +) -> anyhow::Result>> { + for arg in args { + if let Some(arg) = Box::pin(extract_req_param(arg, request, db_connection)).await? { + return Ok(Some(arg)); + } + } + Ok(None) +} + +async fn json_object_params<'a>( + args: &[StmtParam], + request: &'a ExecutionContext, + db_connection: &mut DbConn, +) -> anyhow::Result>> { + use serde::{Serializer, ser::SerializeMap}; + let mut result = Vec::new(); + let mut ser = serde_json::Serializer::new(&mut result); + let mut map_ser = ser.serialize_map(Some(args.len()))?; + let mut it = args.iter(); + while let Some(key) = it.next() { + let key = Box::pin(extract_req_param(key, request, db_connection)).await?; + map_ser.serialize_key(&key)?; + let val = it + .next() + .ok_or_else(|| anyhow::anyhow!("Odd number of arguments in JSON_OBJECT"))?; + + match val { + StmtParam::JsonObject(args) => { + let raw_json = Box::pin(json_object_params(args, request, db_connection)).await?; + let obj = cow_to_raw_json(raw_json.as_ref()); + map_ser.serialize_value(&obj)?; + } + StmtParam::JsonArray(args) => { + let raw_json = Box::pin(json_array_params(args, request, db_connection)).await?; + let obj = cow_to_raw_json(raw_json.as_ref()); + map_ser.serialize_value(&obj)?; + } + val => { + let evaluated = Box::pin(extract_req_param(val, request, db_connection)).await?; + map_ser.serialize_value(&evaluated)?; + } + } + } + map_ser.end()?; + Ok(Some(Cow::Owned(String::from_utf8(result)?))) +} + +async fn json_array_params<'a>( + args: &[StmtParam], + request: &'a ExecutionContext, + db_connection: &mut DbConn, +) -> anyhow::Result>> { + use serde::{Serializer, ser::SerializeSeq}; + let mut result = Vec::new(); + let mut ser = serde_json::Serializer::new(&mut result); + let mut seq_ser = ser.serialize_seq(Some(args.len()))?; + for element in args { + match element { + StmtParam::JsonObject(args) => { + let raw_json = json_object_params(args, request, db_connection).await?; + let obj = cow_to_raw_json(raw_json.as_ref()); + seq_ser.serialize_element(&obj)?; + } + StmtParam::JsonArray(args) => { + let raw_json = Box::pin(json_array_params(args, request, db_connection)).await?; + let obj = cow_to_raw_json(raw_json.as_ref()); + seq_ser.serialize_element(&obj)?; + } + element => { + let evaluated = + Box::pin(extract_req_param(element, request, db_connection)).await?; + seq_ser.serialize_element(&evaluated)?; + } + } + } + seq_ser.end()?; + Ok(Some(Cow::Owned(String::from_utf8(result)?))) +} + +fn cow_to_raw_json<'a>( + raw_json: Option<&'a impl AsRef>, +) -> Option<&'a serde_json::value::RawValue> { + raw_json + .map(AsRef::as_ref) + .map(serde_json::from_str::<&'a serde_json::value::RawValue>) + .map(Result::unwrap) +} diff --git a/src/webserver/error.rs b/src/webserver/error.rs new file mode 100644 index 0000000..817d708 --- /dev/null +++ b/src/webserver/error.rs @@ -0,0 +1,351 @@ +//! HTTP error handling +//! +//! This module owns the single boundary where an internal [`anyhow::Error`] +//! becomes the user-facing error representation. The [`ClientError`] type, built +//! by [`ClientError::new`], is the *only* place that consults the environment +//! (development vs production) to decide how much detail an error may expose. +//! Every output format ([`crate::render`]) renders a `ClientError` and never +//! inspects the environment itself, so no renderer can leak the SQL statement, +//! the source path, the raw database error, environment values, or +//! configuration. The full error is always logged server-side, independently of +//! what the client receives. + +use std::path::PathBuf; + +use crate::AppState; +use crate::app_config::DevOrProd; +use crate::webserver::ErrorWithStatus; +use actix_web::HttpResponseBuilder; +use actix_web::error::UrlencodedError; +use actix_web::http::{StatusCode, header}; +use actix_web::{HttpRequest, HttpResponse}; +use handlebars::{Renderable, StringOutput}; +use serde_json::{Value, json}; + +/// Generic message shown to end users in production instead of the detailed +/// error, which would leak the source file path, the SQL statement, and the +/// raw database error text. +const PRODUCTION_ERROR_MESSAGE: &str = + "Please contact the administrator for more information. The error has been logged."; + +const DEV_ERROR_NOTE: &str = "You can hide error messages like this one from your users by setting the 'environment' configuration option to 'production'."; + +/// An internal error reduced to the form that is safe to send to a client. +/// +/// This is the single boundary where a raw [`anyhow::Error`] becomes a +/// user-facing error. It is built once, from the error and the environment, by +/// [`ClientError::new`] (the only place that consults the environment). +/// Renderers (JSON, NDJSON, SSE, CSV, HTML) only ever receive a `ClientError`, +/// never the raw error, so no output format can leak the source path, the SQL +/// statement, the raw database error, environment values, or configuration: in +/// production every `ClientError` holds nothing but the generic message. The +/// full error is always logged server-side, independently of what the client +/// receives. +/// +/// Adding a new output format is therefore safe by construction: it can only +/// render the fields of `ClientError`, all of which are already +/// production-safe. +#[derive(Debug, Clone)] +pub struct ClientError { + /// One-line, client-safe message. Generic in production, detailed in + /// development. Suitable for machine formats (JSON/NDJSON/SSE/CSV). + message: String, + /// Causes chain, for the human-facing HTML backtrace. Empty in production. + backtrace: Vec, + /// The query that failed, shown to humans in development. `None` in + /// production and when not applicable. + query_number: Option, + /// Hint shown to humans in development on how to hide errors. `None` in + /// production. + note: Option<&'static str>, + /// `true` when this error was reduced to the generic production message. + /// This is the only environment-derived flag callers may branch on, so the + /// environment itself is never consulted outside [`ClientError::new`]. + is_generic: bool, +} + +impl ClientError { + /// Reduces a raw error to the client-safe form for the given environment. + /// In production, only the generic message survives; in development, the + /// full detail (message, backtrace, hint) is kept. + /// + /// This is the single location in the whole codebase that consults the + /// environment (the only call to `DevOrProd::is_prod`) to decide how much + /// of an error reaches a client. Renderers never make this decision; they + /// only forward the configured environment here and then format the result. + #[must_use] + pub fn new(error: &anyhow::Error, environment: DevOrProd, query_number: Option) -> Self { + if environment.is_prod() { + Self { + message: PRODUCTION_ERROR_MESSAGE.to_owned(), + backtrace: Vec::new(), + query_number: None, + note: None, + is_generic: true, + } + } else { + Self { + message: error.to_string(), + backtrace: get_backtrace_as_strings(error), + query_number, + note: Some(DEV_ERROR_NOTE), + is_generic: false, + } + } + } + + /// The single-line, client-safe message used by machine-readable formats + /// (JSON, NDJSON, SSE) and CSV. + #[must_use] + pub fn message(&self) -> &str { + &self.message + } + + /// Whether this error carries only the generic production message (i.e. it + /// was built in production). Lets the header path decide between rendering + /// the error inline and bubbling up to a top-level error response, without + /// itself looking at the environment. + #[must_use] + pub fn is_generic(&self) -> bool { + self.is_generic + } + + /// The data passed to the `error` HTML component. Every field is already + /// client-safe (empty/`None` in production). + #[must_use] + pub fn to_html_data(&self) -> Value { + json!({ + "query_number": self.query_number, + "description": self.message, + "backtrace": self.backtrace, + "note": self.note, + }) + } +} + +/// Collects the chain of error causes as strings, for the human-facing HTML +/// backtrace shown in development. +#[must_use] +pub(crate) fn get_backtrace_as_strings(error: &anyhow::Error) -> Vec { + let mut backtrace = vec![]; + let mut source = error.source(); + while let Some(s) = source { + backtrace.push(format!("{s}")); + source = s.source(); + } + backtrace +} + +fn error_to_html_string(app_state: &AppState, err: &anyhow::Error) -> anyhow::Result { + let mut out = StringOutput::new(); + let shell_template = app_state.all_templates.get_static_template("shell")?; + let error_template = app_state.all_templates.get_static_template("error")?; + let registry = &app_state.all_templates.handlebars; + let shell_ctx = handlebars::Context::null(); + // Reduce the error to its client-safe form before rendering. In production + // this hides the message, backtrace, and source detail behind the generic + // text shown by the `error` template. + let data = ClientError::new(err, app_state.config.environment, None).to_html_data(); + let err_ctx = handlebars::Context::wraps(data)?; + let rc = &mut handlebars::RenderContext::new(None); + + // Open the shell component + shell_template + .before_list + .render(registry, &shell_ctx, rc, &mut out)?; + + // Open the error component + error_template + .before_list + .render(registry, &err_ctx, rc, &mut out)?; + // Close the error component + error_template + .after_list + .render(registry, &err_ctx, rc, &mut out)?; + + // Close the shell component + shell_template + .after_list + .render(registry, &shell_ctx, rc, &mut out)?; + + Ok(out.into_string()?) +} + +pub(super) fn anyhow_err_to_actix_resp(e: &anyhow::Error, state: &AppState) -> HttpResponse { + let mut resp = HttpResponseBuilder::new(StatusCode::INTERNAL_SERVER_ERROR); + resp.insert_header((header::CONTENT_TYPE, header::ContentType::plaintext())); + + if let Some(status) = anyhow_error_status(e) { + resp.status(status); + if status == StatusCode::UNAUTHORIZED { + resp.append_header(( + header::WWW_AUTHENTICATE, + "Basic realm=\"Authentication required\", charset=\"UTF-8\"", + )); + } + } else if let Some(sqlx::Error::PoolTimedOut) = e.downcast_ref() { + use rand::RngExt; + resp.status(StatusCode::TOO_MANY_REQUESTS).insert_header(( + header::RETRY_AFTER, + header::HeaderValue::from(rand::rng().random_range(1..=15)), + )); + } + match error_to_html_string(state, e) { + Ok(body) => { + resp.insert_header((header::CONTENT_TYPE, header::ContentType::html())); + resp.body(body) + } + Err(second_err) => { + log::error!("Unable to render error: {e:#}"); + resp.body(format!( + "A second error occurred while rendering the error page: \n\n\ + Initial error: \n\ + {e:#}\n\n\ + Second error: \n\ + {second_err:#}" + )) + } + } +} + +fn anyhow_error_status(e: &anyhow::Error) -> Option { + if let Some(&ErrorWithStatus { status }) = e.downcast_ref() { + Some(status) + } else if let Some(sqlx::Error::PoolTimedOut) = e.downcast_ref() { + Some(StatusCode::TOO_MANY_REQUESTS) + } else { + None + } +} + +pub(super) fn send_anyhow_error( + e: &anyhow::Error, + resp_send: tokio::sync::oneshot::Sender, + state: &AppState, +) { + log::error!("An error occurred before starting to send the response body: {e:#}"); + resp_send + .send(anyhow_err_to_actix_resp(e, state)) + .unwrap_or_else(|_| log::error!("could not send headers")); +} + +pub(super) fn anyhow_err_to_actix(e: anyhow::Error, state: &AppState) -> actix_web::Error { + log::error!("{e:#}"); + let resp = anyhow_err_to_actix_resp(&e, state); + actix_web::error::InternalError::from_response(e, resp).into() +} + +pub(super) fn handle_form_error( + decode_err: UrlencodedError, + _req: &HttpRequest, +) -> actix_web::Error { + match decode_err { + actix_web::error::UrlencodedError::Overflow { size, limit } => { + actix_web::error::ErrorPayloadTooLarge(format!( + "The submitted form data size ({size} bytes) exceeds the maximum allowed upload size ({limit} bytes). \ + You can increase this limit by setting max_uploaded_file_size in the configuration file.", + )) + } + _ => actix_web::Error::from(decode_err), + } +} + +pub(super) fn bind_error(e: std::io::Error, listen_on: std::net::SocketAddr) -> anyhow::Error { + let (ip, port) = (listen_on.ip(), listen_on.port()); + // Let's try to give a more helpful error message in common cases + let ctx = match e.kind() { + std::io::ErrorKind::AddrInUse => format!( + "Another program is already using port {port} (maybe {} ?). \ + You can either stop that program or change the port in the configuration file.", + if port == 80 || port == 443 { + "Apache or Nginx" + } else { + "another instance of SQLPage" + }, + ), + std::io::ErrorKind::PermissionDenied => format!( + "You do not have permission to bind to {ip} on port {port}. \ + You can either run SQLPage as root with sudo, give it the permission to bind to low ports with `sudo setcap cap_net_bind_service=+ep {executable_path}`, \ + or change the port in the configuration file.", + executable_path = std::env::current_exe() + .unwrap_or_else(|_| PathBuf::from("sqlpage.bin")) + .display(), + ), + std::io::ErrorKind::AddrNotAvailable => format!( + "The IP address {ip} does not exist on this computer. \ + You can change the value of listen_on in the configuration file.", + ), + _ => format!("Unable to bind to {ip} on port {port}"), + }; + anyhow::anyhow!(e).context(ctx) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Structural guard for the production-leak invariant. + /// + /// Every output format renders an error solely from the fields of + /// [`ClientError`] (via [`ClientError::message`] for machine formats and + /// [`ClientError::to_html_data`] for HTML). If, in production, neither of + /// those exposes any sensitive substring of the original error, then no + /// renderer can leak, including formats added in the future. This test + /// asserts exactly that at the single boundary, so it does not need to be + /// repeated per format. + #[test] + fn test_production_client_error_hides_all_detail() { + let sensitive = anyhow::anyhow!("DB error near 'secret_table'") + .context("The SQL statement sent by SQLPage was: SELECT * FROM secret_table") + .context("Error in file /srv/www/private/admin.sql"); + + let client_error = ClientError::new(&sensitive, DevOrProd::Production, Some(3)); + + // Everything a renderer can read about the error, serialized together. + let exposed = format!( + "{}\n{}", + client_error.message(), + client_error.to_html_data() + ); + + for needle in [ + "secret_table", + "SELECT", + "/srv/www/private/admin.sql", + ".sql", + "DB error", + ] { + assert!( + !exposed.contains(needle), + "production ClientError leaked {needle:?}: {exposed}" + ); + } + assert!( + exposed.to_lowercase().contains("administrator"), + "production ClientError should carry the generic message: {exposed}" + ); + // The query number is detail too: it must not survive into production. + assert!( + !exposed.contains('3'), + "production ClientError leaked the query number: {exposed}" + ); + assert!( + client_error.is_generic(), + "a production ClientError must report itself as generic" + ); + } + + /// In development, the full detail must be preserved so authors can debug. + #[test] + fn test_development_client_error_keeps_detail() { + let error = anyhow::anyhow!("near 'secret_table': syntax error") + .context("The SQL statement sent by SQLPage was: SELECT 1"); + let client_error = ClientError::new(&error, DevOrProd::Development, Some(2)); + assert!(client_error.message().contains("SELECT 1")); + assert!(!client_error.is_generic()); + let html = client_error.to_html_data(); + assert_eq!(html["query_number"], json!(2)); + assert!(html["backtrace"].as_array().is_some_and(|b| !b.is_empty())); + assert!(html["note"].is_string()); + } +} diff --git a/src/webserver/error_with_status.rs b/src/webserver/error_with_status.rs new file mode 100644 index 0000000..d9733e7 --- /dev/null +++ b/src/webserver/error_with_status.rs @@ -0,0 +1,82 @@ +use actix_web::{ + ResponseError, + http::{ + StatusCode, + header::{self, ContentType}, + }, +}; + +#[derive(Debug, PartialEq)] +pub struct ErrorWithStatus { + pub status: StatusCode, +} +impl std::fmt::Display for ErrorWithStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.status) + } +} +impl std::error::Error for ErrorWithStatus {} + +impl ResponseError for ErrorWithStatus { + fn status_code(&self) -> StatusCode { + self.status + } + fn error_response(&self) -> actix_web::HttpResponse { + let mut resp_builder = actix_web::HttpResponse::build(self.status); + resp_builder.content_type(ContentType::plaintext()); + if self.status == StatusCode::UNAUTHORIZED { + resp_builder.insert_header(( + header::WWW_AUTHENTICATE, + header::HeaderValue::from_static( + "Basic realm=\"Authentication required\", charset=\"UTF-8\"", + ), + )); + resp_builder.body("Sorry, but you are not authorized to access this page.") + } else { + resp_builder.body(self.status.to_string()) + } + } +} + +pub trait StatusCodeResultExt { + fn with_status(self, status: StatusCode) -> anyhow::Result; + fn with_status_from(self, get_status: impl FnOnce(&E) -> StatusCode) -> anyhow::Result; + fn with_response_status(self) -> anyhow::Result + where + Self: Sized, + E: ResponseError; +} + +impl StatusCodeResultExt for Result +where + E: std::fmt::Display, +{ + fn with_status(self, status: StatusCode) -> anyhow::Result { + self.map_err(|err| anyhow::anyhow!(ErrorWithStatus { status }).context(err.to_string())) + } + + fn with_status_from(self, get_status: impl FnOnce(&E) -> StatusCode) -> anyhow::Result { + self.map_err(|err| { + let status = get_status(&err); + anyhow::anyhow!(ErrorWithStatus { status }).context(err.to_string()) + }) + } + + fn with_response_status(self) -> anyhow::Result + where + E: ResponseError, + { + self.with_status_from(ResponseError::status_code) + } +} + +pub trait ActixErrorStatusExt { + fn with_actix_error_status(self) -> anyhow::Result; +} + +impl ActixErrorStatusExt for Result { + fn with_actix_error_status(self) -> anyhow::Result { + // Snapshot the HTTP status before converting to anyhow, which does not preserve Actix's response mapping for later inspection. + self.with_status_from(|e| e.as_response_error().status_code()) + } +} diff --git a/src/webserver/http.rs b/src/webserver/http.rs new file mode 100644 index 0000000..e37410a --- /dev/null +++ b/src/webserver/http.rs @@ -0,0 +1,763 @@ +//! This module handles HTTP requests and responses for the web server, +//! including rendering SQL files, serving static content, and managing +//! request contexts and response headers. + +use crate::render::{AnyRenderBodyContext, HeaderContext, PageContext}; +use crate::webserver::ErrorWithStatus; +use crate::webserver::content_security_policy::ContentSecurityPolicy; +use crate::webserver::database::execute_queries::stop_at_first_error; +use crate::webserver::database::{DbItem, execute_queries::stream_query_results_with_conn}; +use crate::webserver::http_request_info::extract_request_info; +use crate::webserver::server_timing::ServerTiming; +use crate::{AppConfig, AppState, DEFAULT_404_FILE, ParsedSqlFile}; +use actix_web::dev::{ServiceFactory, ServiceRequest, fn_service}; +use actix_web::error::{ErrorBadRequest, ErrorInternalServerError}; +use actix_web::http::header::Accept; +use actix_web::http::header::{ContentType, Header, HttpDate, IfModifiedSince, LastModified}; +use actix_web::http::{StatusCode, header}; +use actix_web::web::PayloadConfig; +use actix_web::{App, Error, HttpResponse, HttpServer, dev::ServiceResponse, middleware, web}; +use opentelemetry_semantic_conventions::attribute as otel; +use tracing::{Instrument, Span}; +use tracing_actix_web::{DefaultRootSpanBuilder, RootSpanBuilder, TracingLogger}; + +use super::error::{anyhow_err_to_actix, bind_error, send_anyhow_error}; +use super::http_client::make_http_client; +use super::https::make_auto_rustls_config; +use super::oidc::OidcMiddleware; +use super::response_writer::ResponseWriter; +use super::static_content; +use crate::filesystem::FileAccess; +use crate::webserver::routing::RoutingAction::{ + CustomNotFound, Execute, NotFound, Redirect, Serve, +}; +use crate::webserver::routing::{AppFileStore, calculate_route}; +use actix_web::body::MessageBody; +use anyhow::{Context, bail}; +use chrono::{DateTime, Utc}; +use futures_util::StreamExt; +use futures_util::stream::Stream; +use std::borrow::Cow; +use std::path::PathBuf; +use std::pin::Pin; +use std::sync::Arc; +use std::time::SystemTime; +use tokio::sync::mpsc; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ResponseFormat { + #[default] + Html, + Json, + JsonLines, +} + +#[derive(Clone)] +pub struct RequestContext { + pub is_embedded: bool, + pub source_path: PathBuf, + pub content_security_policy: ContentSecurityPolicy, + pub server_timing: Arc, + pub response_format: ResponseFormat, +} + +impl ResponseFormat { + #[must_use] + pub fn from_accept_header(accept: &Accept) -> Self { + for quality_item in accept.iter() { + let mime = &quality_item.item; + let type_ = mime.type_().as_str(); + let subtype = mime.subtype().as_str(); + + match (type_, subtype) { + ("application", "json") => return Self::Json, + ("application", "x-ndjson" | "jsonlines" | "x-jsonlines") => { + return Self::JsonLines; + } + ("text", "x-ndjson" | "jsonlines" | "x-jsonlines") => return Self::JsonLines, + ("text", "html") | ("*", "*") => return Self::Html, + _ => {} + } + } + Self::Html + } + + #[must_use] + pub fn content_type(self) -> &'static str { + match self { + Self::Html => "text/html; charset=utf-8", + Self::Json => "application/json", + Self::JsonLines => "application/x-ndjson", + } + } +} + +async fn stream_response(stream: impl Stream, mut renderer: AnyRenderBodyContext) { + let mut stream = Box::pin(stream); + + if let Err(e) = &renderer.flush().await { + log::error!("Unable to flush initial data to client: {e}"); + return; + } + + while let Some(item) = stream.next().await { + log::trace!("Received item from database: {item:?}"); + let render_result = match item { + DbItem::FinishedQuery => renderer.finish_query().await, + DbItem::Row(row) => renderer.handle_row(&row).await, + DbItem::Error(e) => renderer.handle_error(&e).await, + }; + if let Err(e) = render_result + && let Err(nested_err) = renderer.handle_error(&e).await + { + renderer + .close() + .await + .close_with_error(nested_err.to_string()) + .await; + log::error!( + "An error occurred while trying to display an other error. \ + \nRoot error: {e}\n + \nNested error: {nested_err}" + ); + return; + } + if let Err(e) = &renderer.flush().await { + log::error!( + "Stopping rendering early because we were unable to flush data to client. \ + The user has probably closed the connection before we finished rendering the page: {e:#}" + ); + // If we cannot write to the client anymore, there is nothing we can do, so we just stop rendering + return; + } + } + if let Err(e) = &renderer.close().await.async_flush().await { + log::error!("Unable to flush data to client after rendering the page end: {e}"); + return; + } + log::debug!("Successfully finished rendering the page"); +} + +async fn build_response_header_and_stream>( + app_state: Arc, + database_entries: S, + request_context: RequestContext, +) -> anyhow::Result> { + let chan_size = app_state.config.max_pending_rows; + let (sender, receiver) = mpsc::channel(chan_size); + let writer = ResponseWriter::new(sender); + let mut head_context = HeaderContext::new(app_state, request_context, writer); + let mut stream = Box::pin(database_entries); + while let Some(item) = stream.next().await { + let page_context = match item { + DbItem::Row(data) => { + head_context.request_context.server_timing.record("row"); + head_context.handle_row(data).await? + } + DbItem::FinishedQuery => { + log::debug!("finished query"); + continue; + } + DbItem::Error(source_err) + if matches!( + source_err.downcast_ref(), + Some(&ErrorWithStatus { status: _ }) + ) => + { + return Err(source_err); + } + DbItem::Error(source_err) => head_context.handle_error(source_err).await?, + }; + match page_context { + PageContext::Header(h) => { + head_context = h; + } + PageContext::Body { + mut http_response, + renderer, + } => { + let body_stream = tokio_stream::wrappers::ReceiverStream::new(receiver); + let result_stream = body_stream.map(Ok::<_, actix_web::Error>); + let http_response = http_response.streaming(result_stream); + return Ok(ResponseWithWriter::RenderStream { + http_response, + renderer, + database_entries_stream: stream, + }); + } + PageContext::Close(http_response) => { + return Ok(ResponseWithWriter::FinishedResponse { http_response }); + } + } + } + log::debug!("No SQL statements left to execute for the body of the response"); + let http_response = head_context.close()?; + Ok(ResponseWithWriter::FinishedResponse { http_response }) +} + +#[allow(clippy::large_enum_variant)] +enum ResponseWithWriter { + RenderStream { + http_response: HttpResponse, + renderer: AnyRenderBodyContext, + database_entries_stream: Pin>, + }, + FinishedResponse { + http_response: HttpResponse, + }, +} + +async fn render_sql( + srv_req: &mut ServiceRequest, + sql_file: Arc, + server_timing: ServerTiming, +) -> actix_web::Result { + let app_state = srv_req + .app_data::>() + .ok_or_else(|| ErrorInternalServerError("no state"))? + .clone() + .into_inner(); + + let response_format = Accept::parse(srv_req) + .map(|accept| ResponseFormat::from_accept_header(&accept)) + .unwrap_or_default(); + + let exec_ctx = { + let content_type = srv_req + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + let content_length = srv_req + .headers() + .get("content-length") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse::().ok()); + let url_query = srv_req.query_string(); + let url_query = if url_query.is_empty() { + None + } else { + Some(url_query) + }; + let parse_span = tracing::info_span!( + "http.parse_request", + http.request.method = %srv_req.method(), + "http.request.header.content-type" = content_type, + http.request.body.size = content_length, + url.query = url_query, + ); + extract_request_info(srv_req, Arc::clone(&app_state), server_timing) + .instrument(parse_span) + .await + .map_err(|e| anyhow_err_to_actix(e, &app_state))? + }; + log::debug!("Received a request with the following parameters: {exec_ctx:?}"); + + exec_ctx.request().server_timing.record("parse_req"); + + let (resp_send, resp_recv) = tokio::sync::oneshot::channel::(); + let source_path: PathBuf = sql_file.source_path.clone(); + let exec_span = tracing::info_span!( + "sqlpage.file", + otel.name = %sql_execution_span_name(&source_path), + { otel::CODE_FILE_PATH } = %source_path.display(), + ); + actix_web::rt::spawn(tracing::Instrument::instrument( + async move { + let request_info = exec_ctx.request(); + let request_context = RequestContext { + is_embedded: request_info.url_params.contains_key("_sqlpage_embed"), + source_path, + content_security_policy: ContentSecurityPolicy::with_random_nonce(), + server_timing: Arc::clone(&request_info.server_timing), + response_format, + }; + let mut conn = None; + let database_entries_stream = + stream_query_results_with_conn(&sql_file, &exec_ctx, &mut conn); + let database_entries_stream = stop_at_first_error(database_entries_stream); + let response_with_writer = build_response_header_and_stream( + Arc::clone(&app_state), + database_entries_stream, + request_context, + ) + .await; + match response_with_writer { + Ok(ResponseWithWriter::RenderStream { + http_response, + renderer, + database_entries_stream, + }) => { + resp_send + .send(http_response) + .unwrap_or_else(|e| log::error!("could not send headers {e:?}")); + tracing::Instrument::instrument( + stream_response(database_entries_stream, renderer), + tracing::info_span!("render"), + ) + .await; + } + Ok(ResponseWithWriter::FinishedResponse { http_response }) => { + resp_send + .send(http_response) + .unwrap_or_else(|e| log::error!("could not send headers {e:?}")); + } + Err(err) => { + send_anyhow_error(&err, resp_send, &app_state); + } + } + }, + exec_span, + )); + resp_recv.await.map_err(ErrorInternalServerError) +} + +fn request_span_route(request: &ServiceRequest) -> Cow<'_, str> { + request + .match_pattern() + .map_or_else(|| request.path().to_owned().into(), Cow::from) +} + +fn request_span_name(request: &ServiceRequest) -> String { + format!("{} {}", request.method(), request_span_route(request)) +} + +fn sql_execution_span_name(source_path: &std::path::Path) -> String { + format!("SQL {}", source_path.display()) +} + +struct RequestHeaderCarrier<'a>(&'a actix_web::http::header::HeaderMap); + +impl opentelemetry::propagation::Extractor for RequestHeaderCarrier<'_> { + fn get(&self, key: &str) -> Option<&str> { + self.0.get(key).and_then(|value| value.to_str().ok()) + } + + fn keys(&self) -> Vec<&str> { + self.0 + .keys() + .map(actix_web::http::header::HeaderName::as_str) + .collect() + } +} + +fn set_otel_parent(request: &ServiceRequest, span: &Span) { + use tracing_opentelemetry::OpenTelemetrySpanExt as _; + + let parent_context = opentelemetry::global::get_text_map_propagator(|propagator| { + propagator.extract(&RequestHeaderCarrier(request.headers())) + }); + let _ = span.set_parent(parent_context); +} + +struct SqlPageRootSpanBuilder; + +impl RootSpanBuilder for SqlPageRootSpanBuilder { + fn on_request_start(request: &ServiceRequest) -> Span { + let user_agent = request + .headers() + .get("User-Agent") + .map_or("", |h| h.to_str().unwrap_or("")); + let http_route = request_span_route(request); + let http_method = + tracing_actix_web::root_span_macro::private::http_method_str(request.method()); + let otel_name = request_span_name(request); + let connection_info = request.connection_info(); + let request_id = tracing_actix_web::root_span_macro::private::get_request_id(request); + + let span = tracing::span!( + tracing::Level::INFO, + "HTTP request", + { otel::HTTP_REQUEST_METHOD } = %http_method, + { otel::HTTP_ROUTE } = %http_route, + { otel::NETWORK_PROTOCOL_NAME } = "http", + { otel::NETWORK_PROTOCOL_VERSION } = %tracing_actix_web::root_span_macro::private::http_flavor(request.version()), + { otel::URL_SCHEME } = %tracing_actix_web::root_span_macro::private::http_scheme(connection_info.scheme()), + { otel::SERVER_ADDRESS } = %connection_info.host(), + { otel::CLIENT_ADDRESS } = %request.connection_info().realip_remote_addr().unwrap_or(""), + { otel::USER_AGENT_ORIGINAL } = %user_agent, + { otel::URL_PATH } = %request.path(), + { otel::URL_QUERY } = %request.query_string(), + { otel::HTTP_RESPONSE_STATUS_CODE } = tracing::field::Empty, + "otel.name" = %otel_name, + "otel.kind" = "server", + { otel::OTEL_STATUS_CODE } = tracing::field::Empty, + request_id = %request_id, + { otel::EXCEPTION_MESSAGE } = tracing::field::Empty, + "sqlpage.exception.details" = tracing::field::Empty, + ); + std::mem::drop(connection_info); + set_otel_parent(request, &span); + span + } + + fn on_request_end(span: Span, outcome: &Result, Error>) { + let span_ref = span.clone(); + DefaultRootSpanBuilder::on_request_end(span, outcome); + + // Emit a single log event per completed request so it appears in logs. + let _enter = span_ref.enter(); + if let Ok(response) = outcome { + let status = response.response().status(); + let level = if status.is_server_error() { + log::Level::Error + } else if status.is_client_error() { + log::Level::Warn + } else { + log::Level::Info + }; + log::log!(target: crate::telemetry::ACCESS_LOG_TARGET, level, "{status}"); + } + } +} + +async fn process_sql_request( + req: &mut ServiceRequest, + sql_path: PathBuf, +) -> actix_web::Result { + let app_state: &web::Data = req.app_data().expect("app_state"); + let server_timing = ServerTiming::for_env(app_state.config.environment); + + let access = + FileAccess::unprivileged(&sql_path).map_err(|e| anyhow_err_to_actix(e, app_state))?; + let sql_file = { + let span = tracing::info_span!( + "sqlpage.file.load", + { otel::CODE_FILE_PATH } = %sql_path.display(), + ); + app_state + .sql_file_cache + .get(app_state, access) + .instrument(span) + .await + .with_context(|| format!("Unable to read SQL file \"{}\"", sql_path.display())) + .map_err(|e| anyhow_err_to_actix(e, app_state))? + }; + server_timing.record("sql_file"); + + render_sql(req, sql_file, server_timing).await +} + +async fn serve_file( + path: &str, + state: &AppState, + if_modified_since: Option, +) -> actix_web::Result { + let path = strip_site_prefix(path, state); + let access = + FileAccess::unprivileged(path.as_ref()).map_err(|e| anyhow_err_to_actix(e, state))?; + if let Some(IfModifiedSince(date)) = if_modified_since { + let since = DateTime::::from(SystemTime::from(date)); + let modified = state + .file_system + .modified_since(state, access, since) + .await + .with_context(|| format!("Unable to get modification time of file {path:?}")) + .map_err(|e| anyhow_err_to_actix(e, state))?; + if !modified { + return Ok(HttpResponse::NotModified().finish()); + } + } + state + .file_system + .read_file(state, access) + .await + .with_context(|| format!("Unable to read file {path:?}")) + .map_err(|e| anyhow_err_to_actix(e, state)) + .map(|b| { + HttpResponse::Ok() + .insert_header( + mime_guess::from_path(path) + .first() + .map_or_else(ContentType::octet_stream, ContentType), + ) + .insert_header(LastModified(HttpDate::from(SystemTime::now()))) + .body(b) + }) +} + +/// Strips the site prefix from a path +fn strip_site_prefix<'a>(path: &'a str, state: &AppState) -> &'a str { + path.strip_prefix(&state.config.site_prefix).unwrap_or(path) +} + +pub async fn main_handler( + mut service_request: ServiceRequest, +) -> actix_web::Result { + let app_state: &web::Data = service_request.app_data().expect("app_state"); + let store = AppFileStore::new(&app_state.sql_file_cache, &app_state.file_system, app_state); + let path_and_query = service_request + .uri() + .path_and_query() + .ok_or_else(|| ErrorBadRequest("expected valid path with query from request"))?; + let routing_action = match calculate_route(path_and_query, &store, &app_state.config).await { + Ok(action) => action, + Err(e) => { + let e = e.context(format!( + "The server was unable to fulfill your request. \n\ + The following page is not accessible: {path_and_query:?}" + )); + return Err(anyhow_err_to_actix(e, app_state)); + } + }; + match routing_action { + NotFound => { + let accept_header = + header::Accept::parse(&service_request).unwrap_or(header::Accept::star()); + let prefers_html = accept_header.iter().any(|h| h.item.subtype() == "html"); + + if prefers_html { + let mut response = + process_sql_request(&mut service_request, PathBuf::from(DEFAULT_404_FILE)) + .await?; + *response.status_mut() = StatusCode::NOT_FOUND; + Ok(response) + } else { + Ok(HttpResponse::NotFound() + .content_type(ContentType::plaintext()) + .body("404 Not Found\n")) + } + } + Execute(path) => process_sql_request(&mut service_request, path).await, + CustomNotFound(path) => { + // Currently, we do not set a 404 status when the user provides a fallback 404.sql file. + process_sql_request(&mut service_request, path).await + } + Redirect(redirect_target) => Ok(HttpResponse::MovedPermanently() + .insert_header((header::LOCATION, redirect_target)) + .finish()), + Serve(path) => { + let if_modified_since = IfModifiedSince::parse(&service_request).ok(); + let app_state: &web::Data = service_request.app_data().expect("app_state"); + let path = path + .as_os_str() + .to_str() + .ok_or_else(|| ErrorBadRequest("requested file path must be valid unicode"))?; + serve_file(path, app_state, if_modified_since).await + } + } + .map(|response| service_request.into_response(response)) +} + +/// called when a request is made to a path outside of the sub-path we are serving the site from +async fn default_prefix_redirect( + service_request: ServiceRequest, +) -> actix_web::Result { + let app_state: &web::Data = service_request.app_data().expect("app_state"); + let original_path = service_request.path(); + let site_prefix = &app_state.config.site_prefix; + let redirect_path = site_prefix.trim_end_matches('/').to_string() + original_path; + log::info!( + "Received request to {original_path} (outside of site prefix {site_prefix}), redirecting to {redirect_path}" + ); + Ok(service_request.into_response( + HttpResponse::PermanentRedirect() + .insert_header((header::LOCATION, redirect_path)) + .finish(), + )) +} + +pub fn create_app( + app_state: web::Data, +) -> App< + impl ServiceFactory< + ServiceRequest, + Config = (), + Response = ServiceResponse< + impl MessageBody, + >, + Error = actix_web::Error, + InitError = (), + >, +> { + let encoded_scope: &str = app_state.config.site_prefix.trim_end_matches('/'); + let decoded_scope = percent_encoding::percent_decode_str(encoded_scope).decode_utf8_lossy(); + App::new() + .service( + web::scope(&decoded_scope) + .service(static_content::js()) + .service(static_content::apexcharts_js()) + .service(static_content::tomselect_js()) + .service(static_content::css()) + .service(static_content::favicon()) + .default_service(fn_service(main_handler)), + ) + // when receiving a request outside of the prefix, redirect to the prefix + .default_service(fn_service(default_prefix_redirect)) + .wrap(OidcMiddleware::new(&app_state)) + .wrap(super::http_metrics::HttpMetrics) + .wrap(TracingLogger::::new()) + .wrap(default_headers()) + .wrap(middleware::Condition::new( + app_state.config.compress_responses, + middleware::Compress::default(), + )) + .wrap(middleware::NormalizePath::new( + middleware::TrailingSlash::MergeOnly, + )) + .app_data(payload_config(&app_state)) + .app_data(make_http_client(&app_state.config)) + .app_data(form_config(&app_state)) + .app_data(app_state) +} + +#[must_use] +pub fn form_config(app_state: &web::Data) -> web::FormConfig { + web::FormConfig::default() + .limit(app_state.config.max_uploaded_file_size) + .error_handler(super::error::handle_form_error) +} + +#[must_use] +pub fn payload_config(app_state: &web::Data) -> PayloadConfig { + PayloadConfig::default().limit(app_state.config.max_uploaded_file_size * 2) +} + +fn default_headers() -> middleware::DefaultHeaders { + let server_header = format!("{} v{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")); + middleware::DefaultHeaders::new().add(("Server", server_header)) +} + +pub async fn run_server(config: &AppConfig, state: AppState) -> anyhow::Result<()> { + let listen_on = config.listen_on(); + let state = web::Data::new(state); + let final_state = web::Data::clone(&state); + let factory = move || create_app(web::Data::clone(&state)); + + #[cfg(feature = "lambda-web")] + if lambda_web::is_running_on_lambda() { + lambda_web::run_actix_on_lambda(factory) + .await + .map_err(|e| anyhow::anyhow!("Unable to start the lambda: {e}"))?; + return Ok(()); + } + let mut server = HttpServer::new(factory); + #[cfg_attr( + not(target_family = "unix"), + expect( + clippy::redundant_else, + reason = "Conditional compilation produces redundant else when not on unix targets." + ) + )] + if let Some(unix_socket) = &config.unix_socket { + log::info!( + "Will start HTTP server on UNIX socket: \"{}\"", + unix_socket.display() + ); + #[cfg(target_family = "unix")] + { + server = server + .bind_uds(unix_socket) + .map_err(|e| bind_unix_socket_err(e, unix_socket))?; + } + #[cfg(not(target_family = "unix"))] + anyhow::bail!( + "Unix sockets are not supported on your operating system. Use listen_on instead of unix_socket." + ); + } else { + if let Some(domain) = &config.https_domain { + let mut listen_on_https = listen_on; + listen_on_https.set_port(443); + log::debug!("Will start HTTPS server on {listen_on_https}"); + let config = make_auto_rustls_config(domain, config); + server = server + .bind_rustls_0_23(listen_on_https, config) + .map_err(|e| bind_error(e, listen_on_https))?; + } else if listen_on.port() == 443 { + bail!( + "Please specify a value for https_domain in the configuration file. This is required when using HTTPS (port 443)" + ); + } + if listen_on.port() != 443 { + log::debug!("Will start HTTP server on {listen_on}"); + server = server + .bind(listen_on) + .map_err(|e| bind_error(e, listen_on))?; + } + } + + log_welcome_message(config); + server + .run() + .await + .with_context(|| "Unable to start the application")?; + + // We are done, we can close the database connection + final_state.db.close().await?; + Ok(()) +} + +fn log_welcome_message(config: &AppConfig) { + let address_message = if let Some(unix_socket) = &config.unix_socket { + format!("unix socket \"{}\"", unix_socket.display()) + } else if let Some(domain) = &config.https_domain { + format!("https://{domain}") + } else { + let listen_on = config.listen_on(); + let port = listen_on.port(); + let ip = listen_on.ip(); + if ip.is_unspecified() { + format!( + "http://localhost:{port}\n\ + (also accessible from other devices using your IP address)" + ) + } else if ip.is_ipv6() { + format!("http://[{ip}]:{port}") + } else { + format!("http://{ip}:{port}") + } + }; + + let (sparkle, link, computer, rocket) = if cfg!(target_os = "windows") { + ("", "", "", "") + } else { + ("✨", "🔗", "💻", "🚀") + }; + let version = env!("CARGO_PKG_VERSION"); + let web_root = config.web_root.display(); + + log::info!( + "\n{sparkle} SQLPage v{version} started successfully! {sparkle}\n\n\ + View your website at:\n{link} {address_message}\n\n\ + Create your pages with SQL files in:\n{computer} {web_root}\n\n\ + Happy coding! {rocket}" + ); +} + +#[cfg(target_family = "unix")] +fn bind_unix_socket_err(e: std::io::Error, unix_socket: &std::path::Path) -> anyhow::Error { + let ctx = if e.kind() == std::io::ErrorKind::PermissionDenied { + format!( + "You do not have permission to bind to the UNIX socket \"{}\". \ + You can change the socket path in the configuration file or check the permissions.", + unix_socket.display() + ) + } else { + format!( + "Unable to bind to UNIX socket \"{}\" {e:?}", + unix_socket.display() + ) + }; + anyhow::anyhow!(e).context(ctx) +} + +#[cfg(test)] +mod tests { + use super::{request_span_name, sql_execution_span_name}; + use actix_web::test::TestRequest; + use std::path::Path; + + #[test] + fn request_span_name_uses_request_path_when_no_matched_route_exists() { + let request = TestRequest::with_uri("/todos/42?filter=open").to_srv_request(); + assert_eq!(request_span_name(&request), "GET /todos/42"); + } + + #[test] + fn sql_execution_span_name_uses_sql_file_path() { + assert_eq!( + sql_execution_span_name(Path::new("website/todos.sql")), + "SQL website/todos.sql" + ); + } +} diff --git a/src/webserver/http_client.rs b/src/webserver/http_client.rs new file mode 100644 index 0000000..e935ad4 --- /dev/null +++ b/src/webserver/http_client.rs @@ -0,0 +1,76 @@ +use actix_web::dev::ServiceRequest; +use anyhow::{Context, anyhow}; +use rustls_native_certs::CertificateResult; +use std::sync::OnceLock; + +static NATIVE_CERTS: OnceLock> = OnceLock::new(); + +pub fn make_http_client(config: &crate::app_config::AppConfig) -> anyhow::Result { + make_http_client_with_system_roots(config.system_root_ca_certificates) +} + +pub(crate) fn default_system_root_ca_certificates_from_env() -> bool { + std::env::var("SSL_CERT_FILE").is_ok_and(|value| !value.is_empty()) + || std::env::var("SSL_CERT_DIR").is_ok_and(|value| !value.is_empty()) +} + +pub(crate) fn make_http_client_with_system_roots( + system_root_ca_certificates: bool, +) -> anyhow::Result { + let connector = if system_root_ca_certificates { + let roots = NATIVE_CERTS + .get_or_init(|| { + log::debug!("Loading native certificates because system_root_ca_certificates is enabled"); + let CertificateResult { certs, errors, .. } = rustls_native_certs::load_native_certs(); + log::debug!("Loaded {} native HTTPS client certificates", certs.len()); + for error in errors { + log::error!("Unable to load native certificate: {error}"); + } + let mut roots = rustls::RootCertStore::empty(); + for cert in certs { + log::trace!("Adding native certificate to root store: {cert:?}"); + roots.add(cert.clone()).with_context(|| { + format!("Unable to add certificate to root store: {cert:?}") + })?; + } + Ok(roots) + }) + .as_ref() + .map_err(|e| anyhow!("Unable to load native certificates, make sure the system root CA certificates are available: {e}"))?; + + log::trace!( + "Creating HTTP client with custom TLS connector using native certificates. SSL_CERT_FILE={:?}, SSL_CERT_DIR={:?}", + std::env::var("SSL_CERT_FILE").unwrap_or_default(), + std::env::var("SSL_CERT_DIR").unwrap_or_default() + ); + + let tls_conf = rustls::ClientConfig::builder() + .with_root_certificates(roots.clone()) + .with_no_client_auth(); + + awc::Connector::new().rustls_0_23(std::sync::Arc::new(tls_conf)) + } else { + log::debug!( + "Using the default tls connector with builtin certs because system_root_ca_certificates is disabled" + ); + awc::Connector::new() + }; + let client = awc::Client::builder() + .connector(connector) + .add_default_header((awc::http::header::USER_AGENT, env!("CARGO_PKG_NAME"))) + .finish(); + log::debug!("Created HTTP client"); + Ok(client) +} + +pub(crate) fn get_http_client_from_appdata( + request: &ServiceRequest, +) -> anyhow::Result<&awc::Client> { + if let Some(result) = request.app_data::>() { + result + .as_ref() + .map_err(|e| anyhow!("HTTP client initialization failed: {e}")) + } else { + Err(anyhow!("HTTP client not found in app data")) + } +} diff --git a/src/webserver/http_metrics.rs b/src/webserver/http_metrics.rs new file mode 100644 index 0000000..3d54b60 --- /dev/null +++ b/src/webserver/http_metrics.rs @@ -0,0 +1,91 @@ +use std::future::{Ready, ready}; +use std::time::Instant; + +use actix_web::{ + Error, + dev::{Service, ServiceRequest, ServiceResponse, Transform, forward_ready}, + web, +}; +use futures_util::future::LocalBoxFuture; +use opentelemetry::KeyValue; +use opentelemetry_semantic_conventions::attribute as otel; +use tracing_actix_web::root_span_macro::private::{http_method_str, http_scheme}; + +use crate::AppState; + +pub struct HttpMetrics; + +impl Transform for HttpMetrics +where + S: Service, Error = Error> + 'static, + S::Future: 'static, +{ + type Response = ServiceResponse; + type Error = Error; + type Transform = HttpMetricsMiddleware; + type InitError = (); + type Future = Ready>; + + fn new_transform(&self, service: S) -> Self::Future { + ready(Ok(HttpMetricsMiddleware { service })) + } +} + +pub struct HttpMetricsMiddleware { + service: S, +} + +impl Service for HttpMetricsMiddleware +where + S: Service, Error = Error> + 'static, + S::Future: 'static, +{ + type Response = ServiceResponse; + type Error = Error; + type Future = LocalBoxFuture<'static, Result>; + + forward_ready!(service); + + fn call(&self, req: ServiceRequest) -> Self::Future { + let start_time = Instant::now(); + let method = http_method_str(req.method()).to_string(); + let connection_info = req.connection_info(); + let scheme = http_scheme(connection_info.scheme()).to_string(); + let host = connection_info.host().to_string(); + drop(connection_info); + + // We get the route pattern. In Actix, req.match_pattern() returns the matched route + let route = req + .match_pattern() + .unwrap_or_else(|| req.path().to_string()); + + let fut = self.service.call(req); + + Box::pin(async move { + let res = fut.await?; + let duration = start_time.elapsed().as_secs_f64(); + let status = res.status().as_u16(); + + let mut attributes = vec![ + KeyValue::new(otel::HTTP_REQUEST_METHOD, method), + KeyValue::new(otel::HTTP_RESPONSE_STATUS_CODE, status.to_string()), + KeyValue::new(otel::HTTP_ROUTE, route), + KeyValue::new(otel::URL_SCHEME, scheme), + KeyValue::new(otel::SERVER_ADDRESS, host), + ]; + + if status >= 500 { + attributes.push(KeyValue::new(otel::ERROR_TYPE, status.to_string())); + } + + if let Some(app_state) = res.request().app_data::>() { + app_state + .telemetry_metrics + .http_request_duration + .record(duration, &attributes); + } + + Ok(res) + }) + } +} diff --git a/src/webserver/http_request_info.rs b/src/webserver/http_request_info.rs new file mode 100644 index 0000000..da62873 --- /dev/null +++ b/src/webserver/http_request_info.rs @@ -0,0 +1,411 @@ +use crate::AppState; +use crate::webserver::request_variables::SetVariablesMap; +use crate::webserver::server_timing::ServerTiming; +use actix_multipart::Multipart; +use actix_multipart::form::FieldReader; +use actix_multipart::form::Limits; +use actix_multipart::form::bytes::Bytes; +use actix_multipart::form::tempfile::TempFile; +use actix_web::FromRequest; +use actix_web::HttpMessage as _; +use actix_web::HttpRequest; +use actix_web::dev::ServiceRequest; +use actix_web::http::header::CONTENT_TYPE; +use actix_web::http::header::Header; +use actix_web::web; +use actix_web::web::Form; +use actix_web_httpauth::headers::authorization::Authorization; +use actix_web_httpauth::headers::authorization::Basic; +use anyhow::Context; +use anyhow::anyhow; +use std::cell::RefCell; +use std::collections::HashMap; +use std::net::IpAddr; +use std::rc::Rc; +use std::sync::Arc; +use tokio_stream::StreamExt; + +use super::oidc::OidcClaims; +use super::request_variables::ParamMap; +use super::request_variables::param_map; +use super::{ActixErrorStatusExt, StatusCodeResultExt}; + +#[derive(Debug)] +pub struct RequestInfo { + pub method: actix_web::http::Method, + pub path: String, + pub protocol: String, + pub url_params: ParamMap, + pub post_variables: ParamMap, + pub uploaded_files: Rc>, + pub headers: ParamMap, + pub client_ip: Option, + pub cookies: ParamMap, + pub basic_auth: Option, + pub app_state: Arc, + pub raw_body: Option>, + pub oidc_claims: Option, + pub server_timing: Arc, +} + +#[derive(Debug)] +pub struct ExecutionContext { + pub request: Rc, + pub set_variables: RefCell, + pub clone_depth: u8, +} + +impl ExecutionContext { + #[must_use] + pub fn new(request: RequestInfo) -> Self { + Self { + request: Rc::new(request), + set_variables: RefCell::new(SetVariablesMap::new()), + clone_depth: 0, + } + } + + #[must_use] + pub fn fork(&self) -> Self { + Self { + request: Rc::clone(&self.request), + set_variables: RefCell::new(self.set_variables.borrow().clone()), + clone_depth: self.clone_depth + 1, + } + } + + #[must_use] + pub fn fork_with_variables(&self, variables: SetVariablesMap) -> Self { + Self { + request: Rc::clone(&self.request), + set_variables: RefCell::new(variables), + clone_depth: self.clone_depth + 1, + } + } + + pub fn request(&self) -> &RequestInfo { + self.request.as_ref() + } +} + +impl Clone for ExecutionContext { + fn clone(&self) -> Self { + self.fork() + } +} + +impl std::ops::Deref for ExecutionContext { + type Target = RequestInfo; + + fn deref(&self) -> &Self::Target { + self.request() + } +} + +impl<'a> From<&'a ExecutionContext> for &'a RequestInfo { + fn from(ctx: &'a ExecutionContext) -> Self { + ctx.request() + } +} + +pub(crate) async fn extract_request_info( + req: &mut ServiceRequest, + app_state: Arc, + server_timing: ServerTiming, +) -> anyhow::Result { + let (http_req, payload) = req.parts_mut(); + let method = http_req.method().clone(); + let protocol = http_req.connection_info().scheme().to_string(); + let config = &app_state.config; + let (post_variables, uploaded_files, raw_body) = + extract_post_data(http_req, payload, config).await?; + let headers = req.headers().iter().map(|(name, value)| { + ( + name.to_string(), + String::from_utf8_lossy(value.as_bytes()).to_string(), + ) + }); + let get_variables = web::Query::>::from_query(req.query_string()) + .map(web::Query::into_inner) + .unwrap_or_default(); + let client_ip = req.peer_addr().map(|addr| addr.ip()); + + let raw_cookies = req.cookies(); + let cookies = raw_cookies + .iter() + .flat_map(|c| c.iter()) + .map(|cookie| (cookie.name().to_string(), cookie.value().to_string())); + + let basic_auth = Authorization::::parse(req) + .ok() + .map(Authorization::into_scheme); + + let oidc_claims: Option = req.extensions().get::().cloned(); + + Ok(ExecutionContext::new(RequestInfo { + method, + path: req.path().to_string(), + headers: param_map(headers), + url_params: param_map(get_variables), + post_variables: param_map(post_variables), + uploaded_files: Rc::new(HashMap::from_iter(uploaded_files)), + client_ip, + cookies: param_map(cookies), + basic_auth, + app_state, + protocol, + raw_body, + oidc_claims, + server_timing: Arc::new(server_timing), + })) +} + +async fn extract_post_data( + http_req: &mut actix_web::HttpRequest, + payload: &mut actix_web::dev::Payload, + config: &crate::app_config::AppConfig, +) -> anyhow::Result<( + Vec<(String, String)>, + Vec<(String, TempFile)>, + Option>, +)> { + let content_type = http_req + .headers() + .get(&CONTENT_TYPE) + .map(AsRef::as_ref) + .unwrap_or_default(); + if content_type.starts_with(b"application/x-www-form-urlencoded") { + let vars = extract_urlencoded_post_variables(http_req, payload).await?; + Ok((vars, Vec::new(), None)) + } else if content_type.starts_with(b"multipart/form-data") { + let (vars, files) = extract_multipart_post_data(http_req, payload, config).await?; + Ok((vars, files, None)) + } else { + let body = actix_web::web::Bytes::from_request(http_req, payload) + .await + .map(|bytes| bytes.to_vec()) + .unwrap_or_default(); + Ok((Vec::new(), Vec::new(), Some(body))) + } +} + +async fn extract_urlencoded_post_variables( + http_req: &mut actix_web::HttpRequest, + payload: &mut actix_web::dev::Payload, +) -> anyhow::Result> { + Form::>::from_request(http_req, payload) + .await + .map(Form::into_inner) + .with_actix_error_status() + .context("could not parse request as urlencoded form data") +} + +async fn extract_multipart_post_data( + http_req: &mut actix_web::HttpRequest, + payload: &mut actix_web::dev::Payload, + config: &crate::app_config::AppConfig, +) -> anyhow::Result<(Vec<(String, String)>, Vec<(String, TempFile)>)> { + let mut post_variables = Vec::new(); + let mut uploaded_files = Vec::new(); + + let mut multipart = Multipart::from_request(http_req, payload) + .await + .with_actix_error_status() + .context("could not parse request as multipart form data")?; + + let mut limits = Limits::new(config.max_uploaded_file_size, config.max_uploaded_file_size); + log::trace!( + "Parsing multipart form data with a {:?} KiB limit", + limits.total_limit_remaining / 1024 + ); + + while let Some(part) = multipart.next().await { + let field = part + .with_response_status() + .context("unable to read form field")?; + let content_disposition = field + .content_disposition() + .ok_or_else(|| anyhow!("missing Content-Disposition in form field")) + .with_status(actix_web::http::StatusCode::BAD_REQUEST)?; + // test if field is a file + let filename = content_disposition.get_filename(); + let field_name = content_disposition + .get_name() + .unwrap_or_default() + .to_string(); + log::trace!("Parsing multipart field: {field_name}"); + if let Some(filename) = filename { + log::debug!("Extracting file: {field_name} ({filename})"); + let extracted = extract_file(http_req, field, &mut limits) + .await + .with_context(|| { + format!( + "Failed to extract file {field_name:?}. Max file size: {} kiB", + config.max_uploaded_file_size / 1_024 + ) + })?; + log::trace!( + "Extracted file {field_name} to \"{}\"", + extracted.file.path().display() + ); + if is_file_field_empty(&extracted).await? { + log::debug!("Ignoring empty file field: {field_name}"); + continue; + } + uploaded_files.push((field_name, extracted)); + } else { + let text_contents = extract_text(http_req, field, &mut limits).await?; + log::trace!("Extracted field as text: {field_name} = {text_contents:?}"); + post_variables.push((field_name, text_contents)); + } + } + Ok((post_variables, uploaded_files)) +} + +async fn extract_text( + req: &HttpRequest, + field: actix_multipart::Field, + limits: &mut Limits, +) -> anyhow::Result { + // field is an async stream of Result objects, we collect them into a Vec + let data = Bytes::read_field(req, field, limits) + .await + .map(|bytes| bytes.data) + .with_response_status() + .context("failed to read form field data")?; + String::from_utf8(data.to_vec()) + .with_status(actix_web::http::StatusCode::BAD_REQUEST) + .context("could not parse multipart form field as utf-8 text") +} + +async fn extract_file( + req: &HttpRequest, + field: actix_multipart::Field, + limits: &mut Limits, +) -> anyhow::Result { + // extract a tempfile from the field + let file = TempFile::read_field(req, field, limits) + .await + .with_response_status() + .context("failed to save uploaded file")?; + Ok(file) +} + +/// file upload form fields that are left blank result in the browser sending an empty file, with a mime type of application/octet-stream. +/// We don't want to treat this the same as actual empty files, so we check for this case. +async fn is_file_field_empty( + uploaded_file: &actix_multipart::form::tempfile::TempFile, +) -> anyhow::Result { + Ok( + uploaded_file.content_type == Some(mime_guess::mime::APPLICATION_OCTET_STREAM) + && uploaded_file.file_name.as_deref().is_none_or(str::is_empty) + && tokio::fs::metadata(&uploaded_file.file.path()).await?.len() == 0, + ) +} + +#[cfg(test)] +mod test { + use super::*; + use crate::webserver::single_or_vec::SingleOrVec; + use crate::{app_config::AppConfig, webserver::server_timing::ServerTiming}; + use actix_web::{http::header::ContentType, test::TestRequest}; + + #[actix_web::test] + async fn test_extract_empty_request() { + let config = + serde_json::from_str::(r#"{"listen_on": "localhost:1234"}"#).unwrap(); + let mut service_request = TestRequest::default().to_srv_request(); + let app_data = Arc::new(AppState::init(&config).await.unwrap()); + let server_timing = ServerTiming::default(); + let request_ctx = extract_request_info(&mut service_request, app_data, server_timing) + .await + .unwrap(); + let request_info = request_ctx.request(); + assert_eq!(request_info.post_variables.len(), 0); + assert_eq!(request_info.uploaded_files.len(), 0); + assert_eq!(request_info.url_params.len(), 0); + } + + #[actix_web::test] + async fn test_extract_urlencoded_request() { + let config = + serde_json::from_str::(r#"{"listen_on": "localhost:1234"}"#).unwrap(); + let mut service_request = TestRequest::get() + .uri("/?my_array[]=5") + .insert_header(ContentType::form_url_encoded()) + .set_payload("my_array[]=3&my_array[]=Hello%20World&repeated=1&repeated=2") + .to_srv_request(); + let app_data = Arc::new(AppState::init(&config).await.unwrap()); + let server_timing = ServerTiming::default(); + let request_ctx = extract_request_info(&mut service_request, app_data, server_timing) + .await + .unwrap(); + let request_info = request_ctx.request(); + assert_eq!( + request_info.post_variables, + vec![ + ( + "my_array".to_string(), + SingleOrVec::Vec(vec!["3".to_string(), "Hello World".to_string()]) + ), + ("repeated".to_string(), SingleOrVec::Single("2".to_string())), // without brackets, only the last value is kept + ] + .into_iter() + .collect::() + ); + assert_eq!(request_info.uploaded_files.len(), 0); + assert_eq!( + request_info.url_params, + vec![( + "my_array".to_string(), + SingleOrVec::Vec(vec!["5".to_string()]) + )] // with brackets, even if there is only one value, it is kept as a vector + .into_iter() + .collect::() + ); + } + + #[actix_web::test] + async fn test_extract_multipart_form_data() { + crate::telemetry::init_test_logging(); + let config = + serde_json::from_str::(r#"{"listen_on": "localhost:1234"}"#).unwrap(); + let mut service_request = TestRequest::get() + .insert_header(("content-type", "multipart/form-data;boundary=xxx")) + .set_payload( + "--xxx\r\n\ + Content-Disposition: form-data; name=\"my_array[]\"\r\n\ + Content-Type: text/plain\r\n\ + \r\n\ + 3\r\n\ + --xxx\r\n\ + Content-Disposition: form-data; name=\"my_uploaded_file\"; filename=\"test.txt\"\r\n\ + Content-Type: text/plain\r\n\ + \r\n\ + Hello World\r\n\ + --xxx--\r\n" + ) + .to_srv_request(); + let app_data = Arc::new(AppState::init(&config).await.unwrap()); + let server_timing = ServerTiming::enabled(false); + let request_ctx = extract_request_info(&mut service_request, app_data, server_timing) + .await + .unwrap(); + let request_info = request_ctx.request(); + assert_eq!( + request_info.post_variables, + vec![( + "my_array".to_string(), + SingleOrVec::Vec(vec!["3".to_string()]) + ),] + .into_iter() + .collect::() + ); + assert_eq!(request_info.uploaded_files.len(), 1); + let my_upload = &request_info.uploaded_files["my_uploaded_file"]; + assert_eq!(my_upload.file_name.as_ref().unwrap(), "test.txt"); + assert_eq!(request_info.url_params.len(), 0); + assert_eq!(std::fs::read(&my_upload.file).unwrap(), b"Hello World"); + assert_eq!(request_info.url_params.len(), 0); + } +} diff --git a/src/webserver/https.rs b/src/webserver/https.rs new file mode 100644 index 0000000..f880373 --- /dev/null +++ b/src/webserver/https.rs @@ -0,0 +1,32 @@ +use rustls_acme::{AcmeConfig, caches::DirCache, futures_rustls::rustls::ServerConfig}; +use tokio_stream::StreamExt; + +use crate::app_config::AppConfig; + +pub fn make_auto_rustls_config(domain: &str, config: &AppConfig) -> ServerConfig { + log::info!("Starting HTTPS configuration for {domain}"); + let mut state = AcmeConfig::new([domain]) + .contact([if let Some(email) = &config.https_certificate_email { + format!("mailto:{}", email.as_str()) + } else { + format!("mailto:contact@{domain}") + }]) + .cache_option(Some(DirCache::new( + config.https_certificate_cache_dir.clone(), + ))) + .directory(&config.https_acme_directory_url) + .state(); + let rustls_config = state.challenge_rustls_config(); + + tokio::spawn(async move { + while let Some(event) = state.next().await { + match event { + Ok(ok) => log::info!("ACME configuration event: {ok:?}"), + Err(err) => log::error!("Unable to configure HTTPS: {err:?}"), + } + } + log::error!("ACME configuration stream ended. This should never happen."); + }); + + ServerConfig::clone(&rustls_config) +} diff --git a/src/webserver/mod.rs b/src/webserver/mod.rs new file mode 100644 index 0000000..9744851 --- /dev/null +++ b/src/webserver/mod.rs @@ -0,0 +1,53 @@ +//! Core HTTP server implementation handling SQL file execution and request processing. +//! +//! For more general information about perfomance in sqlite, read our +//! [performance guide](https://sql-page.com/performance.sql). +//! +//! # Overview +//! +//! The webserver module is responsible for: +//! - Processing incoming HTTP requests +//! - Executing SQL files +//! - Streaming query results to clients +//! - Managing database connections +//! - Handling file uploads and static content +//! +//! # Architecture +//! +//! Key components: +//! +//! - [`database`]: SQL execution engine and query processing +//! - [`database::execute_queries`]: Streams query results from database +//! - [`database::migrations`]: Database schema management +//! +//! - [`http`]: HTTP server implementation using actix-web +//! - Request handling +//! - Response streaming +//! - [Content Security Policy](https://sql-page.com/safety.sql) enforcement +//! +//! - [`response_writer`]: Streaming response generation +//! - [`static_content`]: Static asset handling (JS, CSS, icons) +//! + +pub mod content_security_policy; +pub mod database; +pub(crate) mod error; +pub mod error_with_status; +pub mod http; +pub mod http_client; +pub mod http_metrics; +pub mod http_request_info; +mod https; +pub mod request_variables; +pub mod server_timing; + +pub use database::Database; +pub use error_with_status::{ActixErrorStatusExt, ErrorWithStatus, StatusCodeResultExt}; + +pub use database::make_placeholder; +pub use database::migrations::apply; +pub mod oidc; +pub mod response_writer; +pub mod routing; +mod single_or_vec; +mod static_content; diff --git a/src/webserver/oidc.rs b/src/webserver/oidc.rs new file mode 100644 index 0000000..9173aff --- /dev/null +++ b/src/webserver/oidc.rs @@ -0,0 +1,1560 @@ +use std::collections::HashSet; +use std::future::ready; +use std::rc::Rc; +use std::time::Duration; +use std::{future::Future, pin::Pin, str::FromStr, sync::Arc}; +use tokio::time::Instant; + +use crate::webserver::http_client::get_http_client_from_appdata; +use crate::{AppState, app_config::AppConfig}; +use actix_web::http::header; +use actix_web::{ + Error, HttpMessage, HttpResponse, + body::BoxBody, + cookie::Cookie, + dev::{Service, ServiceRequest, ServiceResponse, Transform, forward_ready}, + middleware::Condition, + web::{self, Query}, +}; +use anyhow::{Context, anyhow}; +use awc::Client; +use openidconnect::core::{ + CoreAuthDisplay, CoreAuthPrompt, CoreErrorResponseType, CoreGenderClaim, CoreJsonWebKey, + CoreJweContentEncryptionAlgorithm, CoreJwsSigningAlgorithm, CoreRevocableToken, + CoreRevocationErrorResponse, CoreTokenIntrospectionResponse, CoreTokenType, +}; +use openidconnect::{ + AsyncHttpClient, Audience, CsrfToken, EndSessionUrl, EndpointMaybeSet, EndpointNotSet, + EndpointSet, IssuerUrl, LogoutRequest, Nonce, OAuth2TokenResponse, PostLogoutRedirectUrl, + ProviderMetadataWithLogout, RedirectUrl, Scope, TokenResponse, + core::CoreAuthenticationFlow, + url::{Url, form_urlencoded}, +}; +use openidconnect::{ + EmptyExtraTokenFields, IdTokenFields, IdTokenVerifier, StandardErrorResponse, + StandardTokenResponse, +}; +use opentelemetry_semantic_conventions::attribute as otel; +use serde::{Deserialize, Serialize}; +use tracing::Instrument; + +use super::error::anyhow_err_to_actix_resp; +use super::http_client::make_http_client; + +type LocalBoxFuture = Pin + 'static>>; + +const SQLPAGE_AUTH_COOKIE_NAME: &str = "sqlpage_auth"; +const SQLPAGE_REDIRECT_URI: &str = "/sqlpage/oidc_callback"; +const SQLPAGE_LOGOUT_URI: &str = "/sqlpage/oidc_logout"; +const SQLPAGE_NONCE_COOKIE_NAME: &str = "sqlpage_oidc_nonce"; +const SQLPAGE_TMP_LOGIN_STATE_COOKIE_PREFIX: &str = "sqlpage_oidc_state_"; +const OIDC_CLIENT_MAX_REFRESH_INTERVAL: Duration = Duration::from_hours(1); +const OIDC_CLIENT_MIN_REFRESH_INTERVAL: Duration = Duration::from_secs(5); +const OIDC_HTTP_BODY_TIMEOUT: Duration = OIDC_CLIENT_MIN_REFRESH_INTERVAL; +const SQLPAGE_OIDC_REDIRECT_COUNT_COOKIE: &str = "sqlpage_oidc_redirect_count"; +const MAX_OIDC_REDIRECTS: u8 = 3; +const MAX_OIDC_PARALLEL_LOGIN_FLOWS: usize = 8; +const AUTH_COOKIE_EXPIRATION: awc::cookie::time::Duration = + actix_web::cookie::time::Duration::days(7); +const LOGIN_FLOW_STATE_COOKIE_EXPIRATION: awc::cookie::time::Duration = + actix_web::cookie::time::Duration::minutes(10); + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(transparent)] +pub struct OidcAdditionalClaims(pub(crate) serde_json::Map); + +impl openidconnect::AdditionalClaims for OidcAdditionalClaims {} +type OidcToken = openidconnect::IdToken< + OidcAdditionalClaims, + openidconnect::core::CoreGenderClaim, + openidconnect::core::CoreJweContentEncryptionAlgorithm, + openidconnect::core::CoreJwsSigningAlgorithm, +>; +pub type OidcClaims = + openidconnect::IdTokenClaims; + +#[derive(Clone, Debug)] +pub struct OidcConfig { + pub issuer_url: IssuerUrl, + pub client_id: String, + pub client_secret: String, + pub protected_paths: Vec, + pub public_paths: Vec, + pub app_host: String, + pub scopes: Vec, + pub additional_audience_verifier: AudienceVerifier, + pub site_prefix: String, + pub redirect_uri: String, + pub logout_uri: String, +} + +impl TryFrom<&AppConfig> for OidcConfig { + type Error = Option<&'static str>; + + fn try_from(config: &AppConfig) -> Result { + let issuer_url = config.oidc_issuer_url.as_ref().ok_or(None)?; + let client_secret = config.oidc_client_secret.as_ref().ok_or(Some( + "The \"oidc_client_secret\" setting is required to authenticate with the OIDC provider", + ))?; + + let app_host = get_app_host(config); + + let site_prefix_trimmed = config.site_prefix.trim_end_matches('/'); + let redirect_uri = format!("{site_prefix_trimmed}{SQLPAGE_REDIRECT_URI}"); + let logout_uri = format!("{site_prefix_trimmed}{SQLPAGE_LOGOUT_URI}"); + + let protected_paths: Vec = config + .oidc_protected_paths + .iter() + .map(|path| format!("{site_prefix_trimmed}{path}")) + .collect(); + + let public_paths: Vec = config + .oidc_public_paths + .iter() + .map(|path| format!("{site_prefix_trimmed}{path}")) + .collect(); + + Ok(Self { + issuer_url: issuer_url.clone(), + client_id: config.oidc_client_id.clone(), + client_secret: client_secret.clone(), + protected_paths, + public_paths, + scopes: config + .oidc_scopes + .split_whitespace() + .map(|s| Scope::new(s.to_string())) + .collect(), + app_host: app_host.clone(), + additional_audience_verifier: AudienceVerifier::new( + config.oidc_additional_trusted_audiences.clone(), + ), + site_prefix: config.site_prefix.clone(), + redirect_uri, + logout_uri, + }) + } +} + +impl OidcConfig { + #[must_use] + pub fn is_public_path(&self, path: &str) -> bool { + // Percent-decode both the request path and the configured prefixes + // before comparing. Otherwise an unauthenticated request could encode a + // byte of a protected prefix (e.g. `/%70rotected/`, which the router + // decodes to `/protected/` and serves) to dodge the rule. The prefixes + // are decoded too because a `site_prefix` such as `/my app/` is stored + // percent-encoded (`/my%20app/...`); decoding only one side would never + // match and would wrongly make protected pages public. Matching stays a + // plain string prefix, preserving the documented `/public` vs `/public/` + // distinction (those resolve to different files). + fn decode(s: &str) -> std::borrow::Cow<'_, str> { + percent_encoding::percent_decode_str(s).decode_utf8_lossy() + } + let decoded = decode(path); + let path = decoded.as_ref(); + !self + .protected_paths + .iter() + .any(|p| path.starts_with(decode(p).as_ref())) + || self + .public_paths + .iter() + .any(|p| path.starts_with(decode(p).as_ref())) + } + + /// Creates a custom ID token verifier that supports multiple issuers + fn create_id_token_verifier<'a>( + &'a self, + oidc_client: &'a OidcClient, + ) -> IdTokenVerifier<'a, CoreJsonWebKey> { + oidc_client + .id_token_verifier() + .set_other_audience_verifier_fn(self.additional_audience_verifier.as_fn()) + } + + /// Creates a logout URL with the given redirect URI. + /// + /// The signature is bound to the current session (the value of the + /// [`SQLPAGE_AUTH_COOKIE_NAME`] cookie, if any) so that a logout URL + /// issued for one browser cannot be used to forcibly log out a different + /// browser. `session_token` is the current value of the auth cookie, or + /// `None`/empty if the caller is not authenticated. + #[must_use] + pub fn create_logout_url(&self, redirect_uri: &str, session_token: Option<&str>) -> String { + let timestamp = chrono::Utc::now().timestamp(); + let signature = compute_logout_signature( + redirect_uri, + timestamp, + session_token.unwrap_or_default(), + &self.client_secret, + ); + let query = form_urlencoded::Serializer::new(String::new()) + .append_pair("redirect_uri", redirect_uri) + .append_pair("timestamp", ×tamp.to_string()) + .append_pair("signature", &signature) + .finish(); + format!("{}?{}", self.logout_uri, query) + } +} + +fn get_app_host(config: &AppConfig) -> String { + if let Some(host) = &config.host { + return host.clone(); + } + if let Some(https_domain) = &config.https_domain { + return https_domain.clone(); + } + + let socket_addr = config.listen_on(); + let ip = socket_addr.ip(); + let host = if ip.is_unspecified() || ip.is_loopback() { + format!("localhost:{}", socket_addr.port()) + } else { + socket_addr.to_string() + }; + log::warn!( + "No host or https_domain provided in the configuration, \ + using \"{host}\" as the app host to build the redirect URL. \ + This will only work locally. \ + Disable this warning by providing a value for the \"host\" setting." + ); + host +} + +/// A point-in-time snapshot of the OIDC provider's client and metadata. +/// Cheaply cloneable via Arc — callers never hold a lock while using this. +struct OidcSnapshot { + client: OidcClient, + end_session_endpoint: Option, + created_at: Instant, +} + +pub struct OidcState { + pub config: OidcConfig, + /// Current snapshot. The lock is only held for the instant + /// needed to clone/swap the Arc — never across await points. + snapshot: std::sync::RwLock>, + /// Prevents concurrent background refreshes. + refresh_in_progress: std::sync::atomic::AtomicBool, +} + +impl OidcState { + pub async fn new(oidc_cfg: OidcConfig, app_config: AppConfig) -> anyhow::Result { + let http_client = make_http_client(&app_config)?; + let (client, end_session_endpoint) = build_oidc_client(&oidc_cfg, &http_client).await?; + + Ok(Self { + config: oidc_cfg, + snapshot: std::sync::RwLock::new(Arc::new(OidcSnapshot { + client, + end_session_endpoint, + created_at: Instant::now(), + })), + refresh_in_progress: std::sync::atomic::AtomicBool::new(false), + }) + } + + /// Returns the current snapshot. Never blocks in practice. + fn snapshot(&self) -> Arc { + self.snapshot.read().unwrap().clone() + } + + /// If the snapshot is older than `max_age` and no refresh is already running, + /// spawns a background task to fetch new provider metadata. + /// Returns immediately — never blocks the caller on I/O. + pub fn maybe_refresh(self: &Arc, http_client: &Client, max_age: Duration) { + use std::sync::atomic::Ordering; + if self.snapshot().created_at.elapsed() <= max_age { + return; + } + if self.refresh_in_progress.swap(true, Ordering::AcqRel) { + return; + } + let state = Arc::clone(self); + let http_client = http_client.clone(); + tokio::task::spawn_local(async move { + match build_oidc_client(&state.config, &http_client).await { + Ok((client, end_session_endpoint)) => { + *state.snapshot.write().unwrap() = Arc::new(OidcSnapshot { + client, + end_session_endpoint, + created_at: Instant::now(), + }); + } + Err(e) => log::error!("Failed to refresh OIDC client: {e:#}"), + } + state.refresh_in_progress.store(false, Ordering::Release); + }); + } + + pub fn end_session_endpoint(&self) -> Option { + self.snapshot().end_session_endpoint.clone() + } + + /// Validate and decode the claims of an OIDC token. + fn get_token_claims( + &self, + id_token: OidcToken, + expected_nonce: &Nonce, + ) -> anyhow::Result { + let span = tracing::info_span!( + "oidc.jwt.verify", + enduser.id = tracing::field::Empty, + user.id = tracing::field::Empty, + user.name = tracing::field::Empty, + user.full_name = tracing::field::Empty, + user.email = tracing::field::Empty, + ); + let _guard = span.enter(); + let snapshot = self.snapshot(); + let verifier = self.config.create_id_token_verifier(&snapshot.client); + let nonce_verifier = |nonce: Option<&Nonce>| check_nonce(nonce, expected_nonce); + let claims: OidcClaims = id_token + .into_claims(&verifier, nonce_verifier) + .map_err(|e| anyhow::anyhow!("Could not verify the ID token: {e}"))?; + let sub = claims.subject().as_str(); + span.record("enduser.id", sub); + span.record("user.id", sub); + if let Some(name) = claims.preferred_username() { + span.record("user.name", name.as_str()); + } + if let Some(name) = claims.name().and_then(|n| n.get(None)) { + span.record("user.full_name", name.as_str()); + } + if let Some(email) = claims.email() { + span.record("user.email", email.as_str()); + } + Ok(claims) + } + + /// Builds an absolute redirect URI from the client's configured redirect URL. + pub fn build_absolute_redirect_uri( + &self, + relative_redirect_uri: &str, + ) -> anyhow::Result { + let snapshot = self.snapshot(); + let client_redirect_url = snapshot + .client + .redirect_uri() + .ok_or_else(|| anyhow!("OIDC client has no redirect URL configured"))?; + let absolute_redirect_uri = client_redirect_url + .url() + .join(relative_redirect_uri) + .with_context(|| { + format!( + "Failed to join redirect URI {} with client redirect URL {}", + relative_redirect_uri, + client_redirect_url.url() + ) + })? + .to_string(); + Ok(absolute_redirect_uri) + } +} + +pub async fn initialize_oidc_state( + app_config: &AppConfig, +) -> anyhow::Result>> { + let oidc_cfg = match OidcConfig::try_from(app_config) { + Ok(c) => c, + Err(None) => return Ok(None), // OIDC not configured + Err(Some(e)) => return Err(anyhow::anyhow!(e)), + }; + + Ok(Some(Arc::new( + OidcState::new(oidc_cfg, app_config.clone()).await?, + ))) +} + +async fn build_oidc_client( + oidc_cfg: &OidcConfig, + http_client: &Client, +) -> anyhow::Result<(OidcClient, Option)> { + let issuer_url = oidc_cfg.issuer_url.clone(); + let provider_metadata = discover_provider_metadata(http_client, issuer_url.clone()).await?; + let end_session_endpoint = provider_metadata + .additional_metadata() + .end_session_endpoint + .clone(); + let client = make_oidc_client(oidc_cfg, provider_metadata)?; + Ok((client, end_session_endpoint)) +} + +pub struct OidcMiddleware { + oidc_state: Option>, +} + +impl OidcMiddleware { + #[must_use] + pub fn new(app_state: &web::Data) -> Condition { + let oidc_state = app_state.oidc_state.clone(); + Condition::new(oidc_state.is_some(), Self { oidc_state }) + } +} + +async fn discover_provider_metadata( + http_client: &awc::Client, + issuer_url: IssuerUrl, +) -> anyhow::Result { + log::debug!("Discovering provider metadata for {issuer_url}"); + let provider_metadata = ProviderMetadataWithLogout::discover_async( + issuer_url, + &AwcHttpClient::from_client(http_client), + ) + .await + .with_context(|| "Failed to discover OIDC provider metadata".to_string())?; + log::debug!("Provider metadata discovered: {provider_metadata:?}"); + log::debug!( + "end_session_endpoint: {:?}", + provider_metadata.additional_metadata().end_session_endpoint + ); + Ok(provider_metadata) +} + +impl Transform for OidcMiddleware +where + S: Service, Error = Error> + 'static, + S::Future: 'static, +{ + type Response = ServiceResponse; + type Error = Error; + type InitError = (); + type Transform = OidcService; + type Future = std::future::Ready>; + + fn new_transform(&self, service: S) -> Self::Future { + match &self.oidc_state { + Some(state) => ready(Ok(OidcService::new(service, Arc::clone(state)))), + None => ready(Err(())), + } + } +} + +#[derive(Clone)] +pub struct OidcService { + service: Rc, + oidc_state: Arc, +} + +impl OidcService +where + S: Service, Error = Error>, + S::Future: 'static, +{ + pub fn new(service: S, oidc_state: Arc) -> Self { + Self { + service: Rc::new(service), + oidc_state, + } + } +} + +enum MiddlewareResponse { + Forward(ServiceRequest), + Respond(ServiceResponse), +} + +async fn handle_request( + oidc_state: &Arc, + request: ServiceRequest, +) -> MiddlewareResponse { + log::trace!("Started OIDC middleware request handling"); + let http_client = get_http_client_from_appdata(&request).ok(); + if let Some(c) = http_client { + oidc_state.maybe_refresh(c, OIDC_CLIENT_MAX_REFRESH_INTERVAL); + } + + if request.path() == oidc_state.config.redirect_uri { + let response = handle_oidc_callback(oidc_state, request).await; + return MiddlewareResponse::Respond(response); + } + + if request.path() == oidc_state.config.logout_uri { + let response = handle_oidc_logout(oidc_state, request); + return MiddlewareResponse::Respond(response); + } + + match get_authenticated_user_info(oidc_state, &request) { + Ok(Some(claims)) => { + log::trace!("Storing authenticated user info in request extensions: {claims:?}"); + request.extensions_mut().insert(claims); + MiddlewareResponse::Forward(request) + } + Ok(None) => { + log::trace!("No authenticated user found"); + handle_unauthenticated_request(oidc_state, request) + } + Err(e) => { + log::debug!( + "An auth cookie is present but could not be verified. Redirecting to OIDC provider to re-authenticate. {e:?}" + ); + if let Some(c) = http_client { + oidc_state.maybe_refresh(c, OIDC_CLIENT_MIN_REFRESH_INTERVAL); + } + handle_unauthenticated_request(oidc_state, request) + } + } +} + +fn handle_unauthenticated_request( + oidc_state: &OidcState, + request: ServiceRequest, +) -> MiddlewareResponse { + log::debug!("Handling unauthenticated request to {}", request.path()); + + if oidc_state.config.is_public_path(request.path()) { + return MiddlewareResponse::Forward(request); + } + + log::debug!("Redirecting to OIDC provider"); + + let initial_url = request.uri().to_string(); + let redirect_count = get_redirect_count(&request); + let response = + build_auth_provider_redirect_response(oidc_state, &request, &initial_url, redirect_count); + MiddlewareResponse::Respond(request.into_response(response)) +} + +async fn handle_oidc_callback( + oidc_state: &Arc, + request: ServiceRequest, +) -> ServiceResponse { + let span = tracing::info_span!("oidc.callback"); + match process_oidc_callback(oidc_state, &request) + .instrument(span) + .await + { + Ok(mut response) => { + clear_redirect_count_cookie(&mut response); + request.into_response(response) + } + Err(e) => handle_oidc_callback_error(oidc_state, request, &e), + } +} + +fn handle_oidc_callback_error( + oidc_state: &Arc, + request: ServiceRequest, + e: &anyhow::Error, +) -> ServiceResponse { + let redirect_count = get_redirect_count(&request); + if redirect_count >= MAX_OIDC_REDIRECTS { + return handle_max_redirect_count_reached(request, e, redirect_count); + } + log::error!( + "Failed to process OIDC callback (attempt {redirect_count}). Refreshing oidc provider metadata, then redirecting to home page: {e:#}" + ); + if let Ok(http_client) = get_http_client_from_appdata(&request) { + oidc_state.maybe_refresh(http_client, OIDC_CLIENT_MIN_REFRESH_INTERVAL); + } + let resp = build_auth_provider_redirect_response(oidc_state, &request, "/", redirect_count); + request.into_response(resp) +} + +fn handle_max_redirect_count_reached( + request: ServiceRequest, + e: &anyhow::Error, + redirect_count: u8, +) -> ServiceResponse { + log::error!( + "Failed to process OIDC callback after {redirect_count} attempts. \ + Stopping to avoid infinite redirections: {e:#}" + ); + let resp = build_oidc_error_response(&request, e); + request.into_response(resp) +} + +fn handle_oidc_logout(oidc_state: &OidcState, request: ServiceRequest) -> ServiceResponse { + match process_oidc_logout(oidc_state, &request) { + Ok(response) => request.into_response(response), + Err(e) => { + log::error!("Failed to process OIDC logout: {e:#}"); + request.into_response( + HttpResponse::BadRequest() + .content_type("text/plain") + .body(format!("Logout failed: {e}")), + ) + } + } +} + +#[derive(Debug, Deserialize)] +struct LogoutParams { + redirect_uri: String, + timestamp: i64, + signature: String, +} + +const LOGOUT_TOKEN_VALIDITY_SECONDS: i64 = 600; + +fn parse_logout_params(query: &str) -> anyhow::Result { + Query::::from_query(query) + .with_context(|| format!("{SQLPAGE_LOGOUT_URI}: missing required parameters")) + .map(Query::into_inner) +} + +/// Selects the `sqlpage_auth` cookie that a logout URL is bound to. When a +/// request carries duplicate `sqlpage_auth` cookies, this returns the last one, +/// matching how `RequestInfo` merges duplicate cookies (last value wins) and +/// therefore what `sqlpage.oidc_logout_url` signs. `ServiceRequest::cookie` +/// would return the first instead, which made legitimate logout URLs fail with +/// a 400 whenever duplicate cookies were present. +fn logout_session_cookie(request: &ServiceRequest) -> Option> { + request.cookies().ok().and_then(|cookies| { + cookies + .iter() + .rev() + .find(|c| c.name() == SQLPAGE_AUTH_COOKIE_NAME) + .cloned() + }) +} + +fn process_oidc_logout( + oidc_state: &OidcState, + request: &ServiceRequest, +) -> anyhow::Result { + let params = parse_logout_params(request.query_string())?; + + let id_token_cookie = logout_session_cookie(request); + let session_token = id_token_cookie + .as_ref() + .map(Cookie::value) + .unwrap_or_default(); + + // The signature is bound to the session it was issued for, so a logout URL + // cannot be used to forcibly log out a different browser (CSRF). + verify_logout_params(¶ms, session_token, &oidc_state.config.client_secret)?; + + let id_token = id_token_cookie + .as_ref() + .map(|c| OidcToken::from_str(c.value())) + .transpose() + .ok() + .flatten(); + + let mut response = if let Some(end_session_endpoint) = oidc_state.end_session_endpoint() { + let absolute_redirect_uri = oidc_state.build_absolute_redirect_uri(¶ms.redirect_uri)?; + + let post_logout_redirect_uri = PostLogoutRedirectUrl::new(absolute_redirect_uri.clone()) + .with_context(|| { + format!("Invalid post_logout_redirect_uri: {absolute_redirect_uri}") + })?; + + let mut logout_request = LogoutRequest::from(end_session_endpoint) + .set_post_logout_redirect_uri(post_logout_redirect_uri); + + if let Some(ref token) = id_token { + logout_request = logout_request.set_id_token_hint(token); + } + + let logout_url = logout_request.http_get_url(); + log::info!("Redirecting to OIDC logout URL: {logout_url}"); + build_redirect_response(logout_url.to_string()) + } else { + log::info!( + "No end_session_endpoint, redirecting to {}", + params.redirect_uri + ); + build_redirect_response(params.redirect_uri) + }; + + response.add_removal_cookie( + &Cookie::build(SQLPAGE_AUTH_COOKIE_NAME, "") + .path("/") + .finish(), + )?; + response.add_removal_cookie( + &Cookie::build(SQLPAGE_NONCE_COOKIE_NAME, "") + .path("/") + .finish(), + )?; + log::debug!("User logged out successfully"); + Ok(response) +} + +fn compute_logout_signature( + redirect_uri: &str, + timestamp: i64, + session_token: &str, + client_secret: &str, +) -> String { + use base64::Engine; + use hmac::{Hmac, KeyInit, Mac}; + use sha2::Sha256; + + let mut mac = Hmac::::new_from_slice(client_secret.as_bytes()) + .expect("HMAC accepts any key size"); + mac.update(redirect_uri.as_bytes()); + mac.update(×tamp.to_be_bytes()); + // Bind the signature to the session so a logout URL can only log out the + // session it was issued for. The length prefix prevents ambiguity between + // the redirect_uri and session_token fields. + mac.update(&(session_token.len() as u64).to_be_bytes()); + mac.update(session_token.as_bytes()); + let signature = mac.finalize().into_bytes(); + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(signature) +} + +fn verify_logout_params( + params: &LogoutParams, + session_token: &str, + client_secret: &str, +) -> anyhow::Result<()> { + use base64::Engine; + + let expected_signature = compute_logout_signature( + ¶ms.redirect_uri, + params.timestamp, + session_token, + client_secret, + ); + + let provided_signature = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(¶ms.signature) + .with_context(|| "Invalid logout signature encoding")?; + + let expected_signature_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(&expected_signature) + .with_context(|| "Failed to decode expected signature")?; + + if expected_signature_bytes[..] != provided_signature[..] { + anyhow::bail!("Invalid logout signature"); + } + + let now = chrono::Utc::now().timestamp(); + if now - params.timestamp > LOGOUT_TOKEN_VALIDITY_SECONDS { + anyhow::bail!("Logout token has expired"); + } + if params.timestamp > now + 60 { + anyhow::bail!("Logout token timestamp is in the future"); + } + + if !is_safe_relative_redirect(¶ms.redirect_uri) { + anyhow::bail!("Invalid redirect URI"); + } + + Ok(()) +} + +impl Service for OidcService +where + S: Service, Error = Error> + 'static, + S::Future: 'static, +{ + type Response = ServiceResponse; + type Error = Error; + type Future = LocalBoxFuture>; + + forward_ready!(service); + + fn call(&self, request: ServiceRequest) -> Self::Future { + let srv = Rc::clone(&self.service); + let oidc_state = Arc::clone(&self.oidc_state); + Box::pin(async move { + match handle_request(&oidc_state, request).await { + MiddlewareResponse::Respond(response) => Ok(response), + MiddlewareResponse::Forward(request) => srv.call(request).await, + } + }) + } +} + +async fn process_oidc_callback( + oidc_state: &OidcState, + request: &ServiceRequest, +) -> anyhow::Result { + let params = Query::::from_query(request.query_string()) + .with_context(|| format!("{SQLPAGE_REDIRECT_URI}: invalid url parameters"))? + .into_inner(); + log::debug!( + "Processing OIDC callback. Requesting token for state length {}", + params.state.secret().len() + ); + let mut tmp_login_flow_state_cookie = get_tmp_login_flow_state_cookie(request, ¶ms.state)?; + let snapshot = oidc_state.snapshot(); + let http_client = get_http_client_from_appdata(request)?; + let id_token = exchange_code_for_token(&snapshot.client, http_client, params.clone()).await?; + log::debug!("Received OIDC token response with an ID token"); + let LoginFlowState { + nonce, + redirect_target, + } = parse_login_flow_state(&tmp_login_flow_state_cookie)?; + let redirect_target = + validate_redirect_url(redirect_target.to_string(), &oidc_state.config.redirect_uri); + + log::info!("Redirecting to {redirect_target} after a successful login"); + let mut response = build_redirect_response(redirect_target); + set_auth_cookie(&mut response, &id_token); + let claims = oidc_state + .get_token_claims(id_token, &nonce) + .context("The identity provider returned an invalid ID token")?; + log::debug!("{} successfully logged in", claims.subject().as_str()); + let nonce_cookie = create_final_nonce_cookie(&nonce); + response.add_cookie(&nonce_cookie)?; + tmp_login_flow_state_cookie.set_path("/"); // Required to clean up the cookie + response.add_removal_cookie(&tmp_login_flow_state_cookie)?; + Ok(response) +} + +async fn exchange_code_for_token( + oidc_client: &OidcClient, + http_client: &awc::Client, + oidc_callback_params: OidcCallbackParams, +) -> anyhow::Result { + let span = tracing::info_span!( + "http.client", + "otel.name" = "POST token_endpoint", + { otel::HTTP_REQUEST_METHOD } = "POST", + ); + let token_response = oidc_client + .exchange_code(openidconnect::AuthorizationCode::new( + oidc_callback_params.code, + ))? + .request_async(&AwcHttpClient::from_client(http_client)) + .instrument(span) + .await + .context("Failed to exchange code for token")?; + let access_token = token_response.access_token(); + log::trace!( + "Received OIDC access token with length {}", + access_token.secret().len() + ); + let id_token = token_response + .id_token() + .context("No ID token found in the token response. You may have specified an oauth2 provider that does not support OIDC.")?; + Ok(id_token.clone()) +} + +fn set_auth_cookie(response: &mut HttpResponse, id_token: &OidcToken) { + let id_token_str = id_token.to_string(); + let id_token_size_kb = id_token_str.len() / 1024; + log::trace!( + "Setting auth cookie {SQLPAGE_AUTH_COOKIE_NAME} with value length {} bytes", + id_token_str.len() + ); + if id_token_size_kb > 4 { + log::warn!( + "The ID token cookie from the OIDC provider is {id_token_size_kb}kb. \ + Large cookies can cause performance issues and may be rejected by browsers or by reverse proxies." + ); + } + let cookie = Cookie::build(SQLPAGE_AUTH_COOKIE_NAME, id_token_str) + .secure(true) + .http_only(true) + .max_age(AUTH_COOKIE_EXPIRATION) + .same_site(actix_web::cookie::SameSite::Lax) + .path("/") + .finish(); + + response.add_cookie(&cookie).unwrap(); +} + +fn build_auth_provider_redirect_response( + oidc_state: &OidcState, + request: &ServiceRequest, + initial_url: &str, + redirect_count: u8, +) -> HttpResponse { + let AuthUrl { url, params } = build_auth_url(oidc_state); + let tmp_login_flow_state_cookie = create_tmp_login_flow_state_cookie(¶ms, initial_url); + let redirect_count_cookie = Cookie::build( + SQLPAGE_OIDC_REDIRECT_COUNT_COOKIE, + (redirect_count + 1).to_string(), + ) + .path("/") + .http_only(true) + .same_site(actix_web::cookie::SameSite::Lax) + .max_age(LOGIN_FLOW_STATE_COOKIE_EXPIRATION) + .finish(); + let mut response = HttpResponse::SeeOther(); + response.append_header((header::LOCATION, url.to_string())); + // The location contains a one-time CSRF state. A cached redirect would + // replay it after its state cookie has been consumed. + response.append_header((header::CACHE_CONTROL, "no-store")); + if let Ok(cookies) = request.cookies() { + for mut cookie in get_tmp_login_flow_state_cookies_to_evict(&cookies).cloned() { + cookie.make_removal(); + response.cookie(cookie); + } + } + response.cookie(tmp_login_flow_state_cookie); + response.cookie(redirect_count_cookie); + response.body("Redirecting...") +} + +fn build_redirect_response(target_url: String) -> HttpResponse { + HttpResponse::SeeOther() + .append_header(("Location", target_url)) + .append_header((header::CACHE_CONTROL, "no-store")) + .body("Redirecting...") +} + +fn get_redirect_count(request: &ServiceRequest) -> u8 { + request + .cookie(SQLPAGE_OIDC_REDIRECT_COUNT_COOKIE) + .and_then(|c| c.value().parse().ok()) + .unwrap_or(0) +} + +fn clear_redirect_count_cookie(response: &mut HttpResponse) { + let cookie = Cookie::build(SQLPAGE_OIDC_REDIRECT_COUNT_COOKIE, "") + .path("/") + .finish() + .into_owned(); + response.add_removal_cookie(&cookie).ok(); +} + +fn build_oidc_error_response(request: &ServiceRequest, e: &anyhow::Error) -> HttpResponse { + request.app_data::>().map_or_else( + || HttpResponse::InternalServerError().body(format!("Authentication error: {e}")), + |state| anyhow_err_to_actix_resp(e, state), + ) +} + +/// Returns the claims from the ID token in the `SQLPage` auth cookie. +fn get_authenticated_user_info( + oidc_state: &OidcState, + request: &ServiceRequest, +) -> anyhow::Result> { + let Some(cookie) = request.cookie(SQLPAGE_AUTH_COOKIE_NAME) else { + return Ok(None); + }; + let cookie_value = cookie.value().to_string(); + let id_token = OidcToken::from_str(&cookie_value) + .with_context(|| format!("Invalid SQLPage auth cookie: {cookie_value:?}"))?; + + let nonce = get_final_nonce_from_cookie(request)?; + log::debug!( + "Verifying ID token from auth cookie with length {} bytes", + cookie_value.len() + ); + let claims = oidc_state.get_token_claims(id_token, &nonce)?; + log::debug!("Authenticated user subject: {}", claims.subject().as_str()); + Ok(Some(claims)) +} + +pub struct AwcHttpClient<'c> { + client: &'c awc::Client, +} + +impl<'c> AwcHttpClient<'c> { + #[must_use] + pub fn from_client(client: &'c awc::Client) -> Self { + Self { client } + } +} + +impl<'c> AsyncHttpClient<'c> for AwcHttpClient<'c> { + type Error = AwcWrapperError; + type Future = + Pin> + 'c>>; + + fn call(&'c self, request: openidconnect::HttpRequest) -> Self::Future { + let client = self.client.clone(); + Box::pin(async move { + execute_oidc_request_with_awc(client, request) + .await + .map_err(AwcWrapperError) + }) + } +} + +async fn execute_oidc_request_with_awc( + client: Client, + request: openidconnect::HttpRequest, +) -> Result>, anyhow::Error> { + let awc_method = awc::http::Method::from_bytes(request.method().as_str().as_bytes())?; + let awc_uri = awc::http::Uri::from_str(&request.uri().to_string())?; + log::debug!("Executing OIDC request: {awc_method} {awc_uri}"); + let mut req = client.request(awc_method, awc_uri); + for (name, value) in request.headers() { + req = req.insert_header((name.as_str(), value.to_str()?)); + } + let (req_head, body) = request.into_parts(); + let response = req.send_body(body).await.map_err(|e| { + anyhow!(e.to_string()).context(format!( + "Failed to send request: {} {}", + &req_head.method, &req_head.uri + )) + })?; + let head = response.headers(); + log::debug!( + "Received OIDC response headers: status={}, content_type={:?}", + response.status(), + head.get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + ); + let mut resp_builder = + openidconnect::http::Response::builder().status(response.status().as_u16()); + for (name, value) in head { + resp_builder = resp_builder.header(name.as_str(), value.to_str()?); + } + let mut response = response.timeout(OIDC_HTTP_BODY_TIMEOUT); + let body = response + .body() + .await + .with_context(|| format!("Couldnt read from {}", &req_head.uri))?; + log::debug!("Received OIDC response body_len={} bytes", body.len()); + let resp = resp_builder.body(body.to_vec())?; + Ok(resp) +} + +#[derive(Debug)] +pub struct AwcWrapperError(anyhow::Error); + +impl std::fmt::Display for AwcWrapperError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Display::fmt(&self.0, f) + } +} + +type OidcTokenResponse = StandardTokenResponse< + IdTokenFields< + OidcAdditionalClaims, + EmptyExtraTokenFields, + CoreGenderClaim, + CoreJweContentEncryptionAlgorithm, + CoreJwsSigningAlgorithm, + >, + CoreTokenType, +>; + +type OidcClient = openidconnect::Client< + OidcAdditionalClaims, + CoreAuthDisplay, + CoreGenderClaim, + CoreJweContentEncryptionAlgorithm, + CoreJsonWebKey, + CoreAuthPrompt, + StandardErrorResponse, + OidcTokenResponse, + CoreTokenIntrospectionResponse, + CoreRevocableToken, + CoreRevocationErrorResponse, + EndpointSet, + EndpointNotSet, + EndpointNotSet, + EndpointNotSet, + EndpointMaybeSet, + EndpointMaybeSet, +>; + +impl std::error::Error for AwcWrapperError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + self.0.source() + } +} + +fn make_oidc_client( + config: &OidcConfig, + provider_metadata: ProviderMetadataWithLogout, +) -> anyhow::Result { + let client_id = openidconnect::ClientId::new(config.client_id.clone()); + let client_secret = openidconnect::ClientSecret::new(config.client_secret.clone()); + + let mut redirect_url = RedirectUrl::new(format!( + "https://{}{}", + config.app_host, config.redirect_uri, + )) + .with_context(|| { + format!( + "Failed to build the redirect URL; invalid app host \"{}\"", + config.app_host + ) + })?; + let needs_http = match redirect_url.url().host() { + Some(openidconnect::url::Host::Domain(domain)) => { + domain == "localhost" || domain.ends_with(".localhost") + } + Some(openidconnect::url::Host::Ipv4(_) | openidconnect::url::Host::Ipv6(_)) => true, + None => false, + }; + if needs_http { + log::debug!("App host seems to be local, changing redirect URL to HTTP"); + redirect_url = + RedirectUrl::new(format!("http://{}{}", config.app_host, config.redirect_uri))?; + } + log::info!("OIDC redirect URL for {}: {redirect_url}", config.client_id); + let client = + OidcClient::from_provider_metadata(provider_metadata, client_id, Some(client_secret)) + .set_redirect_uri(redirect_url); + + Ok(client) +} + +#[derive(Debug, Deserialize, Clone)] +struct OidcCallbackParams { + code: String, + state: CsrfToken, +} + +struct AuthUrl { + url: Url, + params: AuthUrlParams, +} + +struct AuthUrlParams { + csrf_token: CsrfToken, + nonce: Nonce, +} + +fn build_auth_url(oidc_state: &OidcState) -> AuthUrl { + let nonce_source = Nonce::new_random(); + let hashed_nonce = Nonce::new(hash_nonce(&nonce_source)); + let scopes = &oidc_state.config.scopes; + let snapshot = oidc_state.snapshot(); + let (url, csrf_token, _nonce) = snapshot + .client + .authorize_url( + CoreAuthenticationFlow::AuthorizationCode, + CsrfToken::new_random, + || hashed_nonce, + ) + .add_scopes(scopes.iter().cloned()) + .url(); + AuthUrl { + url, + params: AuthUrlParams { + csrf_token, + nonce: nonce_source, + }, + } +} + +fn hash_nonce(nonce: &Nonce) -> String { + use argon2::password_hash::{PasswordHasher, SaltString, rand_core::OsRng}; + let salt = SaltString::generate(&mut OsRng); + // low-cost parameters: oidc tokens are short-lived and the source nonce is high-entropy + let params = argon2::Params::new(8, 1, 1, Some(16)).expect("bug: invalid Argon2 parameters"); + let argon2 = argon2::Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params); + let hash = argon2 + .hash_password(nonce.secret().as_bytes(), &salt) + .expect("bug: failed to hash nonce"); + hash.to_string() +} + +fn check_nonce(id_token_nonce: Option<&Nonce>, expected_nonce: &Nonce) -> Result<(), String> { + match id_token_nonce { + Some(id_token_nonce) => nonce_matches(id_token_nonce, expected_nonce), + None => Err("No nonce found in the ID token".to_string()), + } +} + +fn nonce_matches(id_token_nonce: &Nonce, state_nonce: &Nonce) -> Result<(), String> { + log::debug!( + "Checking nonce: {} == {}", + id_token_nonce.secret(), + state_nonce.secret() + ); + let hash = argon2::password_hash::PasswordHash::new(id_token_nonce.secret()).map_err(|e| { + format!( + "Failed to parse state nonce ({}): {e}", + id_token_nonce.secret() + ) + })?; + argon2::password_hash::PasswordVerifier::verify_password( + &argon2::Argon2::default(), + state_nonce.secret().as_bytes(), + &hash, + ) + .map_err(|e| format!("Failed to verify nonce ({}): {e}", state_nonce.secret()))?; + log::debug!("Nonce successfully verified"); + Ok(()) +} + +fn create_final_nonce_cookie(nonce: &Nonce) -> Cookie<'_> { + Cookie::build(SQLPAGE_NONCE_COOKIE_NAME, nonce.secret()) + .secure(true) + .http_only(true) + .same_site(actix_web::cookie::SameSite::Lax) + .max_age(AUTH_COOKIE_EXPIRATION) + .path("/") + .finish() +} + +fn create_tmp_login_flow_state_cookie<'a>( + params: &'a AuthUrlParams, + initial_url: &'a str, +) -> Cookie<'a> { + let csrf_token = ¶ms.csrf_token; + let cookie_name = SQLPAGE_TMP_LOGIN_STATE_COOKIE_PREFIX.to_owned() + csrf_token.secret(); + let cookie_value = serde_json::to_string(&LoginFlowState { + nonce: params.nonce.clone(), + redirect_target: initial_url, + }) + .expect("login flow state is always serializable"); + Cookie::build(cookie_name, cookie_value) + .secure(true) + .http_only(true) + .same_site(actix_web::cookie::SameSite::Lax) + .path("/") + .max_age(LOGIN_FLOW_STATE_COOKIE_EXPIRATION) + .finish() +} + +fn get_final_nonce_from_cookie(request: &ServiceRequest) -> anyhow::Result { + let cookie = request + .cookie(SQLPAGE_NONCE_COOKIE_NAME) + .with_context(|| format!("No {SQLPAGE_NONCE_COOKIE_NAME} cookie found"))?; + Ok(Nonce::new(cookie.value().to_string())) +} + +fn get_tmp_login_flow_state_cookie( + request: &ServiceRequest, + csrf_token: &CsrfToken, +) -> anyhow::Result> { + let cookie_name = SQLPAGE_TMP_LOGIN_STATE_COOKIE_PREFIX.to_owned() + csrf_token.secret(); + request + .cookie(&cookie_name) + .with_context(|| format!("No {cookie_name} cookie found")) +} + +fn get_tmp_login_flow_state_cookies_to_evict<'a>( + cookies: &'a [Cookie<'static>], +) -> impl Iterator> + 'a { + let is_state = &|c: &Cookie<'_>| c.name().starts_with(SQLPAGE_TMP_LOGIN_STATE_COOKIE_PREFIX); + let login_state_count = cookies.iter().filter(|c| is_state(c)).count(); + let to_evict = login_state_count.saturating_sub(MAX_OIDC_PARALLEL_LOGIN_FLOWS - 1); + cookies.iter().filter(|c| is_state(c)).take(to_evict) +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +struct LoginFlowState<'a> { + #[serde(rename = "n")] + nonce: Nonce, + #[serde(rename = "r")] + redirect_target: &'a str, +} + +fn parse_login_flow_state<'a>(cookie: &'a Cookie<'_>) -> anyhow::Result> { + serde_json::from_str(cookie.value()) + .with_context(|| format!("Invalid login flow state cookie: {}", cookie.value())) +} + +/// Given an audience, verify if it is trusted. The `client_id` is always trusted, independently of this function. +#[derive(Clone, Debug)] +pub struct AudienceVerifier(Option>); + +impl AudienceVerifier { + /// JWT audiences (aud claim) are always required to contain the `client_id`, but they can also contain additional audiences. + /// By default we allow any additional audience. + /// The user can restrict the allowed additional audiences by providing a list of trusted audiences. + fn new(additional_trusted_audiences: Option>) -> Self { + AudienceVerifier(additional_trusted_audiences.map(HashSet::from_iter)) + } + + /// Returns a function that given an audience, verifies if it is trusted. + fn as_fn(&self) -> impl Fn(&Audience) -> bool + '_ { + move |aud: &Audience| -> bool { + let Some(trusted_set) = &self.0 else { + return true; + }; + trusted_set.contains(aud.as_str()) + } + } +} + +/// Returns true if the given value is a safe relative redirect target. +/// +/// Only paths starting with a single `/` (not `//`), with no backslash and no +/// ASCII control characters, are accepted. The WHATWG URL Standard treats `\` as +/// equivalent to `/` for special schemes (http/https) and strips tab/newline/CR +/// before parsing, so `/\evil.test` and `/\t/evil.test` both parse to the +/// authority `evil.test`, i.e. `http://evil.test/`. The `url` crate that builds +/// the absolute `post_logout_redirect_uri` is itself a WHATWG parser, so without +/// this check a value classified as "relative" becomes an external open-redirect +/// target on the server side, independent of the client. Browsers implementing +/// the same standard (Chromium, Firefox, Safari) resolve a `Location: /\evil.test` +/// the same way. +pub(crate) fn is_safe_relative_redirect(uri: &str) -> bool { + // Reject backslashes and ASCII control characters. The WHATWG URL parser + // used by SQLPage's `url` crate (and by browsers) treats `\` as `/`, and it + // removes ASCII tab/newline/CR from anywhere in the input before parsing. + // Either can smuggle in an authority component: `/\evil.test` and + // `/\t/evil.test` both resolve to `https://evil.test/`. + if uri.contains('\\') || uri.contains(|c: char| c.is_ascii_control()) { + return false; + } + let mut chars = uri.chars(); + // Must start with `/`... + if chars.next() != Some('/') { + return false; + } + // ...but the second character must not be `/` (protocol-relative authority). + !matches!(chars.next(), Some('/')) +} + +/// Validate that a redirect URL is safe to use (prevents open redirect attacks) +fn validate_redirect_url(url: String, redirect_uri: &str) -> String { + if is_safe_relative_redirect(&url) && !url.starts_with(redirect_uri) { + return url; + } + log::warn!("Refusing to redirect to {url}"); + '/'.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use actix_web::http::StatusCode; + use actix_web::{cookie::Cookie, test::TestRequest}; + use openidconnect::url::Url; + + #[test] + fn relative_redirect_rejects_authority_and_backslash_forms() { + // Legitimate relative paths must be accepted. + for ok in ["/", "/foo", "/foo/bar?x=1", "/a/b/c#frag"] { + assert!( + is_safe_relative_redirect(ok), + "expected {ok:?} to be accepted" + ); + } + // Authority and backslash forms must all be rejected: SQLPage's `url` + // crate (a WHATWG URL parser) normalizes these into an external + // `http://evil.test/` target. + for bad in [ + "//evil.test", + "/\\evil.test", + "/\\/evil.test", + "\\evil.test", + "\\\\evil.test", + "/foo\\bar", + // ASCII tab/newline/CR are stripped by the WHATWG URL parser, so + // these resolve to an authority (`//evil.test`) after stripping. + "/\t/evil.test", + "/\n/evil.test", + "/\r/evil.test", + "/\u{0000}/evil.test", + "https://evil.test", + "evil.test", + "", + ] { + assert!( + !is_safe_relative_redirect(bad), + "expected {bad:?} to be rejected" + ); + } + } + + #[test] + fn login_redirects_use_see_other() { + let response = build_redirect_response("/foo".to_string()); + assert_eq!(response.status(), StatusCode::SEE_OTHER); + let location = response + .headers() + .get(header::LOCATION) + .expect("missing location header") + .to_str() + .expect("invalid location header"); + assert_eq!(location, "/foo"); + } + + #[test] + fn parse_auth0_rfc3339_updated_at() { + let claims_json = r#"{ + "sub": "auth0|123456", + "iss": "https://example.auth0.com/", + "aud": "test-client-id", + "iat": 1700000000, + "exp": 1700086400, + "updated_at": "2023-11-14T12:00:00.000Z" + }"#; + let claims: OidcClaims = serde_json::from_str(claims_json) + .expect("Auth0 returns updated_at as RFC3339 string, not unix timestamp"); + assert!(claims.updated_at().is_some()); + } + + #[test] + fn logout_url_generation_and_parsing_are_compatible() { + let secret = "super_secret_key"; + let config = OidcConfig { + issuer_url: IssuerUrl::new("https://example.com".to_string()).unwrap(), + client_id: "test_client".to_string(), + client_secret: secret.to_string(), + protected_paths: vec![], + public_paths: vec![], + app_host: "example.com".to_string(), + scopes: vec![], + additional_audience_verifier: AudienceVerifier::new(None), + site_prefix: "https://example.com".to_string(), + redirect_uri: format!("https://example.com{SQLPAGE_REDIRECT_URI}"), + logout_uri: format!("https://example.com{SQLPAGE_LOGOUT_URI}"), + }; + let generated = config.create_logout_url("/after", Some("session-token")); + + let parsed = Url::parse(&generated).expect("generated URL should be valid"); + assert_eq!(parsed.path(), SQLPAGE_LOGOUT_URI); + + let params = parse_logout_params(parsed.query().expect("query string is present")) + .expect("generated URL should parse"); + verify_logout_params(¶ms, "session-token", secret) + .expect("generated URL should validate for the session it was issued for"); + } + + /// A logout URL is bound to the session it was issued for: presenting it + /// from a different browser (a different `sqlpage_auth` cookie), or with + /// no session at all, must NOT validate. This is what prevents a forced + /// logout CSRF where a logout URL generated for one user logs out another. + #[test] + fn logout_url_is_bound_to_the_issuing_session() { + let secret = "super_secret_key"; + let config = OidcConfig { + issuer_url: IssuerUrl::new("https://example.com".to_string()).unwrap(), + client_id: "test_client".to_string(), + client_secret: secret.to_string(), + protected_paths: vec![], + public_paths: vec![], + app_host: "example.com".to_string(), + scopes: vec![], + additional_audience_verifier: AudienceVerifier::new(None), + site_prefix: "https://example.com".to_string(), + redirect_uri: format!("https://example.com{SQLPAGE_REDIRECT_URI}"), + logout_uri: format!("https://example.com{SQLPAGE_LOGOUT_URI}"), + }; + + // A logout URL issued for victim's session. + let victim_session = "victim-auth-token"; + let generated = config.create_logout_url("/after", Some(victim_session)); + let parsed = Url::parse(&generated).expect("generated URL should be valid"); + let params = parse_logout_params(parsed.query().expect("query string is present")) + .expect("generated URL should parse"); + + // It validates for the session it was issued for. + verify_logout_params(¶ms, victim_session, secret) + .expect("must validate for the issuing session"); + + // It must NOT validate when presented by a different session... + verify_logout_params(¶ms, "attacker-auth-token", secret) + .expect_err("must reject a different session's auth cookie"); + // ...nor when presented with no session at all (replay/CSRF). + verify_logout_params(¶ms, "", secret) + .expect_err("must reject a request with no auth cookie"); + } + + #[test] + fn logout_session_cookie_uses_last_duplicate_like_request_info() { + // Two sqlpage_auth cookies (e.g. set with different Path attributes). + // actix's ServiceRequest::cookie returns the first, but RequestInfo (and + // thus sqlpage.oidc_logout_url, which signs the logout URL) keeps the + // last after merging duplicates. Verification must use the same value + // the URL was signed with, otherwise a legitimate logout URL is 400ed. + let request = TestRequest::default() + .insert_header(("cookie", "sqlpage_auth=first; sqlpage_auth=second")) + .to_srv_request(); + assert_eq!( + request + .cookie(SQLPAGE_AUTH_COOKIE_NAME) + .as_ref() + .map(Cookie::value), + Some("first"), + "actix selects the first duplicate cookie" + ); + assert_eq!( + logout_session_cookie(&request).as_ref().map(Cookie::value), + Some("second"), + "logout binding must use the last duplicate, matching RequestInfo and the signed URL" + ); + } + + fn test_oidc_config_with_paths( + protected_paths: Vec, + public_paths: Vec, + ) -> OidcConfig { + OidcConfig { + issuer_url: IssuerUrl::new("https://example.com".to_string()).unwrap(), + client_id: "test_client".to_string(), + client_secret: "secret".to_string(), + protected_paths, + public_paths, + app_host: "example.com".to_string(), + scopes: vec![], + additional_audience_verifier: AudienceVerifier::new(None), + site_prefix: "/".to_string(), + redirect_uri: SQLPAGE_REDIRECT_URI.to_string(), + logout_uri: SQLPAGE_LOGOUT_URI.to_string(), + } + } + + #[test] + fn percent_encoded_protected_path_is_not_public() { + let config = test_oidc_config_with_paths(vec!["/protected".to_string()], vec![]); + assert!( + !config.is_public_path("/protected/"), + "/protected/ must be protected" + ); + // Encoding a byte of the prefix (%70 == 'p') must not bypass protection: + // the router percent-decodes the path and serves the protected file. + assert!( + !config.is_public_path("/%70rotected/"), + "/%70rotected/ decodes to /protected/ and must stay protected" + ); + } + + #[test] + fn percent_encoded_public_path_stays_public() { + let config = test_oidc_config_with_paths( + vec!["/protected".to_string()], + vec!["/protected/public".to_string()], + ); + assert!( + config.is_public_path("/protected/%70ublic/page.sql"), + "/protected/%70ublic/... decodes to a public path" + ); + } + + #[test] + fn encoded_site_prefix_keeps_protected_paths_protected() { + // With a site_prefix like `/my app/`, the configured prefixes are stored + // encoded (`/my%20app/...`). Decoding only the request path would never + // match, so protected pages must not become public. + let config = test_oidc_config_with_paths(vec!["/my%20app/protected".to_string()], vec![]); + assert!(!config.is_public_path("/my%20app/protected/page.sql")); + assert!(!config.is_public_path("/my%20app/%70rotected/")); + assert!( + config.is_public_path("/my%20app/login.sql"), + "a non-protected page under the site prefix stays public" + ); + } + + #[test] + fn trailing_slash_public_prefix_does_not_expose_sibling_file() { + // A public exception for the `/public/` directory must not also make the + // extensionless `/public` (which the router resolves to public.sql) + // public. Plain string-prefix matching keeps them distinct. + let config = + test_oidc_config_with_paths(vec!["/".to_string()], vec!["/public/".to_string()]); + assert!( + config.is_public_path("/public/page.sql"), + "files under /public/ are public" + ); + assert!( + !config.is_public_path("/public"), + "/public (the file public.sql) stays protected" + ); + } + + #[test] + fn evicts_excess_tmp_login_flow_state_cookies() { + let request = (0..MAX_OIDC_PARALLEL_LOGIN_FLOWS) + .fold(TestRequest::default(), |request, i| { + request.cookie(Cookie::new( + format!("{SQLPAGE_TMP_LOGIN_STATE_COOKIE_PREFIX}{i}"), + format!("value-{i}"), + )) + }) + .to_srv_request(); + + let cookies = request.cookies().unwrap(); + let cookies_to_evict: Vec<_> = + get_tmp_login_flow_state_cookies_to_evict(&cookies).collect(); + + assert_eq!(cookies_to_evict.len(), 1); + assert!( + cookies_to_evict[0] + .name() + .starts_with(SQLPAGE_TMP_LOGIN_STATE_COOKIE_PREFIX) + ); + } +} diff --git a/src/webserver/request_variables.rs b/src/webserver/request_variables.rs new file mode 100644 index 0000000..1b73f41 --- /dev/null +++ b/src/webserver/request_variables.rs @@ -0,0 +1,28 @@ +use std::collections::{HashMap, hash_map::Entry}; + +use crate::webserver::single_or_vec::SingleOrVec; + +pub type ParamMap = HashMap; +pub type SetVariablesMap = HashMap>; + +pub fn param_map>(values: PAIRS) -> ParamMap { + values + .into_iter() + .fold(HashMap::new(), |mut map, (mut k, v)| { + let entry = if k.ends_with("[]") { + k.replace_range(k.len() - 2.., ""); + SingleOrVec::Vec(vec![v]) + } else { + SingleOrVec::Single(v) + }; + match map.entry(k) { + Entry::Occupied(mut s) => { + SingleOrVec::merge(s.get_mut(), entry); + } + Entry::Vacant(v) => { + v.insert(entry); + } + } + map + }) +} diff --git a/src/webserver/response_writer.rs b/src/webserver/response_writer.rs new file mode 100644 index 0000000..1f506a7 --- /dev/null +++ b/src/webserver/response_writer.rs @@ -0,0 +1,149 @@ +use actix_web::web::Bytes; +use std::io::Write; +use std::mem; +use std::pin::Pin; +use tokio::sync::mpsc; + +/// The response writer is a buffered async writer that sends data to the client. +/// Writing to it just appends to an in-memory buffer, which is flushed to the client asynchronously +/// when `async_flush()` is called. +/// This allows streaming data to the client without blocking, and has built-in back-pressure: +/// if the client cannot keep up with the data, `async_flush()` will fill the sending queue, +/// then block until the client has consumed some data. +#[derive(Clone)] +pub struct ResponseWriter { + buffer: Vec, + response_bytes: mpsc::Sender, +} + +impl ResponseWriter { + #[must_use] + pub fn new(response_bytes: mpsc::Sender) -> Self { + Self { + response_bytes, + buffer: Vec::new(), + } + } + + pub async fn close_with_error(&mut self, mut msg: String) { + if !self.response_bytes.is_closed() { + if let Err(e) = self.async_flush().await { + use std::fmt::Write; + write!(&mut msg, "Unable to flush data: {e}").unwrap(); + } + if let Err(e) = self.response_bytes.send(msg.into()).await { + log::error!("Unable to send error back to client: {e}"); + } + } + } + + pub async fn async_flush(&mut self) -> std::io::Result<()> { + if self.buffer.is_empty() { + return Ok(()); + } + log::trace!( + "Flushing data to client: {}", + String::from_utf8_lossy(&self.buffer) + ); + let sender = self + .response_bytes + .reserve() + .await + .map_err(|_| std::io::ErrorKind::WouldBlock)?; + sender.send(std::mem::take(&mut self.buffer).into()); + Ok(()) + } +} + +impl Write for ResponseWriter { + #[inline] + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.buffer.extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + if self.buffer.is_empty() { + return Ok(()); + } + log::trace!( + "Flushing data to client: {}", + String::from_utf8_lossy(&self.buffer) + ); + self.response_bytes + .try_send(mem::take(&mut self.buffer).into()) + .map_err(|e| + std::io::Error::new( + std::io::ErrorKind::WouldBlock, + format!("{e}: Row limit exceeded. The server cannot store more than {} pending messages in memory. Try again later or increase max_pending_rows in the configuration.", self.response_bytes.max_capacity()) + ) + ) + } +} + +#[allow(clippy::module_name_repetitions)] +pub struct AsyncResponseWriter { + poll_sender: tokio_util::sync::PollSender, + writer: ResponseWriter, +} + +impl AsyncResponseWriter { + #[must_use] + pub fn new(writer: ResponseWriter) -> Self { + let sender = writer.response_bytes.clone(); + Self { + poll_sender: tokio_util::sync::PollSender::new(sender), + writer, + } + } + + #[must_use] + pub fn into_inner(self) -> ResponseWriter { + self.writer + } +} + +impl tokio::io::AsyncWrite for AsyncResponseWriter { + fn poll_write( + mut self: Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + buf: &[u8], + ) -> std::task::Poll> { + std::task::Poll::Ready(self.as_mut().writer.write(buf)) + } + + fn poll_flush( + self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + let Self { + poll_sender, + writer, + } = self.get_mut(); + match poll_sender.poll_reserve(cx) { + std::task::Poll::Ready(Ok(())) => { + let res = poll_sender.send_item(std::mem::take(&mut writer.buffer).into()); + std::task::Poll::Ready(res.map_err(|_| std::io::ErrorKind::BrokenPipe.into())) + } + std::task::Poll::Pending => std::task::Poll::Pending, + std::task::Poll::Ready(Err(_e)) => { + std::task::Poll::Ready(Err(std::io::ErrorKind::BrokenPipe.into())) + } + } + } + + fn poll_shutdown( + self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + self.poll_flush(cx) + } +} + +impl Drop for ResponseWriter { + fn drop(&mut self) { + if let Err(e) = std::io::Write::flush(self) { + log::debug!("Could not flush data to client: {e}"); + } + } +} diff --git a/src/webserver/routing.rs b/src/webserver/routing.rs new file mode 100644 index 0000000..0bc92bf --- /dev/null +++ b/src/webserver/routing.rs @@ -0,0 +1,755 @@ +//! This module determines how incoming HTTP requests are mapped to +//! SQL files for execution, static assets for serving, or error pages. +//! +//! ## Routing Rules +//! +//! `SQLPage` follows a file-based routing system with the following precedence: +//! +//! ### 1. Site Prefix Handling +//! - If a `site_prefix` is configured and the request path doesn't start with it, redirect to the prefixed path +//! - All subsequent routing operates on the path after stripping the prefix +//! +//! ### 2. Path Resolution (in order of precedence) +//! +//! #### Paths ending with `/` (directories): +//! - Look for `index.sql` in that directory +//! - If found: **Execute** the SQL file +//! - If not found: Look for custom 404 handlers (see Error Handling below) +//! +//! #### Paths with `.sql` extension: +//! - If the file exists: **Execute** the SQL file +//! - If not found: Look for custom 404 handlers (see Error Handling below) +//! +//! #### Paths with other extensions (assets): +//! - If the file exists: **Serve** the static file +//! - If not found but `{path}.sql` exists: **Execute** the SQL file +//! - If neither found: Look for custom 404 handlers (see Error Handling below) +//! +//! #### Paths without extension: +//! - First, try to find `{path}.sql` and **Execute** if found +//! - If no SQL file found but `{path}/index.sql` exists: **Redirect** to `{path}/` +//! - Otherwise: Look for custom 404 handlers (see Error Handling below) +//! +//! ### 3. Error Handling (404 cases) +//! +//! When a requested file is not found, `SQLPage` looks for custom 404 handlers: +//! +//! - Starting from the requested path's directory, walk up the directory tree +//! - Look for `404.sql` in each parent directory +//! - If found: **Execute** the custom 404 SQL file +//! - If no custom 404 found anywhere: Return default **404 Not Found** response +//! +//! ## Examples +//! +//! ```text +//! Request: GET / +//! Result: Execute index.sql +//! +//! Request: GET /users +//! - If users.sql exists: Execute users.sql +//! - Else if users/index.sql exists: Redirect to /users/ +//! - Else if 404.sql exists: Execute 404.sql +//! - Else: Default 404 +//! +//! Request: GET /users/ +//! - If users/index.sql exists: Execute users/index.sql +//! - Else if users/404.sql exists: Execute users/404.sql +//! - Else if 404.sql exists: Execute 404.sql +//! - Else: Default 404 +//! +//! Request: GET /api/users.sql +//! - If api/users.sql exists: Execute api/users.sql +//! - Else if api/404.sql exists: Execute api/404.sql +//! - Else if 404.sql exists: Execute 404.sql +//! - Else: Default 404 +//! +//! Request: GET /favicon.ico +//! - If favicon.ico exists: Serve favicon.ico +//! - Else if 404.sql exists: Execute 404.sql +//! - Else: Default 404 +//! +//! Request: GET /api/data.json +//! - If api/data.json exists: Serve api/data.json +//! - Else if api/data.json.sql exists: Execute api/data.json.sql +//! - Else if api/404.sql exists: Execute api/404.sql +//! - Else if 404.sql exists: Execute 404.sql +//! - Else: Default 404 +//! ``` + +use crate::filesystem::{FileAccess, FileSystem}; +use crate::webserver::database::ParsedSqlFile; +use crate::{AppState, file_cache::FileCache}; +use RoutingAction::{CustomNotFound, Execute, NotFound, Redirect, Serve}; +use awc::http::uri::PathAndQuery; +use log::debug; +use percent_encoding; +use std::path::{Path, PathBuf}; + +const INDEX: &str = "index.sql"; +const NOT_FOUND: &str = "404.sql"; +const SQL_EXTENSION: &str = "sql"; +const FORWARD_SLASH: &str = "/"; + +#[derive(Debug, PartialEq)] +pub enum RoutingAction { + CustomNotFound(PathBuf), + Execute(PathBuf), + NotFound, + Redirect(String), + Serve(PathBuf), +} + +#[expect(async_fn_in_trait)] +pub trait FileStore { + async fn contains(&self, access: FileAccess<'_>) -> anyhow::Result; +} + +pub trait RoutingConfig { + fn prefix(&self) -> &str; +} + +pub(crate) struct AppFileStore<'a> { + cache: &'a FileCache, + filesystem: &'a FileSystem, + app_state: &'a AppState, +} + +impl<'a> AppFileStore<'a> { + pub fn new( + cache: &'a FileCache, + filesystem: &'a FileSystem, + app_state: &'a AppState, + ) -> Self { + Self { + cache, + filesystem, + app_state, + } + } +} + +impl FileStore for AppFileStore<'_> { + async fn contains(&self, access: FileAccess<'_>) -> anyhow::Result { + if self.cache.contains(access).await? { + Ok(true) + } else { + self.filesystem.file_exists(self.app_state, access).await + } + } +} + +pub async fn calculate_route( + path_and_query: &PathAndQuery, + store: &T, + config: &C, +) -> anyhow::Result +where + T: FileStore, + C: RoutingConfig, +{ + let result = match check_path(path_and_query, config) { + Ok(path) => match path.extension().and_then(|e| e.to_str()) { + Some(SQL_EXTENSION) => find_file_or_not_found(&path, SQL_EXTENSION, store).await?, + Some(extension) => match find_file(&path, extension, store).await? { + Some(action) => action, + None => calculate_route_without_extension(path_and_query, path, store).await?, + }, + None => calculate_route_without_extension(path_and_query, path, store).await?, + }, + Err(action) => action, + }; + debug!("Route: [{path_and_query}] -> {result:?}"); + Ok(result) +} + +fn check_path(path_and_query: &PathAndQuery, config: &C) -> Result +where + C: RoutingConfig, +{ + match path_and_query.path().strip_prefix(config.prefix()) { + None => Err(Redirect(config.prefix().to_string())), + Some(path) => { + let decoded = percent_encoding::percent_decode_str(path); + #[cfg(unix)] + { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + + let decoded = decoded.collect::>(); + Ok(PathBuf::from(OsString::from_vec(decoded))) + } + #[cfg(not(unix))] + { + Ok(PathBuf::from(decoded.decode_utf8_lossy().as_ref())) + } + } + } +} + +async fn calculate_route_without_extension( + path_and_query: &PathAndQuery, + mut path: PathBuf, + store: &T, +) -> anyhow::Result +where + T: FileStore, +{ + if path_and_query.path().ends_with(FORWARD_SLASH) { + path.push(INDEX); + find_file_or_not_found(&path, SQL_EXTENSION, store).await + } else { + let path_with_ext = PathBuf::from(format!("{}.{SQL_EXTENSION}", path.display())); + match find_file_or_not_found(&path_with_ext, SQL_EXTENSION, store).await? { + Execute(x) => Ok(Execute(x)), + other_action => { + let index_path = path.join(INDEX); + if store + .contains(FileAccess::unprivileged(&index_path)?) + .await? + { + Ok(Redirect(append_to_path(path_and_query, FORWARD_SLASH))) + } else { + Ok(other_action) + } + } + } + } +} + +async fn find_file_or_not_found( + path: &Path, + extension: &str, + store: &T, +) -> anyhow::Result +where + T: FileStore, +{ + match find_file(path, extension, store).await? { + None => find_not_found(path, store).await, + Some(execute) => Ok(execute), + } +} + +async fn find_file( + path: &Path, + extension: &str, + store: &T, +) -> anyhow::Result> +where + T: FileStore, +{ + if store.contains(FileAccess::unprivileged(path)?).await? { + Ok(Some(if extension == SQL_EXTENSION { + Execute(path.to_path_buf()) + } else { + Serve(path.to_path_buf()) + })) + } else { + Ok(None) + } +} + +async fn find_not_found(path: &Path, store: &T) -> anyhow::Result +where + T: FileStore, +{ + let mut parent = path.parent(); + while let Some(p) = parent { + let target = p.join(NOT_FOUND); + if store.contains(FileAccess::unprivileged(&target)?).await? { + return Ok(CustomNotFound(target)); + } + parent = p.parent(); + } + + Ok(NotFound) +} + +fn append_to_path(path_and_query: &PathAndQuery, append: &str) -> String { + let mut full_uri = path_and_query.to_string(); + full_uri.insert_str(path_and_query.path().len(), append); + full_uri +} + +#[cfg(test)] +mod tests { + use super::RoutingAction::{CustomNotFound, Execute, NotFound, Redirect, Serve}; + use super::{FileAccess, FileStore, RoutingAction, RoutingConfig, calculate_route}; + use StoreConfig::{Custom, Default, Empty, File}; + use awc::http::uri::PathAndQuery; + use std::default::Default as StdDefault; + use std::path::PathBuf; + use std::str::FromStr; + + mod execute { + use super::StoreConfig::{Default, File}; + use super::{do_route, execute}; + + #[tokio::test] + async fn root_path_executes_index() { + let actual = do_route("/", Default, None).await; + let expected = execute("index.sql"); + + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn root_path_and_site_prefix_executes_index() { + let actual = do_route("/prefix/", Default, Some("/prefix/")).await; + let expected = execute("index.sql"); + + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn extension() { + let actual = do_route("/index.sql", Default, None).await; + let expected = execute("index.sql"); + + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn extension_and_site_prefix() { + let actual = do_route("/prefix/index.sql", Default, Some("/prefix/")).await; + let expected = execute("index.sql"); + + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn no_extension() { + let actual = do_route("/path", File("path.sql"), None).await; + let expected = execute("path.sql"); + + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn no_extension_and_site_prefix() { + let actual = do_route("/prefix/path", File("path.sql"), Some("/prefix/")).await; + let expected = execute("path.sql"); + + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn trailing_slash_executes_index_in_directory() { + let actual = do_route("/folder/", File("folder/index.sql"), None).await; + let expected = execute("folder/index.sql"); + + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn trailing_slash_and_site_prefix_executes_index_in_directory() { + let actual = do_route( + "/prefix/folder/", + File("folder/index.sql"), + Some("/prefix/"), + ) + .await; + let expected = execute("folder/index.sql"); + + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn path_with_non_sql_extension_executes_sql_file() { + let actual = do_route("/abc.def", File("abc.def.sql"), None).await; + let expected = execute("abc.def.sql"); + + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn path_with_non_sql_extension_and_site_prefix_executes_sql_file() { + let actual = do_route("/prefix/abc.def", File("abc.def.sql"), Some("/prefix/")).await; + let expected = execute("abc.def.sql"); + + assert_eq!(expected, actual); + } + } + + mod custom_not_found { + use super::StoreConfig::{Default, File}; + use super::{custom_not_found, do_route}; + + #[tokio::test] + async fn sql_extension() { + let actual = do_route("/unknown.sql", Default, None).await; + let expected = custom_not_found("404.sql"); + + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn sql_extension_and_site_prefix() { + let actual = do_route("/prefix/unknown.sql", Default, Some("/prefix/")).await; + let expected = custom_not_found("404.sql"); + + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn sql_extension_executes_deeper_not_found_file_if_exists() { + let actual = do_route("/unknown/unknown.sql", File("unknown/404.sql"), None).await; + let expected = custom_not_found("unknown/404.sql"); + + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn sql_extension_and_site_prefix_executes_deeper_not_found_file_if_exists() { + let actual = do_route( + "/prefix/unknown/unknown.sql", + File("unknown/404.sql"), + Some("/prefix/"), + ) + .await; + let expected = custom_not_found("unknown/404.sql"); + + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn sql_extension_executes_deepest_not_found_file_that_exists() { + let actual = do_route( + "/unknown/unknown/unknown.sql", + File("unknown/404.sql"), + None, + ) + .await; + let expected = custom_not_found("unknown/404.sql"); + + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn sql_extension_and_site_prefix_executes_deepest_not_found_file_that_exists() { + let actual = do_route( + "/prefix/unknown/unknown/unknown.sql", + File("unknown/404.sql"), + Some("/prefix/"), + ) + .await; + let expected = custom_not_found("unknown/404.sql"); + + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn no_extension_path_that_would_result_in_404_does_not_redirect() { + let actual = do_route("/nonexistent", Default, None).await; + let expected = custom_not_found("404.sql"); + + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn no_extension_path_that_would_result_in_404_does_not_redirect_with_site_prefix() { + let actual = do_route("/prefix/nonexistent", Default, Some("/prefix/")).await; + let expected = custom_not_found("404.sql"); + + assert_eq!(expected, actual); + } + } + + mod not_found { + use super::StoreConfig::Empty; + use super::{default_not_found, do_route}; + + #[tokio::test] + async fn default_404_when_no_not_found_file_available() { + let actual = do_route("/unknown.sql", Empty, None).await; + let expected = default_not_found(); + + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn default_404_when_no_not_found_file_available_and_site_prefix() { + let actual = do_route("/prefix/unknown.sql", Empty, Some("/prefix/")).await; + let expected = default_not_found(); + + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn asset_not_found() { + let actual = do_route("/favicon.ico", Empty, None).await; + let expected = default_not_found(); + + assert_eq!(expected, actual); + } + } + + mod asset { + use super::StoreConfig::File; + use super::{do_route, serve}; + + #[tokio::test] + async fn serves_corresponding_asset() { + let actual = do_route("/favicon.ico", File("favicon.ico"), None).await; + let expected = serve("favicon.ico"); + + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn asset_trims_query() { + let actual = do_route("/favicon.ico?version=10", File("favicon.ico"), None).await; + let expected = serve("favicon.ico"); + + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn asset_trims_fragment() { + let actual = do_route("/favicon.ico#asset1", File("favicon.ico"), None).await; + let expected = serve("favicon.ico"); + + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn serves_corresponding_asset_given_site_prefix() { + let actual = + do_route("/prefix/favicon.ico", File("favicon.ico"), Some("/prefix/")).await; + let expected = serve("favicon.ico"); + + assert_eq!(expected, actual); + } + } + + mod redirect { + use super::StoreConfig::{Default, Empty}; + use super::{custom_not_found, default_not_found, do_route, redirect}; + + #[tokio::test] + async fn path_without_site_prefix_redirects_to_site_prefix() { + let actual = do_route("/path", Default, Some("/prefix/")).await; + let expected = redirect("/prefix/"); + + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn no_extension_and_no_corresponding_file_with_custom_404_does_not_redirect() { + let actual = do_route("/folder", Default, None).await; + let expected = custom_not_found("404.sql"); + + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn no_extension_no_corresponding_file_with_custom_404_does_not_redirect_with_query() { + let actual = do_route("/folder?misc=1&foo=bar", Default, None).await; + let expected = custom_not_found("404.sql"); + + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn no_extension_site_prefix_and_no_corresponding_file_with_custom_404_does_not_redirect() + { + let actual = do_route("/prefix/folder", Default, Some("/prefix/")).await; + let expected = custom_not_found("404.sql"); + + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn no_extension_returns_404_when_no_404sql_available() { + assert_eq!(do_route("/folder", Empty, None).await, default_not_found()); + } + } + + async fn do_route(path: &str, config: StoreConfig, prefix: Option<&str>) -> RoutingAction { + let store = match config { + Default => Store::with_default_contents(), + Empty => Store::empty(), + File(file) => Store::new(file), + Custom(files) => Store::with_files(&files), + }; + let config = match prefix { + None => Config::default(), + Some(value) => Config::new(value), + }; + calculate_route(&PathAndQuery::from_str(path).unwrap(), &store, &config) + .await + .unwrap() + } + + fn default_not_found() -> RoutingAction { + NotFound + } + + fn execute(path: &str) -> RoutingAction { + Execute(PathBuf::from(path)) + } + + fn custom_not_found(path: &str) -> RoutingAction { + CustomNotFound(PathBuf::from(path)) + } + + fn redirect(uri: &str) -> RoutingAction { + Redirect(uri.to_string()) + } + + fn serve(path: &str) -> RoutingAction { + Serve(PathBuf::from(path)) + } + + enum StoreConfig { + Default, + Empty, + File(&'static str), + Custom(Vec<&'static str>), + } + + struct Store { + contents: Vec, + } + + impl Store { + const INDEX: &'static str = "index.sql"; + const NOT_FOUND: &'static str = "404.sql"; + fn new(path: &str) -> Self { + let mut contents = Self::default_contents(); + contents.push(path.to_string()); + Self { contents } + } + + fn default_contents() -> Vec { + vec![Self::INDEX.to_string(), Self::NOT_FOUND.to_string()] + } + + fn with_default_contents() -> Self { + Self { + contents: Self::default_contents(), + } + } + + fn empty() -> Self { + Self { contents: vec![] } + } + + fn contains(&self, path: &str) -> bool { + let normalized_path = path.replace('\\', "/"); + dbg!(&normalized_path, &self.contents); + self.contents.contains(&normalized_path) + } + + fn with_files(files: &[&str]) -> Self { + Self { + contents: files.iter().map(|s| (*s).to_string()).collect(), + } + } + } + + impl FileStore for Store { + async fn contains(&self, access: FileAccess<'_>) -> anyhow::Result { + Ok(self.contains(access.path().to_string_lossy().to_string().as_str())) + } + } + + struct Config { + prefix: String, + } + + impl Config { + fn new(prefix: &str) -> Self { + Self { + prefix: prefix.to_string(), + } + } + } + impl RoutingConfig for Config { + fn prefix(&self) -> &str { + &self.prefix + } + } + + impl StdDefault for Config { + fn default() -> Self { + Self::new("/") + } + } + + mod specific_configuration { + use crate::webserver::routing::tests::default_not_found; + + use super::StoreConfig::Custom; + use super::{RoutingAction, custom_not_found, do_route, execute, redirect}; + + async fn route_with_index_and_folder_404(path: &str) -> RoutingAction { + do_route( + path, + Custom(vec![ + "index.sql", + "folder/404.sql", + "folder_with_index/index.sql", + ]), + None, + ) + .await + } + + #[tokio::test] + async fn root_path_executes_index() { + let actual = route_with_index_and_folder_404("/").await; + let expected = execute("index.sql"); + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn index_sql_path_executes_index() { + let actual = route_with_index_and_folder_404("/index.sql").await; + let expected = execute("index.sql"); + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn folder_without_trailing_slash_redirects() { + let actual = route_with_index_and_folder_404("/folder_with_index").await; + let expected = redirect("/folder_with_index/"); + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn folder_without_trailing_slash_without_index_does_not_redirect() { + let actual = route_with_index_and_folder_404("/folder").await; + let expected = default_not_found(); + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn folder_with_trailing_slash_executes_custom_404() { + let actual = route_with_index_and_folder_404("/folder/").await; + let expected = custom_not_found("folder/404.sql"); + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn folder_xxx_executes_custom_404() { + let actual = route_with_index_and_folder_404("/folder/xxx").await; + let expected = custom_not_found("folder/404.sql"); + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn folder_xxx_with_query_executes_custom_404() { + let actual = route_with_index_and_folder_404("/folder/xxx?x=1").await; + let expected = custom_not_found("folder/404.sql"); + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn folder_nested_path_executes_custom_404() { + let actual = route_with_index_and_folder_404("/folder/xxx/yyy").await; + let expected = custom_not_found("folder/404.sql"); + assert_eq!(expected, actual); + } + } +} diff --git a/src/webserver/server_timing.rs b/src/webserver/server_timing.rs new file mode 100644 index 0000000..aa06b0f --- /dev/null +++ b/src/webserver/server_timing.rs @@ -0,0 +1,72 @@ +use std::fmt::Write; +use std::sync::Mutex; +use std::time::Instant; + +use crate::app_config::DevOrProd; + +#[derive(Debug)] +pub struct ServerTiming { + enabled: bool, + created_at: Instant, + events: Mutex>, +} + +#[derive(Debug)] +struct PerfEvent { + time: Instant, + name: &'static str, +} + +impl Default for ServerTiming { + fn default() -> Self { + Self { + enabled: false, + created_at: Instant::now(), + events: Mutex::new(Vec::new()), + } + } +} + +impl ServerTiming { + #[must_use] + pub fn enabled(enabled: bool) -> Self { + Self { + enabled, + ..Default::default() + } + } + + #[must_use] + pub fn for_env(env: DevOrProd) -> Self { + Self::enabled(!env.is_prod()) + } + + pub fn record(&self, name: &'static str) { + if self.enabled { + self.events.lock().unwrap().push(PerfEvent { + time: Instant::now(), + name, + }); + } + } + + pub fn header_value(&self) -> Option { + if !self.enabled { + return None; + } + let evts = self.events.lock().unwrap(); + let mut s = String::with_capacity(evts.len() * 16); + let mut last = self.created_at; + for (i, PerfEvent { name, time }) in evts.iter().enumerate() { + if i > 0 { + s.push_str(", "); + } + let micros = time.saturating_duration_since(last).as_micros(); + let millis = micros / 1000; + let micros = micros % 1000; + write!(&mut s, "{name};dur={millis}.{micros:03}").ok()?; + last = *time; + } + Some(s) + } +} diff --git a/src/webserver/single_or_vec.rs b/src/webserver/single_or_vec.rs new file mode 100644 index 0000000..3944451 --- /dev/null +++ b/src/webserver/single_or_vec.rs @@ -0,0 +1,148 @@ +use serde::de::Error; +use std::borrow::Cow; +use std::mem; + +#[derive(Debug, serde::Serialize, PartialEq, Clone)] +#[serde(untagged)] +pub enum SingleOrVec { + Single(String), + Vec(Vec), +} + +impl<'de> serde::Deserialize<'de> for SingleOrVec { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = serde_json::Value::deserialize(deserializer)?; + match value { + serde_json::Value::String(s) => Ok(SingleOrVec::Single(s)), + serde_json::Value::Array(values) => { + let mut strings = Vec::with_capacity(values.len()); + for (idx, item) in values.into_iter().enumerate() { + match item { + serde_json::Value::String(s) => strings.push(s), + other => { + return Err(D::Error::custom(format!( + "expected an array of strings, but item at index {idx} is {other}" + ))); + } + } + } + Ok(SingleOrVec::Vec(strings)) + } + other => Err(D::Error::custom(format!( + "expected a string or an array of strings, but found {other}" + ))), + } + } +} + +impl std::fmt::Display for SingleOrVec { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SingleOrVec::Single(x) => write!(f, "{x}"), + SingleOrVec::Vec(v) => { + write!(f, "[")?; + let mut it = v.iter(); + if let Some(first) = it.next() { + write!(f, "{first}")?; + } + for item in it { + write!(f, ", {item}")?; + } + write!(f, "]") + } + } + } +} + +impl SingleOrVec { + pub(crate) fn merge(&mut self, other: Self) { + match (self, other) { + (Self::Single(old), Self::Single(new)) => *old = new, + (old, mut new) => { + let mut v = old.take_vec(); + v.extend_from_slice(&new.take_vec()); + *old = Self::Vec(v); + } + } + } + + fn take_vec(&mut self) -> Vec { + match self { + SingleOrVec::Single(x) => vec![mem::take(x)], + SingleOrVec::Vec(v) => mem::take(v), + } + } + + #[must_use] + pub fn as_json_str(&self) -> Cow<'_, str> { + match self { + SingleOrVec::Single(x) => Cow::Borrowed(x), + SingleOrVec::Vec(v) => Cow::Owned(serde_json::to_string(v).unwrap()), + } + } + + /// Returns the first value. This mirrors how `actix_web::HttpRequest::cookie` + /// selects the first cookie of a given name, so callers that need to agree + /// with that selection (such as OIDC logout session binding) stay consistent + /// instead of accidentally using a JSON array of merged duplicate values. + #[must_use] + pub fn first_str(&self) -> &str { + match self { + SingleOrVec::Single(x) => x, + SingleOrVec::Vec(v) => v.first().map_or("", String::as_str), + } + } +} + +#[cfg(test)] +mod single_or_vec_tests { + use super::SingleOrVec; + + #[test] + fn deserializes_string_and_array_values() { + let single: SingleOrVec = serde_json::from_str(r#""hello""#).unwrap(); + assert_eq!(single, SingleOrVec::Single("hello".to_string())); + let array: SingleOrVec = serde_json::from_str(r#"["a","b"]"#).unwrap(); + assert_eq!( + array, + SingleOrVec::Vec(vec!["a".to_string(), "b".to_string()]) + ); + } + + #[test] + fn rejects_non_string_items() { + let err = serde_json::from_str::(r#"["a", 1]"#).unwrap_err(); + assert!( + err.to_string() + .contains("expected an array of strings, but item at index 1 is 1"), + "{err}" + ); + } + + #[test] + fn first_str_returns_first_value() { + assert_eq!(SingleOrVec::Single("a".to_string()).first_str(), "a"); + // Duplicate values (e.g. two cookies of the same name) yield the first, + // matching HttpRequest::cookie, not a JSON array. + assert_eq!( + SingleOrVec::Vec(vec!["a".to_string(), "b".to_string()]).first_str(), + "a" + ); + assert_eq!(SingleOrVec::Vec(vec![]).first_str(), ""); + } + + #[test] + fn displays_single_value() { + let single = SingleOrVec::Single("hello".to_string()); + assert_eq!(single.to_string(), "hello"); + } + + #[test] + fn displays_array_values() { + let array = SingleOrVec::Vec(vec!["a".to_string(), "b".to_string()]); + assert_eq!(array.to_string(), "[a, b]"); + } +} diff --git a/src/webserver/static_content.rs b/src/webserver/static_content.rs new file mode 100644 index 0000000..61bb4ab --- /dev/null +++ b/src/webserver/static_content.rs @@ -0,0 +1,57 @@ +use crate::utils::static_filename; +use actix_web::{ + HttpRequest, HttpResponse, Resource, + http::header::{ + CacheControl, CacheDirective, ContentEncoding, ETag, EntityTag, Header, IfNoneMatch, + }, + web, +}; + +macro_rules! static_file_endpoint { + ($filestem:literal, $extension:literal, $mime:literal) => {{ + const FILENAME_WITH_TAG: &str = static_filename!(concat!($filestem, ".", $extension)); + web::resource(FILENAME_WITH_TAG).to(|req: HttpRequest| async move { + let file_etag = EntityTag::new_strong(FILENAME_WITH_TAG.to_string()); + if matches!(IfNoneMatch::parse(&req), Ok(IfNoneMatch::Items(etags)) if etags.iter().any(|etag| etag.weak_eq(&file_etag))) { + return HttpResponse::NotModified().finish(); + } + HttpResponse::Ok() + .content_type(concat!($mime, ";charset=UTF-8")) + .insert_header(CacheControl(vec![ + CacheDirective::Public, + CacheDirective::MaxAge(3600 * 24 * 7), + CacheDirective::Extension("immutable".to_owned(), None), + ])) + .insert_header(ETag(file_etag)) + .insert_header(ContentEncoding::Gzip) + .body( + &include_bytes!(concat!(env!("OUT_DIR"), "/", $filestem, ".", $extension))[..], + ) + }) + }}; +} + +#[must_use] +pub fn js() -> Resource { + static_file_endpoint!("sqlpage", "js", "application/javascript") +} + +#[must_use] +pub fn apexcharts_js() -> Resource { + static_file_endpoint!("apexcharts", "js", "application/javascript") +} + +#[must_use] +pub fn tomselect_js() -> Resource { + static_file_endpoint!("tomselect", "js", "application/javascript") +} + +#[must_use] +pub fn css() -> Resource { + static_file_endpoint!("sqlpage", "css", "text/css") +} + +#[must_use] +pub fn favicon() -> Resource { + static_file_endpoint!("favicon", "svg", "image/svg+xml") +} diff --git a/test.hurl b/test.hurl new file mode 100644 index 0000000..db06462 --- /dev/null +++ b/test.hurl @@ -0,0 +1,8 @@ +# Generic smoke test for SQLPage examples. +GET http://localhost:8080 +HTTP 200 +[Asserts] +header "Content-Type" contains "text/html" +xpath "//html" exists +xpath "//body" exists +body not contains "An error occurred" diff --git a/tests/basic/mod.rs b/tests/basic/mod.rs new file mode 100644 index 0000000..1db6cce --- /dev/null +++ b/tests/basic/mod.rs @@ -0,0 +1,53 @@ +use actix_web::{ + body::MessageBody, + http::{self}, + test, +}; + +use crate::common::req_path; + +#[actix_web::test] +async fn test_index_ok() { + let resp = req_path("/").await.unwrap(); + assert_eq!(resp.status(), http::StatusCode::OK); + let body = test::read_body(resp).await; + assert!(body.starts_with(b"")); + let body = String::from_utf8(body.to_vec()).unwrap(); + assert!(body.contains("It works !")); + assert!(!body.contains("error")); +} + +#[actix_web::test] +async fn test_access_config_forbidden() { + let resp_result = req_path("/sqlpage/sqlpage.json").await; + assert!( + resp_result.is_err(), + "Accessing the config file should be forbidden, but we received a response: {resp_result:?}" + ); + let resp = resp_result.unwrap_err().error_response(); + assert_eq!(resp.status(), http::StatusCode::FORBIDDEN); + assert!( + String::from_utf8_lossy(&resp.into_body().try_into_bytes().unwrap()) + .to_lowercase() + .contains("forbidden"), + ); +} + +#[actix_web::test] +async fn test_static_files() { + let resp = req_path("/tests/it_works.txt").await.unwrap(); + assert_eq!(resp.status(), http::StatusCode::OK); + let body = test::read_body(resp).await; + assert_eq!(&body, &b"It works !"[..]); +} + +#[actix_web::test] +async fn test_spaces_in_file_names() { + let resp = req_path("/tests/core/spaces%20in%20file%20name.sql") + .await + .unwrap(); + assert_eq!(resp.status(), http::StatusCode::OK); + let body = test::read_body(resp).await; + let body_str = String::from_utf8(body.to_vec()).unwrap(); + assert!(body_str.contains("It works !"), "{body_str}"); +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..31823de --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,171 @@ +use std::time::Duration; + +use actix_web::{ + App, HttpResponse, HttpServer, + dev::{ServiceRequest, fn_service}, + http::header, + http::header::ContentType, + test::{self, TestRequest}, + web, + web::Data, +}; +use sqlpage::{ + AppState, + app_config::{AppConfig, test_database_url}, + telemetry, + webserver::http::{form_config, main_handler, payload_config}, +}; +use tokio::sync::oneshot; +use tokio::task::JoinHandle; + +pub async fn get_request_to_with_data( + path: &str, + data: Data, +) -> actix_web::Result { + Ok(test::TestRequest::get() + .uri(path) + .insert_header(ContentType::plaintext()) + .insert_header(header::Accept::html()) + .app_data(payload_config(&data)) + .app_data(form_config(&data)) + .app_data(data)) +} + +pub async fn get_request_to(path: &str) -> actix_web::Result { + let data = make_app_data().await; + get_request_to_with_data(path, data).await +} + +pub async fn make_app_data_from_config(config: AppConfig) -> Data { + let state = AppState::init(&config).await.unwrap(); + Data::new(state) +} + +pub async fn make_app_data() -> Data { + init_log(); + let config = test_config(); + make_app_data_from_config(config).await +} + +pub async fn req_path( + path: impl AsRef, +) -> Result { + let req = get_request_to(path.as_ref()).await?.to_srv_request(); + main_handler(req).await +} + +const REQ_TIMEOUT: Duration = Duration::from_secs(8); +pub async fn req_path_with_app_data( + path: impl AsRef, + app_data: Data, +) -> anyhow::Result { + req_path_with_app_data_and_accept(path, app_data, header::Accept::html()).await +} + +pub async fn req_path_with_app_data_json( + path: impl AsRef, + app_data: Data, +) -> anyhow::Result { + req_path_with_app_data_and_accept(path, app_data, header::Accept::json()).await +} + +async fn req_path_with_app_data_and_accept( + path: impl AsRef, + app_data: Data, + accept: header::Accept, +) -> anyhow::Result { + let path = path.as_ref(); + let req = test::TestRequest::get() + .uri(path) + .app_data(app_data) + .insert_header(("cookie", "test_cook=123")) + .insert_header(("authorization", "Basic dGVzdDp0ZXN0")) + .insert_header(accept) + .to_srv_request(); + let resp = tokio::time::timeout(REQ_TIMEOUT, main_handler(req)) + .await + .map_err(|e| anyhow::anyhow!("Request to {path} timed out: {e}"))? + .map_err(|e| { + anyhow::anyhow!( + "Request to {path} failed with status {}: {e:#}", + e.as_response_error().status_code() + ) + })?; + Ok(resp) +} + +pub fn test_config() -> AppConfig { + let db_url = test_database_url(); + serde_json::from_str::(&format!( + r#"{{ + "database_url": "{db_url}", + "max_database_pool_connections": 1, + "database_connection_retries": 3, + "database_connection_acquire_timeout_seconds": 15, + "allow_exec": true, + "max_uploaded_file_size": 123456, + "listen_on": "111.111.111.111:1", + "system_root_ca_certificates" : false + }}"# + )) + .unwrap() +} + +pub fn init_log() { + telemetry::init_test_logging(); +} + +fn format_request_line_and_headers(req: &ServiceRequest) -> String { + let mut out = format!("{} {}", req.method(), req.uri()); + let mut headers: Vec<_> = req.headers().iter().collect(); + headers.sort_by_key(|(k, _)| k.as_str()); + for (k, v) in headers { + if k.as_str().eq_ignore_ascii_case("date") { + continue; + } + out.push_str(&format!("|{k}: {}", v.to_str().unwrap_or("?"))); + } + out +} + +async fn format_body(req: &mut ServiceRequest) -> Vec { + req.extract::() + .await + .map(|b| b.to_vec()) + .unwrap_or_default() +} + +fn build_echo_response(body: Vec, meta: String) -> HttpResponse { + let mut resp = meta.into_bytes(); + resp.push(b'|'); + resp.extend_from_slice(&body); + HttpResponse::Ok() + .insert_header((header::DATE, "Mon, 24 Feb 2025 12:00:00 GMT")) + .insert_header((header::CONTENT_TYPE, "text/plain")) + .body(resp) +} + +pub fn start_echo_server(shutdown: oneshot::Receiver<()>) -> (JoinHandle<()>, u16) { + let listener = std::net::TcpListener::bind("localhost:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = HttpServer::new(|| { + App::new().default_service(fn_service(|mut req: ServiceRequest| async move { + let meta = format_request_line_and_headers(&req); + let body = format_body(&mut req).await; + let resp = build_echo_response(body, meta); + Ok(req.into_response(resp)) + })) + }) + .workers(1) + .listen(listener) + .unwrap() + .shutdown_timeout(1) + .run(); + let handle = tokio::spawn(async move { + tokio::select! { + _ = server => {}, + _ = shutdown => {}, + } + }); + (handle, port) +} diff --git a/tests/components/any_component.sql b/tests/components/any_component.sql new file mode 100644 index 0000000..1356f76 --- /dev/null +++ b/tests/components/any_component.sql @@ -0,0 +1,6 @@ +select $component as component; +select + 'It works !' as title, + 'It works !' as description; + +select 'divider' as component, 'the end' as contents; \ No newline at end of file diff --git a/tests/components/display_form_field.sql b/tests/components/display_form_field.sql new file mode 100644 index 0000000..dc849a5 --- /dev/null +++ b/tests/components/display_form_field.sql @@ -0,0 +1,4 @@ +-- This test checks that the size of the form field can successfully roundtrip, +-- from POST variable to sqlpage variable to handlebars, back to the client +set x = :x; +select 'text' as component, $x as contents; diff --git a/tests/components/display_text.sql b/tests/components/display_text.sql new file mode 100644 index 0000000..99b295d --- /dev/null +++ b/tests/components/display_text.sql @@ -0,0 +1 @@ +select 'html' as component, $html as html; \ No newline at end of file diff --git a/tests/core/.hidden.sql b/tests/core/.hidden.sql new file mode 100644 index 0000000..a763123 --- /dev/null +++ b/tests/core/.hidden.sql @@ -0,0 +1 @@ +select 'text' as component, 'This is a hidden file that should not be accessible' as contents; \ No newline at end of file diff --git a/tests/core/mod.rs b/tests/core/mod.rs new file mode 100644 index 0000000..229aeac --- /dev/null +++ b/tests/core/mod.rs @@ -0,0 +1,257 @@ +use actix_web::{http::StatusCode, test}; +use sqlpage::{ + AppState, + webserver::{self, make_placeholder}, +}; +use sqlx::Executor as _; + +use crate::common::{make_app_data_from_config, req_path, req_path_with_app_data, test_config}; + +#[actix_web::test] +async fn test_concurrent_requests() { + let components = [ + "table", "form", "card", "datagrid", "hero", "list", "timeline", + ]; + let app_data = make_app_data_from_config(test_config()).await; + let reqs = (0..64) + .map(|i| { + let component = components[i % components.len()]; + req_path_with_app_data( + format!("/tests/components/any_component.sql?component={component}"), + app_data.clone(), + ) + }) + .collect::>(); + let results = futures_util::future::join_all(reqs).await; + for result in results.into_iter() { + let resp = result.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = test::read_body(resp).await; + assert!( + body.starts_with(b""), + "Expected html doctype" + ); + let body = String::from_utf8(body.to_vec()).unwrap(); + assert!( + body.contains("It works !"), + "Expected to contain: It works !, but got: {body}" + ); + assert!(!body.contains("error")); + } +} + +#[actix_web::test] +async fn test_routing_with_db_fs() { + let mut config = test_config(); + if config.database_url.contains("memory") { + return; + } + + config.site_prefix = "/prefix/".to_string(); + let state = AppState::init(&config).await.unwrap(); + + if matches!( + state.db.info.database_type, + sqlpage::webserver::database::SupportedDatabase::Oracle + ) { + return; + } + + let drop_sql = "DROP TABLE IF EXISTS sqlpage_files"; + state.db.connection.execute(drop_sql).await.unwrap(); + let create_table_sql = + sqlpage::filesystem::DbFsQueries::get_create_table_sql(state.db.info.database_type); + state.db.connection.execute(create_table_sql).await.unwrap(); + let insert_sql = format!( + "INSERT INTO sqlpage_files(path, contents) VALUES ('on_db.sql', {})", + make_placeholder(state.db.info.kind, 1) + ); + sqlx::query(&insert_sql) + .bind("select ''text'' as component, ''Hi from db !'' AS contents;".as_bytes()) + .execute(&state.db.connection) + .await + .unwrap(); + + let state = AppState::init(&config).await.unwrap(); + let app_data = actix_web::web::Data::new(state); + + let resp = req_path_with_app_data("/prefix/on_db.sql", app_data.clone()) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = test::read_body(resp).await; + let body_str = String::from_utf8(body.to_vec()).unwrap(); + assert!( + body_str.contains("Hi from db !"), + "{body_str}\nexpected to contain: Hi from db !" + ); +} + +#[cfg(unix)] +#[actix_web::test] +async fn test_non_unicode_static_path_returns_bad_request_with_db_fs() { + let mut config = test_config(); + if !config.database_url.starts_with("sqlite") { + return; + } + config.database_url = + "sqlite://file:test_non_unicode_static_path?mode=memory&cache=shared".to_string(); + + let state = AppState::init(&config).await.unwrap(); + let expected_db_path = "\u{FFFD}.txt"; + let mut conn = state.db.connection.acquire().await.unwrap(); + + (&mut *conn) + .execute(sqlpage::filesystem::DbFsQueries::get_create_table_sql( + sqlpage::webserver::database::SupportedDatabase::Sqlite, + )) + .await + .unwrap(); + let insert_sql = format!( + "INSERT INTO sqlpage_files(path, contents) VALUES ({}, {})", + make_placeholder(state.db.info.kind, 1), + make_placeholder(state.db.info.kind, 2) + ); + sqlx::query(&insert_sql) + .bind(expected_db_path) + .bind("file from db fs".as_bytes()) + .execute(&mut *conn) + .await + .unwrap(); + drop(conn); + + let state = AppState::init(&config).await.unwrap(); + let app_data = actix_web::web::Data::new(state); + let req = test::TestRequest::get() + .uri("/%FF.txt") + .app_data(app_data) + .to_srv_request(); + + let err = sqlpage::webserver::http::main_handler(req) + .await + .expect_err("non-unicode path should not panic and must return bad request"); + assert_eq!( + err.as_response_error().status_code(), + StatusCode::BAD_REQUEST + ); +} + +#[actix_web::test] +async fn test_routing_with_prefix() { + let mut config = test_config(); + config.site_prefix = "/prefix/".to_string(); + let state = AppState::init(&config).await.unwrap(); + + let app_data = actix_web::web::Data::new(state); + let resp = req_path_with_app_data( + "/prefix/tests/sql_test_files/component_rendering/simple.sql", + app_data.clone(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = test::read_body(resp).await; + let body_str = String::from_utf8(body.to_vec()).unwrap(); + assert!( + body_str.contains("It works !"), + "{body_str}\nexpected to contain: It works !" + ); + assert!( + body_str.contains("href=\"/prefix/"), + "{body_str}\nexpected to contain links with site prefix" + ); + + let resp = req_path_with_app_data("/prefix/nonexistent.sql", app_data.clone()) + .await + .expect("should handle 404"); + let body = test::read_body(resp).await; + let body_str = String::from_utf8(body.to_vec()).unwrap(); + assert!( + body_str.contains("404"), + "Response should contain \"404\", but got:\n{body_str}" + ); + + let resp = req_path_with_app_data("/prefix/sqlpage/migrations/0001_init.sql", app_data.clone()) + .await + .expect_err("Expected forbidden error") + .to_string(); + assert!(resp.to_lowercase().contains("forbidden"), "{resp}"); + + let resp = req_path_with_app_data( + "/tests/sql_test_files/component_rendering/simple.sql", + app_data, + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::MOVED_PERMANENTLY); + let location = resp + .headers() + .get("location") + .expect("location header should be present"); + assert_eq!(location.to_str().unwrap(), "/prefix/"); +} + +#[actix_web::test] +async fn test_hidden_files() { + let resp_result = req_path("/tests/core/.hidden.sql").await; + assert!( + resp_result.is_err(), + "Accessing a hidden file should be forbidden, but received success: {resp_result:?}" + ); + let resp = resp_result.unwrap_err().error_response(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + let srv_resp = actix_web::test::TestRequest::default().to_srv_response(resp); + let body = test::read_body(srv_resp).await; + assert!( + String::from_utf8_lossy(&body) + .to_lowercase() + .contains("forbidden"), + ); +} + +#[actix_web::test] +async fn test_official_website_documentation() { + let app_data = make_app_data_for_official_website().await; + let resp = req_path_with_app_data("/component.sql?component=button", app_data) + .await + .unwrap_or_else(|e| { + panic!("Failed to get response for /component.sql?component=button: {e}") + }); + assert_eq!(resp.status(), StatusCode::OK); + let body = test::read_body(resp).await; + let body_str = String::from_utf8(body.to_vec()).unwrap(); + assert!( + body_str.contains(r#"