diff --git a/.github/workflows/translate.yml b/.github/workflows/translate.yml new file mode 100644 index 000000000..3d987e575 --- /dev/null +++ b/.github/workflows/translate.yml @@ -0,0 +1,536 @@ +name: Translate content (Translated) + +# Machine-translates the English docs with Translated (TranslationOS) and hands the +# result over as a review PR. build_scripts/translate-content.py does the work; this +# file only decides WHEN it runs, in WHICH environment, and WHERE the output goes. +# +# Jobs +# dry_run pull_request only. Runs the PR's own copy of the script offline +# (--self-test, --plan, --dump-orders) and uploads the dumped orders. +# When the PR carries the label `translate-sandbox-dryrun` AND a key is +# present, it also submits ONE file (features/toc.md, es) to the sandbox +# with `--run --force --wait 5` and uploads the report plus a patch of +# localizedContent. Never pushes, never opens a PR. +# translate push / schedule / workflow_dispatch. Folds the open translation +# branch into main's tree, runs --self-test, then --run (or --probe), +# commits localizedContent, pushes to the target branch and opens or +# updates the review PR. Holds the `translate-content` concurrency slot. +# +# Gating (event x TRANSLATED_ENV) +# pull_request -> dry_run only (offline; sandbox mini-run needs label + key) +# push / schedule -> translate, ONLY when the variable TRANSLATED_ENV == production; +# otherwise the job is skipped (no sandbox spam, no sandbox MT PRs) +# workflow_dispatch -> translate in whatever environment TRANSLATED_ENV names. +# Sandbox runs push to `localization-sandbox` as a DRAFT PR titled +# "New translations (sandbox - do not merge)" labelled `sandbox`; +# only production runs may target `localization`. +# +# Secrets / variables (repository settings) +# TRANSLATED_API_KEY secret TranslationOS key matching TRANSLATED_ENV +# TRANSLATED_SANDBOX_API_KEY secret optional; used by the pull_request dry run +# (falls back to TRANSLATED_API_KEY). Add it when +# TRANSLATED_API_KEY is switched to the production +# key so the label-gated PR run keeps hitting the +# sandbox. +# TRANSLATED_ENV variable sandbox | production; selects +# translation.environments[...] in metadata/build-config.json +# TRANSLATED_SERVICE_TYPE variable optional override of the environment's serviceType +# (production has none until it is set; find it with mode=probe) +# Missing key or TRANSLATED_ENV: a dispatch fails with ::error::; push/schedule end green +# with a ::warning:: so the schedule does not go red four times a day. +# +# workflow_dispatch inputs +# mode translate (default): --run, then commit/push/PR. +# probe: --probe only (key, languages, service types); nothing pushed. +# dry-run: --plan and --dump-orders, then in sandbox --run with a report +# (jobs are submitted and collected, the report is uploaded, nothing is +# committed or pushed). In production the mode stops after --dump-orders, +# because a production submission that is never pushed would be billed +# and then resubmitted by the next run. +# target_branch default '' = auto: `localization` when TRANSLATED_ENV == production, +# `localization-sandbox` otherwise. An explicit `localization` is refused +# (exit 1) for mode=translate unless TRANSLATED_ENV == production; for +# probe/dry-run it is remapped to `localization-sandbox` with a ::notice::. +# Any other value must start with `localization-`; the bot never pushes +# to main or to a feature branch. +# limit / wait / force / file passed through as --limit / --wait / --force / --file. +# `wait` must be a whole number of minutes <= 35: the job is killed at +# 45 minutes and the accepted job records must be pushed before that. +# The script refuses sandbox submissions without --limit/--file and caps files and +# characters per run (translation.sandboxLimits); this workflow never passes +# --allow-unbounded and never passes --lenient. +# +# Repository settings an admin must enable (Settings > Actions > General / Settings > General) +# "Allow GitHub Actions to create and approve pull requests" - needed by the PR step +# (it warns instead of failing when off; the push has already landed) +# "Automatically delete head branches" - keeps `localization` from going stale after a +# squash merge (the merge step also deletes a stale branch whose PR is merged/closed) +# +# Failure safety: the push and PR steps run even when the translate step fails +# (`!cancelled()`), so job records that Translated already accepted are committed and +# never resubmitted; the PR body/comment then starts with a warning line. The merge +# step is the opposite: when the open translation branch cannot be folded into main it +# fails the run BEFORE anything is submitted, because continuing from main would lose +# the in-flight job records on that branch and resubmit (and pay for) their files. + +on: + push: + branches: [main] + paths: + - 'content/**' + - 'metadata/build-config.json' + - 'metadata/language-metadata.json' + - 'build_scripts/translate-content.py' + - 'build_scripts/config_loader.py' + - '.github/workflows/translate.yml' + schedule: + - cron: '17 */6 * * *' + workflow_dispatch: + inputs: + mode: + description: translate = submit/collect and push; probe = check key, languages and service types only; dry-run = plan, dump orders and (sandbox only) run without pushing + type: choice + options: [translate, probe, dry-run] + default: translate + target_branch: + description: Branch that receives the translations. Empty = auto (`localization` when TRANSLATED_ENV is production, `localization-sandbox` otherwise); an explicit `localization` is refused outside production; any other name must start with `localization-`. + type: string + default: '' + limit: + description: Submit at most N files per language (0 = no limit; sandbox requires a limit or a file) + type: string + default: '0' + wait: + description: Minutes to wait for deliveries before pushing what has arrived (whole number, at most 35) + type: string + default: '25' + force: + description: Re-translate the matched files even when they are current (with --file/--limit; without either it means the whole corpus) + type: boolean + default: false + file: + description: Only files matching this glob, relative to content/ (e.g. features/toc.md) + type: string + default: '' + pull_request: + types: [opened, synchronize, reopened, labeled] + paths: + - '.github/workflows/translate.yml' + - 'build_scripts/translate-content.py' + - 'build_scripts/config_loader.py' + - 'metadata/build-config.json' + - 'metadata/language-metadata.json' + +defaults: + run: + # `bash -eo pipefail`; every run block below relies on it. + shell: bash + +jobs: + dry_run: + name: Dry run (pull request) + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + concurrency: + group: translate-dry-run-${{ github.event.pull_request.number }} + cancel-in-progress: true + env: + # `secrets` cannot be used in `if:`; fork PRs get no secrets, so this is 'false' there. + # Prefer the dedicated sandbox key so this run keeps working once TRANSLATED_API_KEY + # holds the production key. + HAS_KEY: ${{ (secrets.TRANSLATED_SANDBOX_API_KEY || secrets.TRANSLATED_API_KEY) != '' }} + steps: + - uses: actions/checkout@v4 + with: + # The PR's own commit, not the synthetic merge commit: test what will be merged. + ref: ${{ github.event.pull_request.head.sha }} + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Self-test (offline) + run: python build_scripts/translate-content.py --self-test + + - name: Plan (offline) + run: python build_scripts/translate-content.py --plan | tee "$RUNNER_TEMP/plan.txt" + + - name: Dump orders (offline) + run: python build_scripts/translate-content.py --dump-orders "$RUNNER_TEMP/orders" + + - name: Sandbox mini-run (label translate-sandbox-dryrun) + id: sandbox + if: env.HAS_KEY == 'true' && contains(github.event.pull_request.labels.*.name, 'translate-sandbox-dryrun') + env: + TRANSLATED_ENV: sandbox + TRANSLATED_API_KEY: ${{ secrets.TRANSLATED_SANDBOX_API_KEY || secrets.TRANSLATED_API_KEY }} + run: | + python build_scripts/translate-content.py --run --force --limit 1 --file "features/toc.md" --lang es \ + --wait 5 --report "$RUNNER_TEMP/report.md" + + - name: Patch of localizedContent + if: ${{ !cancelled() && steps.sandbox.outcome != 'skipped' }} + run: | + git add -N localizedContent + git diff localizedContent > "$RUNNER_TEMP/translations.patch" + git diff --stat localizedContent + + - name: Upload artefacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: translate-dry-run + if-no-files-found: ignore + path: | + ${{ runner.temp }}/plan.txt + ${{ runner.temp }}/orders + ${{ runner.temp }}/report.md + ${{ runner.temp }}/translations.patch + + - name: Step summary + if: always() + run: | + { + echo "## Translation dry run" + echo + if [ -f "$RUNNER_TEMP/report.md" ]; then + head -c 60000 "$RUNNER_TEMP/report.md" + echo + elif [ -f "$RUNNER_TEMP/plan.txt" ]; then + echo 'No sandbox run (label `translate-sandbox-dryrun` missing or secret absent). Plan:' + echo + echo '```' + head -c 60000 "$RUNNER_TEMP/plan.txt" + echo '```' + else + echo "The self-test failed before a plan was produced; see the job log." + fi + } >> "$GITHUB_STEP_SUMMARY" + + translate: + name: Submit and collect translations + # Automatic triggers only in production; a dispatch runs in whatever TRANSLATED_ENV names. + if: github.event_name == 'workflow_dispatch' || (github.event_name != 'pull_request' && vars.TRANSLATED_ENV == 'production') + runs-on: ubuntu-latest + timeout-minutes: 45 + permissions: + contents: write + pull-requests: write + # `gh label create sandbox` (labels are an issues-API resource) + issues: write + concurrency: + group: translate-content + cancel-in-progress: false + env: + TRANSLATED_ENV: ${{ vars.TRANSLATED_ENV }} + TRANSLATED_SERVICE_TYPE: ${{ vars.TRANSLATED_SERVICE_TYPE }} + HAS_KEY: ${{ secrets.TRANSLATED_API_KEY != '' }} + steps: + - name: Check secret, environment and inputs + id: preflight + env: + INPUT_WAIT: ${{ inputs.wait }} + run: | + # `wait` only exists on a dispatch; the job is killed after 45 minutes and the + # accepted job records must be committed and pushed before that. + if [ -n "$INPUT_WAIT" ]; then + if ! [[ "$INPUT_WAIT" =~ ^[0-9]+$ ]]; then + echo "::error::Input 'wait' must be a whole number of minutes (got '$INPUT_WAIT')." + exit 1 + fi + if [ "$INPUT_WAIT" -gt 35 ]; then + echo "::error::Input 'wait' is $INPUT_WAIT minutes; the maximum is 35 (the job times out at 45 and must push the accepted job records first)." + exit 1 + fi + fi + ready=true + problems=() + if [ "$HAS_KEY" != "true" ]; then + problems+=("secret TRANSLATED_API_KEY is not set") + fi + if [ -z "$TRANSLATED_ENV" ]; then + problems+=("repository variable TRANSLATED_ENV is not set (sandbox | production)") + fi + if [ "${#problems[@]}" -gt 0 ]; then + ready=false + msg="Translation run skipped: ${problems[*]}" + if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then + echo "::error::$msg" + exit 1 + fi + echo "::warning::$msg" + fi + echo "ready=$ready" >> "$GITHUB_OUTPUT" + + - uses: actions/checkout@v4 + if: steps.preflight.outputs.ready == 'true' + with: + # Always main, whatever branch the dispatch was started from (the script + # version under test on a PR is covered by the dry_run job). + ref: main + fetch-depth: 0 + + - uses: actions/setup-python@v5 + if: steps.preflight.outputs.ready == 'true' + with: + python-version: '3.11' + + - name: Resolve target branch + id: target + if: steps.preflight.outputs.ready == 'true' + env: + INPUT_TARGET_BRANCH: ${{ inputs.target_branch }} + INPUT_MODE: ${{ inputs.mode }} + run: | + # Empty (the default, and always on push/schedule) means auto by environment. + target="$INPUT_TARGET_BRANCH" + if [ -z "$target" ]; then + if [ "$TRANSLATED_ENV" = "production" ]; then + target=localization + else + target=localization-sandbox + fi + echo "target_branch not given; auto-selected '$target' for TRANSLATED_ENV=$TRANSLATED_ENV" + fi + # Only translation branches may receive bot pushes: never main or a feature branch. + case "$target" in + localization|localization-*) ;; + *) + echo "::error::target_branch must be 'localization' or start with 'localization-' (got '$target')." + exit 1 + ;; + esac + if [ "$target" = "localization" ] && [ "$TRANSLATED_ENV" != "production" ]; then + case "${INPUT_MODE:-translate}" in + probe|dry-run) + # Nothing is pushed in these modes; fall back so the merge step can still + # pick up in-flight records instead of failing the run. + echo "::notice::TRANSLATED_ENV=$TRANSLATED_ENV; using localization-sandbox instead of localization (nothing is pushed in mode $INPUT_MODE)" + target=localization-sandbox + ;; + *) + echo "::error::Refusing to target 'localization' while TRANSLATED_ENV=$TRANSLATED_ENV; only production runs may. Use target_branch=localization-sandbox (or leave it empty for auto)." + exit 1 + ;; + esac + fi + echo "Target branch: $target (TRANSLATED_ENV=$TRANSLATED_ENV)" + echo "branch=$target" >> "$GITHUB_OUTPUT" + + - name: Continue from the open translation branch + # Unmerged translations and in-flight job records live on the target branch. + # Fold them into main's tree so this run builds on them instead of resubmitting. + # A stale branch (tip already in main, or its PR merged/closed at that tip - the + # squash-merge case) is deleted instead, so no empty PR is opened afterwards. + # If the branch can neither be merged nor taken as-is, the run FAILS here: + # continuing from main would resubmit files whose job records live on the branch. + if: steps.preflight.outputs.ready == 'true' + env: + GH_TOKEN: ${{ github.token }} + TARGET: ${{ steps.target.outputs.branch }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + if ! git ls-remote --exit-code --heads origin "$TARGET" >/dev/null; then + echo "No branch '$TARGET' on origin; starting from main." + exit 0 + fi + git fetch origin "$TARGET" + tip="$(git rev-parse "origin/$TARGET")" + + stale="" + if git merge-base --is-ancestor "$tip" HEAD; then + stale="its tip $tip is already contained in main" + else + open_count="$(gh pr list -R "$GITHUB_REPOSITORY" --head "$TARGET" --state open --json number --jq 'length' || echo 0)" + done_heads="$( { gh pr list -R "$GITHUB_REPOSITORY" --head "$TARGET" --state merged --json headRefOid --jq '.[].headRefOid'; + gh pr list -R "$GITHUB_REPOSITORY" --head "$TARGET" --state closed --json headRefOid --jq '.[].headRefOid'; } 2>/dev/null || true)" + if [ "$open_count" = "0" ] && printf '%s\n' "$done_heads" | grep -qx "$tip"; then + stale="its PR was merged/closed at $tip and no PR is open" + fi + fi + if [ -n "$stale" ]; then + echo "Deleting stale branch '$TARGET': $stale" + git push origin --delete "$TARGET" + exit 0 + fi + + if git merge --no-edit -X theirs "origin/$TARGET"; then + exit 0 + fi + if ! test -f .git/MERGE_HEAD; then + # Not a conflict but some other failure (unrelated histories, dirty tree, ...): + # there is nothing to repair, and continuing from main is not an option. + echo "::error::git merge origin/$TARGET failed without leaving a merge in progress. Nothing was submitted or pushed; inspect branch '$TARGET' and rerun." + exit 1 + fi + echo "::warning::Merge of origin/$TARGET left conflicts (modify/delete?); taking the branch's localizedContent as-is." + if git checkout "origin/$TARGET" -- localizedContent \ + && git add -A localizedContent \ + && { git -c core.editor=true merge --continue || git commit -m "Merge $TARGET (translations)"; }; then + exit 0 + fi + unmerged="$(git diff --name-only --diff-filter=U | tr '\n' ' ')" + echo "::error::Could not fold origin/$TARGET into main; unmerged paths: ${unmerged:-(none reported)}. Nothing was submitted or pushed. Resolve the conflict on '$TARGET' (or merge/close its PR so the branch is deleted) and rerun." + git merge --abort || true + exit 1 + + - name: Self-test (offline) + if: steps.preflight.outputs.ready == 'true' + run: python build_scripts/translate-content.py --self-test + + - name: Translate changed content + id: translate + if: steps.preflight.outputs.ready == 'true' + env: + TRANSLATED_API_KEY: ${{ secrets.TRANSLATED_API_KEY }} + INPUT_MODE: ${{ inputs.mode }} + INPUT_WAIT: ${{ inputs.wait }} + INPUT_LIMIT: ${{ inputs.limit }} + INPUT_FORCE: ${{ inputs.force }} + INPUT_FILE: ${{ inputs.file }} + run: | + mode="${INPUT_MODE:-translate}" + report="$RUNNER_TEMP/translation-report.md" + run_args=(--run --wait "${INPUT_WAIT:-25}" --limit "${INPUT_LIMIT:-0}" --report "$report") + if [ "$INPUT_FORCE" = "true" ]; then + run_args+=(--force) + fi + if [ -n "$INPUT_FILE" ]; then + run_args+=(--file "$INPUT_FILE") + fi + echo "mode=$mode TRANSLATED_ENV=$TRANSLATED_ENV" + case "$mode" in + probe) + python build_scripts/translate-content.py --probe + ;; + dry-run) + python build_scripts/translate-content.py --plan + python build_scripts/translate-content.py --dump-orders "$RUNNER_TEMP/orders" + if [ "$TRANSLATED_ENV" = "production" ]; then + # A production --run whose results are never pushed would be billed and + # then resubmitted by the next run; plan + dumped orders are the dry run here. + echo "::warning::mode=dry-run in production stops after --plan/--dump-orders (nothing is submitted)." + else + python build_scripts/translate-content.py "${run_args[@]}" + fi + ;; + *) + python build_scripts/translate-content.py "${run_args[@]}" + ;; + esac + + - name: Push to the translation branch + id: push + # Runs even when the translate step failed so accepted job records are kept. + if: ${{ !cancelled() && steps.translate.outcome != 'skipped' && inputs.mode != 'dry-run' && inputs.mode != 'probe' }} + env: + TARGET: ${{ steps.target.outputs.branch }} + run: | + changed=false + pushed=false + if [ -n "$(git status --porcelain localizedContent)" ]; then + git add -A localizedContent + if [ "$TRANSLATED_ENV" = "production" ]; then + git commit -m "New translations (Translated)" + else + git commit -m "New translations (Translated, sandbox)" + fi + changed=true + fi + if git diff --quiet origin/main -- localizedContent; then + echo "localizedContent matches main; nothing to review." + else + if [ "$changed" = "false" ] \ + && git rev-parse -q --verify "origin/$TARGET" >/dev/null \ + && git diff --quiet "origin/$TARGET" HEAD -- localizedContent; then + echo "origin/$TARGET already carries these translations; not pushing an empty merge." + else + git push origin "HEAD:refs/heads/$TARGET" + echo "Pushed to $TARGET." + fi + pushed=true + fi + echo "changed=$changed" >> "$GITHUB_OUTPUT" + echo "pushed=$pushed" >> "$GITHUB_OUTPUT" + + - name: Open or update the review PR + # No implicit success(): after a failed translate step the pushed records still + # deserve a PR (or a comment), flagged with a warning line. + if: ${{ !cancelled() && steps.push.outputs.pushed == 'true' }} + env: + GH_TOKEN: ${{ github.token }} + TARGET: ${{ steps.target.outputs.branch }} + CHANGED: ${{ steps.push.outputs.changed }} + TRANSLATE_OUTCOME: ${{ steps.translate.outcome }} + run: | + report="$RUNNER_TEMP/translation-report.md" + body="$RUNNER_TEMP/pr-body.md" + { + if [ "$TRANSLATE_OUTCOME" = "failure" ]; then + echo '> The translate step ended with an error; some files may not have been submitted or collected. See the run log.' + echo + fi + if [ -f "$report" ]; then + head -c 60000 "$report" + else + printf 'Translations on `%s` from run %s/%s/actions/runs/%s (no report was produced; see the job log).\n' \ + "$TARGET" "$GITHUB_SERVER_URL" "$GITHUB_REPOSITORY" "$GITHUB_RUN_ID" + fi + } > "$body" + number="$(gh pr list -R "$GITHUB_REPOSITORY" --base main --head "$TARGET" --state open --json number --jq '.[0].number // empty')" + if [ -z "$number" ]; then + if [ "$TRANSLATED_ENV" = "production" ]; then + create_args=(--title "New translations (Translated)") + else + create_args=(--title "New translations (sandbox - do not merge)" --draft) + gh label create sandbox -R "$GITHUB_REPOSITORY" --color d4c5f9 \ + --description "Sandbox machine translations; never merge" >/dev/null 2>&1 || true + fi + if url="$(gh pr create -R "$GITHUB_REPOSITORY" --base main --head "$TARGET" "${create_args[@]}" --body-file "$body")"; then + echo "Opened $url" + if [ "$TRANSLATED_ENV" != "production" ]; then + gh pr edit "$url" -R "$GITHUB_REPOSITORY" --add-label sandbox \ + || echo "::warning::Could not add label 'sandbox' to $url" + fi + else + echo "::warning::Could not open a PR for branch '$TARGET'. The translations are pushed; open the PR manually and check Settings > Actions > General > 'Allow GitHub Actions to create and approve pull requests'." + fi + elif [ "$CHANGED" = "true" ]; then + gh pr comment "$number" -R "$GITHUB_REPOSITORY" --body-file "$body" \ + || echo "::warning::Could not comment on PR #$number" + else + echo "PR #$number is open and nothing changed this run." + fi + + - name: Upload report + if: always() + uses: actions/upload-artifact@v4 + with: + name: translation-report + if-no-files-found: ignore + path: | + ${{ runner.temp }}/translation-report.md + ${{ runner.temp }}/orders + + - name: Step summary + if: always() + env: + READY: ${{ steps.preflight.outputs.ready }} + INPUT_MODE: ${{ inputs.mode }} + run: | + { + echo "## Translation run (${INPUT_MODE:-translate}, TRANSLATED_ENV=${TRANSLATED_ENV:-unset})" + echo + if [ -f "$RUNNER_TEMP/translation-report.md" ]; then + head -c 60000 "$RUNNER_TEMP/translation-report.md" + echo + elif [ "$READY" != "true" ]; then + echo "Skipped: TRANSLATED_API_KEY secret or TRANSLATED_ENV variable is missing (see the job log)." + else + echo "No report was produced (mode probe/dry-run, or the run stopped early); see the job log." + fi + } >> "$GITHUB_STEP_SUMMARY" diff --git a/README.md b/README.md index 6dc37306c..4952d5444 100644 --- a/README.md +++ b/README.md @@ -68,25 +68,29 @@ swa start _site | `--no-api-copy` | Skip copying API docs to localized sites | | `--skip-api` | Reuse existing API metadata in content/api, ~30-40% faster (local markdown iteration only, requires `--serve`/`--lang`; never for testing/CI/CD/releases) | | `--permissive` | Don't treat English DocFX warnings as build failures (for local iteration; full/CI builds stay strict) | -| `--sync` | Sync English fallback for missing/outdated translations (for local dev) | +| `--sync` | Copy English over missing/outdated translations for a local build. Fallback copies only: never commit them. The translation status file is not touched | ## What the Build Script Does 1. **Generates DocFX configurations** - Runs `gen_redirects.py` to create `docfx.json` for each language 2. **Generates language manifest** - Creates `metadata/languages.json` for runtime language switching -3. **Syncs content** - Copies English source to `localizedContent/en/`. For other languages, only shared directories (assets, api) are synced by default since Crowdin manages translations. Use `--sync` to enable full English fallback for missing/outdated translations (useful for local development). -4. **Normalizes DocFX alerts** - Runs `normalize-localized-alerts.py` on each non-English language to repair Crowdin-collapsed Note/Tip/etc. alerts before building (see [DocFX Alerts and Translations](#docfx-alerts-and-translations)) +3. **Syncs content** - Copies English source to `localizedContent/en/`. For other languages, only shared directories (assets, api) are synced by default: the translations themselves are committed by the automated translation workflow (see [Translating Content](#translating-content)). Use `--sync` to copy English over missing/outdated translations for a local build; those fallback copies must never be committed, and the translation status file is left alone. +4. **Normalizes DocFX alerts** - Runs `normalize-localized-alerts.py` on each non-English language as a safety net that repairs collapsed Note/Tip/etc. alerts in translator output before building (see [DocFX Alerts and Translations](#docfx-alerts-and-translations)) 5. **Stabilizes heading anchors** - Runs `normalize-localized-heading-anchors.py` on each non-English language to inject English-slug bookmark anchors before translated headings, so `#anchor` cross-references resolve even when the heading text is translated (see [Bookmark Links and Translations](#bookmark-links-and-translations)) 6. **Builds documentation** - Runs DocFX for each requested language 7. **Fixes API docs** - Patches xref links in generated API documentation 8. **Copies API docs** - Shares English API docs with localized sites 9. **Injects SEO tags** - Adds hreflang and canonical tags to HTML files 10. **Generates SWA config** - Creates `staticwebapp.config.json` for Azure Static Web Apps routing +11. **Generates the sitemap index** - Runs `gen_sitemap_index.py` last, once every per-language sitemap exists, to write the site-wide sitemap index and `robots.txt` # Project Structure ``` / +├── .github/workflows/ +│ ├── deploy.yml # Build and deploy the site +│ └── translate.yml # Automated translation workflow (Translated), see Translating Content ├── build-docs.py # Main build script ├── run # Task runner for common dev tasks (see build_scripts/run_scripts/README.md) ├── build_scripts/ # Helper scripts @@ -98,9 +102,10 @@ swa start _site │ ├── gen_sitemap_index.py # Post-processes the English sitemap; generates the sitemap index │ ├── gen_staticwebapp_config.py # Generates Azure Static Web Apps routing config │ ├── inject_seo_tags.py # Adds hreflang and canonical tags to built HTML -│ ├── normalize-localized-alerts.py # Repairs Crowdin-collapsed DocFX alerts +│ ├── normalize-localized-alerts.py # Repairs collapsed DocFX alerts in translations (safety net) │ ├── normalize-localized-heading-anchors.py # Injects English-slug bookmark anchors into translations -│ ├── sync-localized-content.py # Syncs English content into localized build dirs +│ ├── sync-localized-content.py # Syncs English content into localized build dirs (local builds) +│ ├── translate-content.py # Submits changed English content to Translated, verifies and writes the translations │ ├── te_script_runner.py # Runs C# snippets against a throwaway model via the te CLI │ ├── test-fixtures/ # Fixtures for the build-script tests │ └── run_scripts/ # ./run subcommand scripts and shared lib.sh (see its README) @@ -109,14 +114,17 @@ swa start _site ├── localizedContent/ # Build directories for all languages │ ├── en/ # English build (generated, gitignored) │ └── {lang}/ # Translated content +│ ├── .translation-status.json # Per-file translation bookkeeping, owned by translate-content.py (tracked) │ ├── content/ # Translated markdown and UI strings (tracked) │ │ └── _ui-strings.json # Translated UI strings for this language │ └── docfx.json # Generated config (gitignored) ├── metadata/ +│ ├── build-config.json # Content/shared dirs and the `translation` section (environments, sources, passthrough) │ ├── languages.json # Language manifest (generated) -│ ├── language-metadata.json # Language display names and RTL flags +│ ├── language-metadata.json # Language display names, RTL flags and translatedLocale │ └── redirects.json # URL redirects (server 301s and client meta-refresh) ├── docfx-template.json # Base DocFX configuration template +├── pyproject.toml # ruff/mypy configuration for the checked build scripts ├── templates/ # DocFX templates └── _site/ # Generated output ├── en/ @@ -132,11 +140,127 @@ swa start _site 4. Add a translated `_ui-strings.json` to the content subdirectory (see [Translating UI Strings](#translating-ui-strings) below). If no translation is provided, an automatic fallback will be generated. 5. Run `python build-docs.py --all` to generate configs and build. Language will be added dynamically to language picker. -> **Note:** English content from `content/` is automatically copied to `localizedContent/en/content/` during build. For other languages, Crowdin manages translations via PRs. Shared directories (assets, api) are always synced from English. To use English as fallback for missing/outdated translations during local development, add the `--sync` flag. +> **Note:** English content from `content/` is automatically copied to `localizedContent/en/content/` during build. For other languages, translations arrive as review PRs from the automated translation workflow (see [Translating Content](#translating-content)); add `"translatedLocale"` (e.g. `"fr-FR"`) to the language's entry in `metadata/language-metadata.json` so it is picked up (the first run submits every scoped file for the new language, so start with `--limit`). Shared directories (assets, api) are always synced from English. To use English as fallback for missing/outdated translations during local development, add the `--sync` flag; never commit those copies. + +# Translating Content + +Translations are produced by [Translated](https://translated.com) through the TranslationOS API and arrive as review PRs from the `localization` branch, driven by `.github/workflows/translate.yml` and `build_scripts/translate-content.py`. English in `content/` is the only authored source. Hand edits to `localizedContent/{lang}/content/` - on the translation branch or on `main` - are fine, but they are overwritten the next time the English source changes unless the file is pinned (see [Runbook](#runbook)). + +## Scope and configuration + +The `translation` section of `metadata/build-config.json` defines what is translated and where: + +- **`sources`** - globs relative to `content/`: every `**/*.md` (including the sidebar `toc.md` files), `404.html`, `getting-started/app/**/*.html`, `toc.yml` and `_ui-strings.json`. Files under the shared directories (`assets`, `api`) are always skipped. +- **`passthrough`** - `whats-new/**/*.html`. The release-note pages are not translated: every run copies them from English byte-for-byte when their hash differs (or the target is missing) and records them in the status file with status `copied`. +- **`environments`** - the two endpoints the script can talk to. `sandbox` is `https://api.sandbox.translated.com/v2/` with service type `economy` (the tier that machine-translates in this account's sandbox and delivers within minutes; the spec's `premium` parks sandbox requests at `analyzing` indefinitely); `production` is `https://api.translated.com/v2/` with service type `null` until the account's service type is known. `TRANSLATED_ENV` selects which one is active (see below). +- **`batchSize`** (200 orders per `/translate` call) and **`sandboxLimits`** (`maxFilesPerRun`: 20, `maxCharsPerRun`: 200000; see [Sandbox caps](#sandbox-caps)). +- **`instructions`** - file-level notes attached to every order. They are shown to human linguists on human service types; machine translation does not enforce them. Glossaries and do-not-translate lists are configured by Translated on the service type itself, not per request. + +Every target language folder needs a `translatedLocale` (`es-ES`, `zh-CN`) in `metadata/language-metadata.json`. A run checks this up front and fails with the list of missing locales before submitting anything. + +## Environment variables + +| Variable | Where it lives | Meaning | +|----------|----------------|---------| +| `TRANSLATED_API_KEY` | repository secret / your shell | API key for the active environment. | +| `TRANSLATED_ENV` | repository variable / your shell | `sandbox` or `production`; selects the entry of `translation.environments` to use. There is no default: every network action (`--submit`, `--poll`, `--run`, `--probe`) refuses to run while it is unset or unknown. | +| `TRANSLATED_SERVICE_TYPE` | repository variable / your shell | Overrides the environment's `serviceType`. Production has no default and refuses to run while the resolved service type is empty; run `--probe` to list the account's service types. | +| `TRANSLATED_SANDBOX_API_KEY` | repository secret (optional) | Sandbox key for the label-gated PR dry run once `TRANSLATED_API_KEY` holds the production key. Only the workflow reads it (and passes it to the script as `TRANSLATED_API_KEY`); when it is absent the dry run falls back to `TRANSLATED_API_KEY`. | + +Offline actions (`--plan`, `--self-test`, `--dump-orders`, `--baseline`) need neither variable nor key. Before any network call the script prints the resolved environment, base URL and service type - read that line before you continue. + +## Command-line reference + +`python build_scripts/translate-content.py [options]`. Exactly one action per invocation: + +| Action | What it does | Network | +|--------|--------------|---------| +| `--plan` | Print what would happen per language: missing/outdated files to submit, pending jobs, recorded failures, pinned and unscoped entries, orphans, passthrough copies, character counts. | no | +| `--submit` | Submit missing/outdated files (one order per file and language), copy passthrough files, delete orphaned translations. | yes | +| `--poll` | Collect deliveries for the pending jobs, repair and verify them, write the translations. | yes | +| `--run` | `--submit` followed by `--poll` - what CI runs. | yes | +| `--baseline` | Seed the status file from the existing translations: accept those that verify (after repair) and mark only the unrepairable ones untranslated (see [Baseline](#baseline)). | no | +| `--self-test` | Offline test suite: identity round-trip over the whole corpus, verifier/repair fixtures, and a fake-client end-to-end run of `--submit`/`--poll` in a temporary mini repo. Exits 1 on any failure; the `pull_request` dry-run job runs it on PRs that touch the translation script or its config, and every `translate` run starts with it. | no | +| `--probe` | Fetch the account's service-type names and language list, print them, and check `sourceLocale`, every `translatedLocale` and the resolved service type against them (exit 1 with the closest matches on a mismatch). | yes | +| `--dump-orders DIR` | Build the exact `/translate` request bodies through the same code path as `--submit` and write them to `DIR/{lang}/{rel}.json` plus `DIR/summary.json` (counts, characters, environment). Nothing is sent and the status file is not modified. | no | +| `--cancel-pending` | Cancel every pending request of the active environment (`POST /translate/cancel`, chunked by 200) and drop its `pendingJobs` record; `files` entries and `failures` are untouched, and pending jobs from the other environment are left alone and reported. In production it refuses without `--force`, because cancelling production requests discards paid work. Translated only cancels requests still in an early state (`upload`, `wc raw`, `ingested`, `bucketing`) and refuses a whole call otherwise, so a refused batch is retried one request at a time; whatever cannot be cancelled stays pending with a warning and the run exits 1. | yes | + +Options: + +| Option | Meaning | +|--------|---------| +| `--lang X` | Restrict to one language (repeatable). | +| `--file GLOB` | Restrict to files matching a glob relative to `content/` (repeatable). | +| `--limit N` | Submit at most N files per language (`--plan` output honours it too). | +| `--wait MINUTES` | How long `--poll`/`--run` keep waiting for deliveries. | +| `--force` | Treat the files matched by `--file`/`--limit` as outdated even when they are current. With neither filter it means the whole corpus. With `--cancel-pending`: allow cancelling production requests. | +| `--retry-failed` | Re-plan files whose recorded failure is at the current source hash (otherwise they are skipped until the English source changes). | +| `--lenient` | Write deliveries even when verification finds problems - except empty or otherwise unusable text, which is never written, and `toc.yml`/`_ui-strings.json` deliveries, which are always rejected on problems. For local inspection only; the workflow never passes it. | +| `--allow-unbounded` | Lift the sandbox caps. The workflow never passes it. | +| `--report PATH` | Write the Markdown run report (used as the PR body). | +| `--baseline-ref REF` | With `--baseline`: hash the English sources at this git ref (via `git show`) instead of the working tree. The ref must resolve to a commit, otherwise the run exits 1. | +| `--overwrite-baseline` | With `--baseline`: allow overwriting a status file that already has `files` entries. | +| `--repair-existing` | With `--baseline`: also write the repaired text of existing translations that the repair step had to fix (fences, inline code, markers, link targets, HTML attributes restored from English). Without it only the status file changes. | + +## How a run works + +1. **Plan.** Each English file's SHA-256 (line endings normalized) is compared with the entry in `localizedContent/{lang}/.translation-status.json`. Missing and outdated files are submitted. Files with `"manual": true` are skipped and reported as *pinned*; a delivery that was already in flight when the pin was added is discarded when it arrives. Files whose recorded failure is at the current source hash are skipped until `--retry-failed` or `--force`. Status entries that are neither in `sources`, `passthrough` nor orphans are reported as *unscoped* and dropped. Translations whose English source no longer exists (*orphans*) are deleted - only when no `--file`/`--limit` filter is active. +2. **Submit.** One order per file and language, in batches of `batchSize`; every batch carries an `x-idempotency-id` so a retry after a timeout or 5xx never creates duplicate (billed) requests. Markdown is sent as `text/markdown`; `toc.yml` is sent as a JSON map of its `name` values so hrefs never reach the translator; in `_ui-strings.json` every `{name}` placeholder is encoded as `{{name}}` (a syntax Translated protects) and decoded on delivery. The status file is saved after every accepted batch, so nothing Translated accepted is ever left unrecorded. A rejected batch records each of its files under `failures` with the API error and the run continues with the next batch and language, exiting 1 at the end. When a file is resubmitted while an older job for it is still in flight, the old job is cancelled and reported as *superseded*. +3. **Poll.** One status request per language per round, every 30 s backing off to 60 s and 120 s, until `--wait` minutes have passed. Job status `delivered` or `invoiced` means done; `failed`, `failed delivery` or `cancelled` means failed; everything else (including `completed`) means still waiting. In the sandbox a request stops at `completed`, because delivery is a separate step a project manager performs in production; sandbox runs therefore call `POST /sandbox/delivery` for their completed requests, at most once per request per run, so polling can finish (the call is not retried and a repeating error is warned about once per language). Production runs never call it. Jobs pending for more than 7 days time out. Each pending job records the environment it was submitted in; pending jobs from another environment than the active one are dropped with a report line ("dropped: submitted in sandbox"). +4. **Repair, then verify.** Every delivery is compared with its English source. Whatever can be aligned positionally is restored from English rather than trusted: fenced code blocks (fence lines and bodies), inline code spans, alert and include markers (`[!NOTE]`, `[!include]`), link targets, HTML `href`/`src` attributes, the BOM and the line-ending style, and every frontmatter key except `title`/`description`, which take the translated value (quoted when YAML needs it). What cannot be aligned is a verification problem: heading count differs; fence, marker or link counts differ; HTML tag set differs; JSON key set or per-key placeholder set differs; a `toc.yml` key is missing or a value contains a newline; the source has frontmatter and the delivery does not; or the delivered text is identical to the English source. The one exception is inline code: when the number of backtick spans differs from English, the delivery is still written with the spans as delivered and the drift is reported as a *warning* (`inline code spans 57 -> 59 (missing [...], added [...])`), not as a problem. MT routinely adds or drops a backtick pair around a word like `true` or a variable name; rejecting the delivery would resubmit (and bill) the whole page for one span, and the same MT would do it again on the next delivery. The report says what was repaired (`repaired: n fences, m inline code, k markers, j links`) and lists the files written with warnings. +5. **What "failed" means.** A delivery with problems is *not* written: the existing translation stays untouched, the file is recorded under `failures` in the status file (with the reasons and the API status), listed in the report, and not resubmitted until the English source changes or `--retry-failed`/`--force` is given. `--lenient` (local inspection only) writes it anyway, except that empty or otherwise unusable text is never written and `toc.yml`/`_ui-strings.json` deliveries are never written leniently. Warnings are not failures: a delivery whose only finding is inline-code count drift is written and recorded `translated` like a clean one, and listed under *written with warnings* in the report so the reviewer can check the spans; `--baseline` likewise accepts such existing translations as current. +6. **Commit and PR.** The workflow commits `localizedContent` to the target branch and opens the PR, or comments the run report on the open one - also when the translate step itself exited 1 (a rejected batch or a failed language), in which case the PR body says so and whatever was delivered is still reviewable. Production runs use `localization`; sandbox runs use `localization-sandbox` and open a *draft* PR titled "New translations (sandbox - do not merge)" labelled `sandbox`. The report shows, per language, the files submitted, delivered, repaired (per kind), written with warnings, copied, superseded, dropped, still pending, failed and removed, plus billed word counts when the API returns them. `deploy.yml` builds a `localization` PR only when a review is requested, so review it, request a review to get a preview build, then merge. + +## The workflow (`translate.yml`) + +Two jobs: + +- **`dry_run`** runs on pull requests that touch the workflow, `translate-content.py`, `config_loader.py`, `build-config.json` or `language-metadata.json`. It checks out the PR head and runs `--self-test`, `--plan` and `--dump-orders` (uploaded as an artifact, appended to the job summary). Adding the label `translate-sandbox-dryrun` to the PR additionally runs `--run --force --limit 1 --file "features/toc.md" --lang es --wait 5` against the sandbox and uploads the report and a `translations.patch` with the resulting diff; that step uses the secret `TRANSLATED_SANDBOX_API_KEY` when it is set and `TRANSLATED_API_KEY` otherwise, and is skipped when neither exists. It never pushes and never opens a PR. +- **`translate`** runs on `workflow_dispatch` always, and on `push` to `main` (paths `content/**`, `metadata/build-config.json`, `metadata/language-metadata.json`, `translate-content.py`, `config_loader.py`, the workflow itself) and on the six-hourly schedule (`17 */6 * * *`) only when the repository variable `TRANSLATED_ENV` is `production`. Until then the automatic triggers are inert; a missing key or environment produces a warning, not a red run. Dispatch inputs: `mode` (`translate` | `probe` | `dry-run`), `target_branch` (default empty = auto: `localization` when `TRANSLATED_ENV` is `production`, `localization-sandbox` otherwise; an explicit `localization` is refused outside production in `translate` mode; any other name must start with `localization-`, so the bot can never push to `main` or a feature branch), `limit`, `wait` (minutes; values above 35 are refused so the run fits the 45-minute job timeout), `force` and `file`. A sandbox dispatch must set `limit` or `file`, because the [sandbox caps](#sandbox-caps) refuse an unbounded run. The job first merges the open translation branch into main's tree (after deleting a stale branch whose PR was already merged or closed); a translation branch that cannot be merged stops the run with an error instead of silently continuing from `main`. It then runs `--self-test`, then `--run --wait --limit [--force] [--file ] --report ...`, then pushes and opens or updates the PR; the PR step also runs when the translate step failed, with a note in the PR body. In `probe` mode it runs `--probe` only. In `dry-run` mode it runs `--plan` and `--dump-orders` and, in the sandbox only, `--run` with a report; nothing is committed or pushed. In production, dry-run stops after the dumped orders: a production submission that is never pushed would be billed and then resubmitted by the next real run. The full report is always uploaded as an artifact and appended to the job summary. + +Secrets and settings: `TRANSLATED_API_KEY` (repository secret; the key of the environment `TRANSLATED_ENV` names), `TRANSLATED_SANDBOX_API_KEY` (optional repository secret; the sandbox key the label-gated PR dry run uses once `TRANSLATED_API_KEY` holds the production key), `TRANSLATED_ENV` and `TRANSLATED_SERVICE_TYPE` (repository variables), *Allow GitHub Actions to create and approve pull requests* enabled under Settings > Actions > General, and *Automatically delete head branches* enabled under Settings > General. The `translate` job runs with `contents: write`, `pull-requests: write` and `issues: write` (the last one creates the `sandbox` label). + +## Sandbox caps + +With `TRANSLATED_ENV=sandbox` a run refuses (with the counts) to submit more than `sandboxLimits.maxFilesPerRun` files or `maxCharsPerRun` characters, and requires `--limit` or `--file`, unless `--allow-unbounded` is given. Translated blocks sandbox keys for excessive use of the human-translation workflow, and `/translate` is that workflow whatever the service type, so keep sandbox runs small. Sandbox output is throwaway machine translation: never merge a sandbox PR and never commit sandbox translations from a local run. + +## Local use + +```bash +python build_scripts/translate-content.py --self-test # offline checks (no key) +python build_scripts/translate-content.py --plan # what would be sent, and how many characters +python build_scripts/translate-content.py --dump-orders /tmp/orders # the exact request bodies; nothing is sent +export TRANSLATED_ENV=sandbox TRANSLATED_API_KEY=... # PowerShell: $env:TRANSLATED_ENV = "sandbox" +python build_scripts/translate-content.py --probe # key, endpoint, locales and service type +python build_scripts/translate-content.py --submit --lang es --limit 3 # send a few files +python build_scripts/translate-content.py --poll --wait 10 --report r.md # collect deliveries +python build_scripts/translate-content.py --run --wait 25 --report r.md # what CI does (production; in the sandbox add --limit or --file) +python build_scripts/translate-content.py --baseline --baseline-ref --overwrite-baseline --lang es # (re)seed the status file, see Baseline +``` + +Afterwards discard the sandbox output with `git checkout -- localizedContent` (a plain `git diff --stat` first shows what a real run would have changed). + +## Baseline + +`--baseline` was used once when migrating from the previous provider, and is the way to (re)seed a status file. For every scoped source with an existing translation it runs the same repair-then-verify step a delivery goes through, between English and the existing translation: translations that verify, or whose problems the repair step can fix (fences, inline code, markers, link targets, HTML attributes restored from English), are recorded as `translated` at the English hash; only unrepairable ones are recorded as `untranslated` with an empty hash and the reason is printed, so they are submitted on the first run; missing targets are `untranslated`. By default the repaired text is *not* written - the file is accepted as it is and only the status file changes; pass `--repair-existing` to write the repaired text as well. The English hash is taken from `--baseline-ref REF` when given (via `git show`, so pages edited since that ref are re-translated on the first run); the ref must resolve to a commit or the run exits 1. Unscoped entries are dropped and passthrough entries are left alone. It refuses to run when the status file already has `files` entries unless `--overwrite-baseline` is given, and prints a per-language summary including the files it marked outdated and why. Existing translations whose only finding is inline-code count drift are accepted as current too (printed as `warning` lines and counted as *accepted with inline-code warnings*), for the same reason such deliveries are written: resubmitting a whole page for one backtick pair is not worth it. + +## Status file + +`localizedContent/{lang}/.translation-status.json` is owned by `translate-content.py` and committed with the translations. It holds `language`, `sourceBaseline`, `files` (per file: `sourceHash`, `status` = `translated` | `untranslated` | `copied`, and optionally `"manual": true`), `pendingJobs` and `failures` (present only when non-empty), and `summary` (counts including `copied`, `pinned`, `pendingJobs`, `failures`). `build-docs.py --sync` reads it but never writes it. + +## Runbook + +- **Fix a translation.** While the translation PR is open, push the fix to its branch (`localization`); the next run merges that branch before doing anything else, so the fix survives. After the PR is merged, edit the file on `main` through a normal PR. Either way the file is overwritten by the next machine translation when its English source changes, unless you pin it. +- **Pin (skip) a file.** Add `"manual": true` to the file's entry in `localizedContent/{lang}/.translation-status.json`. Pinned files are reported and never submitted or overwritten, and a delivery that was already in flight when you added the pin is discarded when it arrives; remove the flag to hand the file back to the workflow. +- **Re-translate one file.** Dispatch `translate.yml` with `force=true` and `file=features/dax-query.md` (and `limit` as needed), or locally: `python build_scripts/translate-content.py --run --force --file "features/dax-query.md" --lang es --wait 10 --report r.md`. Local runs use whatever `TRANSLATED_ENV` says; only commit output from production. +- **A file failed.** The report and the `failures` entry list the reasons (for example "heading count differs" or "delivered content identical to source"). Usually the English source has a structure the translator cannot preserve: fix the English, and the changed hash resubmits the file on the next run (or use `--retry-failed` to try again unchanged). If the delivery is acceptable despite the problems, fetch it locally with `--run --force --file --lang --lenient --report r.md`, review the written file, and commit it through a normal PR. +- **Clean slate for smoke tests.** `--cancel-pending` cancels every pending sandbox request and drops the records (`TRANSLATED_ENV=sandbox python build_scripts/translate-content.py --cancel-pending`); then revert any sandbox output with `git checkout -- localizedContent`. Requests that Translated no longer lets you cancel (past `bucketing`) stay pending and are reported; wait for them to deliver or drop them from the next run's report. +- **Switch sandbox -> production.** Keep this order. Production refuses to run without a service type, so between flipping `TRANSLATED_ENV` and setting `TRANSLATED_SERVICE_TYPE` every push and scheduled run would fail; learn the name first. (1) Replace the `TRANSLATED_API_KEY` secret with the production key, and add `TRANSLATED_SANDBOX_API_KEY` with the sandbox key if the label-gated PR dry run should keep working. (2) Learn the production service-type name *before* flipping the variable: locally, `TRANSLATED_ENV=production TRANSLATED_API_KEY= python build_scripts/translate-content.py --probe` (the probe needs no service type; in PowerShell set `$env:TRANSLATED_ENV` and `$env:TRANSLATED_API_KEY` first). A dispatch with `mode=probe` probes whatever environment the repository variable names, so it only reaches production after step 4. (3) Set the repository variable `TRANSLATED_SERVICE_TYPE` to that name. (4) Set the repository variable `TRANSLATED_ENV` to `production`; from now on the automatic triggers are live. (5) Dispatch `translate.yml` with `mode=probe` to confirm the key, `en-US`/`es-ES`/`zh-CN` and the service type from the workflow. (6) Dispatch `mode=translate` with `limit=3`, `force=true`, `file=features/toc.md` and `target_branch` empty (auto = `localization`) for the first reviewed PR; request a review to get a preview build, merge. (7) Let the push and schedule triggers run; watch the first two. Afterwards record the service type in `build-config.json` (`environments.production.serviceType`) in a follow-up PR. +- **Stale-branch cleanup.** Before merging the translation branch into its tree, the workflow checks whether the branch tip is already contained in `main` or its PR is merged or closed (`gh pr list --head --state merged` / `--state closed`). Closing the translation PR - merged or not - marks the branch stale: the next run deletes the remote branch and starts from `main`, so a squash-merged translation PR never produces an empty follow-up PR (*Automatically delete head branches* makes this the normal path). The pending-job records that lived on that branch are lost with it, so files still in flight are resubmitted (and billed) on the next run. To keep unmerged translations, leave the PR open or reopen it before the next run. # Bookmark Links and Translations -When linking to a specific heading within a page (e.g., `#my-heading`), DocFX auto-generates the anchor ID from the heading **text**. Because Crowdin translates that text, the generated anchor changes per language (`#model-io` becomes `#es-del-modelo`, etc.), so a hardcoded English `#anchor` link breaks in every translated page and DocFX logs an `InvalidBookmark` warning. English builds stay clean because the anchors match there. +When linking to a specific heading within a page (e.g., `#my-heading`), DocFX auto-generates the anchor ID from the heading **text**. Because translation changes that text, the generated anchor changes per language (`#model-io` becomes `#es-del-modelo`, etc.), so a hardcoded English `#anchor` link breaks in every translated page and DocFX logs an `InvalidBookmark` warning. English builds stay clean because the anchors match there. ## Automatic anchor stabilization (the build handles this) @@ -147,9 +271,9 @@ When linking to a specific heading within a page (e.g., `#my-heading`), DocFX au ## E/S del modelo ``` -DocFX accepts the injected `id` as a valid bookmark, so `#model-io` resolves and the link lands on the right section while the heading keeps its translated text. Headings are aligned to the English source positionally (Crowdin preserves heading structure); if the heading counts differ, the file is skipped and reported rather than risk a misaligned anchor. The script is idempotent (it strips its own `data-loc-xref` anchors before recomputing) and never modifies English. +DocFX accepts the injected `id` as a valid bookmark, so `#model-io` resolves and the link lands on the right section while the heading keeps its translated text. Headings are aligned to the English source positionally; this is safe because the translation script rejects deliveries whose heading count differs from the English source. If the heading counts differ anyway (a stale or hand-edited translation), the file is skipped and reported rather than risk a misaligned anchor. The script is idempotent (it strips its own `data-loc-xref` anchors before recomputing) and never modifies English. -The build runs it automatically for each non-English language before DocFX (step 5 of [What the Build Script Does](#what-the-build-script-does)). You can also run it manually after a Crowdin pull: +The build runs it automatically for each non-English language before DocFX (step 5 of [What the Build Script Does](#what-the-build-script-does)). You can also run it manually after merging a translation PR: ```bash python build_scripts/normalize-localized-heading-anchors.py # all languages @@ -161,7 +285,7 @@ python build_scripts/normalize-localized-heading-anchors.py es # a singl ## Authoring guidance - **Prefer the bracketed link form** `[text](xref:uid#anchor)` over the bare `@uid#anchor` autolink. The closing `)` delimits the anchor, so trailing punctuation in any language can never leak into it. -- **For a rename-proof anchor**, add an explicit `` tag above the heading. Crowdin does not translate HTML `name` attributes, so the anchor stays stable across all languages *and* survives English heading renames — unlike an auto-generated slug. Only add these to headings actually linked to; there is no need to add them everywhere. +- **For a rename-proof anchor**, add an explicit `` tag above the heading. Translators and machine translation leave HTML `name` attributes alone (and the translation script restores HTML attributes from English), so the anchor stays stable across all languages *and* survives English heading renames — unlike an auto-generated slug. Only add these to headings actually linked to; there is no need to add them everywhere. ```markdown @@ -177,11 +301,11 @@ DocFX renders styled alert boxes (Note, Tip, Important, Warning, Caution) from a > Your note text here. ``` -When an alert like this is nested inside a list item, Crowdin collapses the two lines into one on export, producing `> [!NOTE]> Your note text here.`. DocFX requires the marker to be alone on its line, so the collapsed form is downgraded to a plain `
` — losing the styled box — and the build logs an `invalid-note-section` warning. Only list-nested alerts are affected; top-level alerts round-trip through Crowdin unchanged. +Some translation exports collapse the two lines into one when an alert like this is nested inside a list item, producing `> [!NOTE]> Your note text here.`. DocFX requires the marker to be alone on its line, so the collapsed form is downgraded to a plain `
` — losing the styled box — and the build logs an `invalid-note-section` warning. Only list-nested alerts are affected; top-level alerts round-trip unchanged. -`build_scripts/normalize-localized-alerts.py` repairs this by splitting the collapsed form back into two lines, preserving the original indentation so the alert stays inside its list item. It is idempotent and only rewrites the exact collapsed pattern (text inside fenced code blocks is left untouched), so it is safe to run repeatedly. +`build_scripts/normalize-localized-alerts.py` repairs this by splitting the collapsed form back into two lines, preserving the original indentation so the alert stays inside its list item. It is a safety net for translator output: the translation script already restores alert markers from English, but older translations and hand edits can still carry the collapsed form. It is idempotent and only rewrites the exact collapsed pattern (text inside fenced code blocks is left untouched), so it is safe to run repeatedly. -The build runs it automatically for each non-English language before DocFX (step 4 of [What the Build Script Does](#what-the-build-script-does)). You can also run it manually after a Crowdin pull: +The build runs it automatically for each non-English language before DocFX (step 4 of [What the Build Script Does](#what-the-build-script-does)). You can also run it manually after merging a translation PR: ```bash python build_scripts/normalize-localized-alerts.py # fix all languages @@ -194,7 +318,7 @@ python build_scripts/normalize-localized-alerts.py es # fix a single lan The `_ui-strings.json` file controls the text of site-wide UI elements that are not part of the documentation content itself: the header navigation, header buttons, footer text, and the AI translation warning banner. These strings are applied at runtime by the JavaScript bundle for non-English pages. -The English source is at `content/_ui-strings.json`. To provide translations for a language, create `localizedContent/{lang}/content/_ui-strings.json` with the same keys and translated values. +The English source is at `content/_ui-strings.json`. For existing languages the translated file `localizedContent/{lang}/content/_ui-strings.json` is produced by the automated translation workflow like any other source file (see [Translating Content](#translating-content)): the run encodes `{placeholder}` tokens so the translator leaves them alone and rejects a delivery whose key set or placeholders differ from English. For a new language you can also seed the file by hand with the same keys and translated values. If a key is missing from a language's file, or no `_ui-strings.json` exists at all, the English value is used as fallback. diff --git a/build-docs.py b/build-docs.py index 91920d985..7a08ae9d3 100644 --- a/build-docs.py +++ b/build-docs.py @@ -171,7 +171,9 @@ def prepare_localized_content(lang: str, sync: bool = False) -> int: For English: always copies all source content (required for docfx) For other languages: sync=True: full English fallback sync (hash comparison, copy missing/outdated) - sync=False: only sync shared directories (assets, api) — Crowdin manages translations + sync=False: only sync shared directories (assets, api) — translations are + committed by the automated translation workflow + (translate-content.py / translate.yml) """ if lang == "en": # English always needs full sync @@ -193,8 +195,10 @@ def prepare_localized_content(lang: str, sync: bool = False) -> int: if result != 0: return result - # Repair Crowdin-collapsed DocFX alerts (e.g. "> [!NOTE]> text") before docfx - # builds this language, so alerts render as styled boxes instead of plain quotes. + # Repair collapsed DocFX alerts in translator output (e.g. "> [!NOTE]> text") + # before docfx builds this language, so alerts render as styled boxes instead + # of plain quotes. A safety net: the translation script verifies markers, but + # older translations and hand edits can still carry the collapsed form. result = run_command( [sys.executable, "build_scripts/normalize-localized-alerts.py", lang], f"Normalizing DocFX alerts for {lang}" @@ -226,9 +230,10 @@ def build_language(lang: str, sync: bool = False, skip_api: bool = False, permis return result # Build the documentation — fail on DocFX warnings only for English (the - # authored source). Localized content is Crowdin-managed and may carry - # translation warnings that must not block deployment. `permissive` lifts the - # English gate too, for local iteration where transient warnings are expected + # authored source). Localized content comes from the automated translation + # workflow and may carry translation warnings that must not block deployment. + # `permissive` lifts the English gate too, for local iteration where transient + # warnings are expected # (warnings are still printed, just not fatal); full/CI builds leave it off. # # `docfx build` skips API metadata regeneration and reuses the existing @@ -348,7 +353,7 @@ def main() -> int: parser.add_argument("--no-api-copy", action="store_true", help="Skip copying API docs to localized sites") parser.add_argument("--skip-api", action="store_true", help="LOCAL markdown iteration only (requires --serve/--lang): reuse existing content/api, ~30-40%% faster. NEVER for testing/CI/CD/releases") parser.add_argument("--permissive", action="store_true", help="Don't treat English DocFX warnings as build failures (for local iteration; keep full/CI builds strict)") - parser.add_argument("--sync", action="store_true", help="Sync English fallback for missing/outdated translations (for local dev)") + parser.add_argument("--sync", action="store_true", help="Copy English over missing/outdated translations for a local build (fallback copies only; never commit them)") args = parser.parse_args() diff --git a/build_scripts/README.md b/build_scripts/README.md index bd0a01370..a259fefa7 100644 --- a/build_scripts/README.md +++ b/build_scripts/README.md @@ -4,6 +4,7 @@ This document covers new build tools: - `te_script_runner.py`: standalone runner and module for other tools that need to execute C# scripts - `csharp_doctest.py`: compiles and runs annotated `csharp` code blocks in markdown files - `check_links.py`: dead link checker for built site +- `translate-content.py`: submits changed English content to Translated (TranslationOS), then repairs, verifies and writes the delivered translations; `python build_scripts/translate-content.py --self-test` runs its offline test suite (no key, no network; the `pull_request` dry-run job runs it on PRs that touch the script or its config, and every `translate` run starts with it). CLI, environment variables and runbook: [Translating Content](../README.md#translating-content) Existing docfx and localization orchestration can be found in [../README.md](../README.md) @@ -11,7 +12,7 @@ Existing docfx and localization orchestration can be found in [../README.md](../ Required on PATH: -- `python3` -- 3.10+ (the scripts use 3.10 syntax; validated on 3.14). +- `python3` -- 3.11+ (`pyproject.toml` targets py311 and CI runs 3.11; validated on 3.14). - `uv` -- provides `uvx`, for lint and type-check. - `te` -- the Tabular Editor CLI, for the doc-validation scripts; you should use a build aligned with TE3 release for checking docs. - `docfx` -- or `dotnet` with the pinned local docfx tool, to build the site. @@ -22,10 +23,11 @@ paths like `_site` and `content/` resolve relative to the current directory. ## Contributing and development notes Scripts have no build phase. -All Python build scripts are linted and type-checked: +The Python build scripts listed in `pyproject.toml` (`check_links.py`, `csharp_doctest.py`, `te_script_runner.py`, `translate-content.py`, `config_loader.py`) are linted, format-checked and type-checked by `./run scripts check`; add a script to both lists there when it meets the bar: ```shell -$ uvx ruff check --select F,B,SIM,I,UP +$ uvx ruff check --select F,B,SIM,I,UP --line-length 120 --target-version py311 +$ uvx ruff format --check --line-length 120 $ uvx mypy --strict ``` diff --git a/build_scripts/config_loader.py b/build_scripts/config_loader.py index 28d0468ff..cf7f14d86 100644 --- a/build_scripts/config_loader.py +++ b/build_scripts/config_loader.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# -*- coding: utf-8 -*- """ Shared configuration loader for build scripts. @@ -12,7 +11,6 @@ from pathlib import Path from typing import Any - # Default paths (relative to project root) BUILD_CONFIG_PATH = "metadata/build-config.json" REDIRECTS_CONFIG_PATH = "metadata/redirects.json" @@ -25,60 +23,54 @@ def load_build_config(config_path: Path | str | None = None) -> dict[str, Any]: """Load the build configuration from JSON file. - + Returns the full config dict with the following keys: - contentDirectories: directories with translatable content - sharedDirectories: assets/api that aren't translated - rootFiles: root-level files (index.md, toc.yml, etc.) """ global _build_config - + if _build_config is not None and config_path is None: return _build_config - - if config_path is None: - config_path = Path(BUILD_CONFIG_PATH) - else: - config_path = Path(config_path) - + + config_path = Path(BUILD_CONFIG_PATH) if config_path is None else Path(config_path) + if not config_path.exists(): raise FileNotFoundError(f"Build config not found: {config_path}") - + with open(config_path, encoding="utf-8") as f: config: dict[str, Any] = json.load(f) - + if config_path == Path(BUILD_CONFIG_PATH): _build_config = config - + return config def load_redirects_config(config_path: Path | str | None = None) -> dict[str, Any]: """Load the redirects configuration from JSON file. - + Returns the full config dict with the following keys: - serverRedirects: 301 redirects handled by Azure SWA - clientRedirects: meta-refresh HTML redirects """ global _redirects_config - + if _redirects_config is not None and config_path is None: return _redirects_config - - if config_path is None: - config_path = Path(REDIRECTS_CONFIG_PATH) - else: - config_path = Path(config_path) - + + config_path = Path(REDIRECTS_CONFIG_PATH) if config_path is None else Path(config_path) + if not config_path.exists(): raise FileNotFoundError(f"Redirects config not found: {config_path}") - + with open(config_path, encoding="utf-8") as f: config: dict[str, Any] = json.load(f) - + if config_path == Path(REDIRECTS_CONFIG_PATH): _redirects_config = config - + return config @@ -108,7 +100,7 @@ def get_root_files(config: dict[str, Any] | None = None) -> list[str]: def get_legacy_shortcuts(config: dict[str, Any] | None = None) -> dict[str, str]: """Get legacy shortcut redirects (old URL → new URL). - + These are server-side 301 redirects for high-priority/vanity URLs. Filters out keys starting with '_' which are used for comments. """ @@ -121,7 +113,7 @@ def get_legacy_shortcuts(config: dict[str, Any] | None = None) -> dict[str, str] def get_client_redirects(config: dict[str, Any] | None = None) -> dict[str, str]: """Get client-side redirects for legacy content URLs. - + These are meta-refresh HTML redirects for content migration. Keys are paths like '/te2/Getting-Started.html', values are target paths. Filters out keys starting with '_' which are used for comments. @@ -135,13 +127,13 @@ def get_client_redirects(config: dict[str, Any] | None = None) -> dict[str, str] def get_all_redirects(config: dict[str, Any] | None = None) -> dict[str, str]: """Get all redirects (both server and client) merged together. - + Returns a combined dict of all redirects. Server redirects take precedence if there are any duplicates (though there shouldn't be). """ if config is None: config = load_redirects_config() - + all_redirects = {} all_redirects.update(get_client_redirects(config)) all_redirects.update(get_legacy_shortcuts(config)) @@ -166,9 +158,7 @@ def get_base_url(config: dict[str, Any] | None = None) -> str: config = load_build_config() base_url: str | None = config.get("baseUrl") if not base_url: - raise KeyError( - '"baseUrl" is missing from metadata/build-config.json' - ) + raise KeyError('"baseUrl" is missing from metadata/build-config.json') return base_url.rstrip("/") @@ -198,36 +188,38 @@ def get_sitemap_exclude(config: dict[str, Any] | None = None) -> list[dict[str, def compute_file_hash(file_path: Path | str) -> str: """Compute SHA256 hash of a file's contents. - + + CRLF line endings are normalized to LF before hashing so the hash is the same + for a Windows checkout (core.autocrlf), a Linux CI checkout and the blob in + git. The translation status files compare these hashes across all three. + Returns a hex string prefixed with 'sha256:' for clarity. Returns empty string if file doesn't exist. """ file_path = Path(file_path) - + if not file_path.exists(): return "" - - sha256_hash = hashlib.sha256() + with open(file_path, "rb") as f: - # Read in chunks for large files - for chunk in iter(lambda: f.read(8192), b""): - sha256_hash.update(chunk) - - return f"sha256:{sha256_hash.hexdigest()}" + data = f.read() + + normalized = data.replace(b"\r\n", b"\n") + return f"sha256:{hashlib.sha256(normalized).hexdigest()}" def get_all_content_files(content_dir: Path | str) -> list[Path]: """Get all content files (markdown, yaml) from a content directory. - + Returns list of paths relative to the content directory. """ content_dir = Path(content_dir) - + if not content_dir.exists(): return [] - + files: list[Path] = [] for pattern in ["**/*.md", "**/*.yml", "**/*.yaml"]: files.extend(content_dir.glob(pattern)) - + return sorted(files) diff --git a/build_scripts/normalize-localized-alerts.py b/build_scripts/normalize-localized-alerts.py index 3582c6990..d951bfadf 100644 --- a/build_scripts/normalize-localized-alerts.py +++ b/build_scripts/normalize-localized-alerts.py @@ -1,15 +1,16 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- """ -Normalize DocFX alerts in Crowdin-translated content. +Normalize DocFX alerts in translated content. -Crowdin collapses DocFX/GitHub-style alerts that are nested inside list items, -joining the marker line and the first content line. This: +Some translation exports (machine translation included) collapse DocFX/GitHub-style +alerts that are nested inside list items, joining the marker line and the first +content line. This: > [!NOTE] > text -comes back from Crowdin as: +comes back from the translator as: > [!NOTE]> text @@ -20,7 +21,10 @@ This script finds the collapsed form and splits it back into two lines, preserving the original indentation so the alert stays inside its list item. It is idempotent and only rewrites the exact collapsed pattern, so it is safe -to run after every Crowdin pull. Lines inside fenced code blocks are skipped so +to run after merging every translation PR; the build runs it on every non-English +language as a safety net for translator output (build_scripts/translate-content.py +restores alert markers from English, but older translations and hand edits may +still carry the collapsed form). Lines inside fenced code blocks are skipped so documentation that shows alert syntax verbatim is never altered. Usage: @@ -117,7 +121,7 @@ def iter_markdown_files(lang: str | None): def main() -> int: parser = argparse.ArgumentParser( - description="Split Crowdin-collapsed DocFX alerts back into two lines." + description="Split collapsed DocFX alerts in translated content back into two lines." ) parser.add_argument( "lang", nargs="?", diff --git a/build_scripts/normalize-localized-heading-anchors.py b/build_scripts/normalize-localized-heading-anchors.py index 0e247e02d..25aba6e82 100644 --- a/build_scripts/normalize-localized-heading-anchors.py +++ b/build_scripts/normalize-localized-heading-anchors.py @@ -24,12 +24,14 @@ This neutralizes the whole class of warning for current and future pages without touching translations. -Headings are aligned to the English source positionally (Crowdin preserves -heading structure). If the heading count differs (a translation added/removed a -heading, or is stale), the file is skipped and reported rather than risk a -misaligned anchor. Frontmatter and fenced code blocks are skipped. Injected -anchors are tagged `data-loc-xref` so the script is idempotent: it strips its own -prior anchors before recomputing. +Headings are aligned to the English source positionally: the translation script +(build_scripts/translate-content.py) rejects deliveries whose heading count +differs from the English source, so positional alignment is safe for anything it +wrote. If the heading count differs anyway (a hand-edited or stale translation), +the file is skipped and reported rather than risk a misaligned anchor. +Frontmatter and fenced code blocks are skipped. Injected anchors are tagged +`data-loc-xref` so the script is idempotent: it strips its own prior anchors +before recomputing. English (`en`) is never modified - it is the source of the slugs. diff --git a/build_scripts/sync-localized-content.py b/build_scripts/sync-localized-content.py index bdce57c4e..85c8b9523 100644 --- a/build_scripts/sync-localized-content.py +++ b/build_scripts/sync-localized-content.py @@ -1,24 +1,32 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- """ -Sync localized content with translation status tracking. +Sync localized content for local builds. -This script manages the synchronization between source content (English) -and localized content, tracking which translations are current vs outdated. +This script prepares localizedContent/{lang}/ for a build: it copies English +into localizedContent/en/, syncs the shared directories (assets, api) for every +language and, with --sync, copies English over missing/outdated translations so +a local build has a complete site. -Features: -- Tracks source file hashes to detect changes -- Falls back to English for outdated/missing translations -- Provides status reports on translation coverage +Translations themselves are produced by the automated translation workflow +(build_scripts/translate-content.py, .github/workflows/translate.yml), which +also owns localizedContent/{lang}/.translation-status.json. This script only +READS that file (to tell current from outdated translations) and never writes +it from --sync. The English fallback copies written by --sync are for local +builds only and must never be committed. Usage: python sync-localized-content.py --status # Show all languages python sync-localized-content.py --status es # Show Spanish details - python sync-localized-content.py --sync es # Sync Spanish content - python sync-localized-content.py --shared-only es # Sync only shared dirs (assets, api) + python sync-localized-content.py --sync en # Copy English source into localizedContent/en/ + python sync-localized-content.py --sync es # English fallback copies for a local es build (do not commit) + python sync-localized-content.py --shared-only es # Sync only shared dirs (assets, api) - the default build path + python sync-localized-content.py --json # JSON output for CI + +Legacy (kept for hand repairs; the status file is normally maintained by +translate-content.py, use its --baseline instead): python sync-localized-content.py --init es # Initialize tracking python sync-localized-content.py --mark-translated es # Mark all as translated - python sync-localized-content.py --json # JSON output for CI """ import argparse @@ -176,6 +184,10 @@ def check_translation_status(lang: str, source_files: dict[str, str]) -> dict[st def sync_language(lang: str, source_files: dict[str, str], dry_run: bool = False) -> dict[str, int]: """Sync content for a language, falling back to English for outdated/missing. + For local builds only: the English copies are fallbacks and must never be + committed. The translation status file is read, not written (it is owned by + translate-content.py). + Returns dict with counts of actions taken. """ status = check_translation_status(lang, source_files) @@ -196,8 +208,6 @@ def sync_language(lang: str, source_files: dict[str, str], dry_run: bool = False if not dry_run: dest_file.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(source_file, dest_file) - # Update status to untranslated (since we replaced with English) - file_info["status"] = STATUS_UNTRANSLATED counts["replaced"] += 1 print(f" Replaced (outdated): {rel_path}") elif file_status == STATUS_UNTRANSLATED: @@ -226,8 +236,9 @@ def sync_language(lang: str, source_files: dict[str, str], dry_run: bool = False shutil.copytree(src, dest) print(f" Synced shared: {dir_name}/") - if not dry_run: - save_translation_status(lang, status) + if counts["copied"] or counts["replaced"]: + print(f" Note: {STATUS_FILENAME} was not modified (it is maintained by translate-content.py).") + print(" The English fallback copies above are for local builds only - do not commit them.") return counts @@ -281,8 +292,9 @@ def sync_english(dry_run: bool = False) -> dict[str, int]: def sync_shared_only(lang: str, dry_run: bool = False) -> dict[str, int]: """Sync only shared directories (assets, api) for a language. - Used when full translation sync is disabled (Crowdin manages translations). - Skips hash comparison and translation status tracking. + The default build path: translations are committed by the automated + translation workflow (translate-content.py), so only the shared directories + need syncing. Skips hash comparison and translation status tracking. """ localized_content_dir = LOCALIZED_DIR / lang / "content" counts = {"synced_dirs": 0} @@ -304,9 +316,11 @@ def sync_shared_only(lang: str, dry_run: bool = False) -> dict[str, int]: def init_language(lang: str, source_files: dict[str, str]) -> None: - """Initialize translation tracking for an existing language. + """Initialize translation tracking for an existing language (legacy). Marks all existing translations as 'translated' with current source hash. + Prefer `translate-content.py --baseline`, which also verifies each + translation against its source and keeps the pendingJobs/failures sections. """ localized_content_dir = LOCALIZED_DIR / lang / "content" @@ -345,9 +359,11 @@ def init_language(lang: str, source_files: dict[str, str]) -> None: def mark_translated(lang: str, file_paths: list[str] | None, source_files: dict[str, str]) -> None: - """Mark files as translated with current source hash. + """Mark files as translated with current source hash (legacy). - If file_paths is None, marks all files in the language. + If file_paths is None, marks all files in the language. Rewrites the summary + section of the status file; the file is normally maintained by + translate-content.py. """ status = load_translation_status(lang) @@ -462,7 +478,8 @@ def main() -> int: parser.add_argument( "--sync", metavar="LANG", - help="Sync content for a language (use 'en' for English)" + help="Sync content for a language ('en' copies the English source; other languages get " + "English fallback copies for a local build - never commit those)" ) parser.add_argument( "--shared-only", @@ -472,12 +489,12 @@ def main() -> int: parser.add_argument( "--init", metavar="LANG", - help="Initialize tracking for existing translations" + help="Legacy: initialize tracking for existing translations (prefer translate-content.py --baseline)" ) parser.add_argument( "--mark-translated", metavar="LANG", - help="Mark files as translated (all files if no --files specified)" + help="Legacy: mark files as translated (all files if no --files specified)" ) parser.add_argument( "--files", diff --git a/build_scripts/translate-content.py b/build_scripts/translate-content.py new file mode 100644 index 000000000..4a94c6386 --- /dev/null +++ b/build_scripts/translate-content.py @@ -0,0 +1,3750 @@ +#!/usr/bin/env python +""" +Translate documentation content with Translated (TranslationOS API). + +English sources live in content/, translations land at +localizedContent/{lang}/content/{same path} using two-letter folder codes, and +translation PRs arrive on the `localization` branch (see .github/workflows/translate.yml). + +Actions (exactly one per invocation): + + --plan compare English source hashes with localizedContent/{lang}/.translation-status.json + and list what is missing, outdated, in flight, orphaned, pinned or unscoped (offline) + --submit POST /translate for every missing/outdated file (one request per file and + language) and record the job under "pendingJobs" in the status file + --poll POST /status for all pending jobs; when delivered, repair what can be restored + from the English source (code, markers, link targets), verify the rest, write the + file and mark it "translated" at the source hash it was translated from (in the + sandbox, requests that stop at "completed" are pushed through POST /sandbox/delivery) + --run --submit followed by --poll (CI mode) + --baseline one-time migration step: run every existing translation through the same + repair-then-verify path as a delivery, mark the valid ones as current so only future + changes are sent, and mark the rest untranslated (offline; repairs are only written + back with --repair-existing) + --self-test offline proof that extraction, repair, verification and bookkeeping work + --probe GET service types and languages from the API and check the configuration + --dump-orders write the exact /translate request bodies to a directory instead of sending them + --cancel-pending + POST /translate/cancel for every pending request of the active environment and drop + its "pendingJobs" record (translations and failures untouched; production needs --force) + +Scope (which files are translated and how) comes from the "translation" section of +metadata/build-config.json; locales come from "translatedLocale" in +metadata/language-metadata.json. Target languages are the folders under +localizedContent/ that contain a content/ directory (same rule as gen_languages.py). + +Usage (run from the docs repo root): + python build_scripts/translate-content.py --plan [--lang es] [--file "features/*.md"] + python build_scripts/translate-content.py --submit --lang es --limit 3 [--force] + python build_scripts/translate-content.py --poll [--wait 20] [--report report.md] + python build_scripts/translate-content.py --run --wait 25 --report report.md + python build_scripts/translate-content.py --baseline [--baseline-ref d376766] [--overwrite-baseline] [--repair-existing] + python build_scripts/translate-content.py --self-test + python build_scripts/translate-content.py --probe + python build_scripts/translate-content.py --dump-orders /tmp/orders + python build_scripts/translate-content.py --cancel-pending [--lang es] [--force] + +Environment: + TRANSLATED_ENV sandbox | production: selects translation.environments[...] in + build-config.json; required for --submit/--poll/--run/--probe/--cancel-pending + TRANSLATED_API_KEY required for --submit/--poll/--run/--probe/--cancel-pending + TRANSLATED_SERVICE_TYPE overrides the environment's serviceType (production has none until set) + +Exit code is 1 for configuration or API errors and when any /translate batch failed. +Individual files that fail verification do not fail the run; they are recorded under +"failures" in the status file (at the source hash they failed for, so they are not +resubmitted until the English changes or --retry-failed is given) and listed in the report. +""" + +from __future__ import annotations + +import argparse +import difflib +import email.message +import fnmatch +import hashlib +import http.client +import io +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.request +import uuid +from collections import Counter +from collections.abc import Callable, Iterator +from contextlib import contextmanager, redirect_stdout, suppress +from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any, Protocol + +from config_loader import compute_file_hash, get_default_language, load_build_config + +CONTENT_DIR = Path("content") +LOCALIZED_DIR = Path("localizedContent") +STATUS_FILENAME = ".translation-status.json" +LANGUAGE_METADATA_PATH = Path("metadata/language-metadata.json") + +STATUS_TRANSLATED = "translated" +STATUS_UNTRANSLATED = "untranslated" +STATUS_OUTDATED = "outdated" +STATUS_COPIED = "copied" + +# TranslationOS request_status enum: preprocessing, upload, machine translation, wc raw, +# ingested, bucketing, analyzing, quote, in progress, completed, delivered, invoiced, +# failed, failed delivery, cancelled. Content is trusted at delivered/invoiced only; +# "completed" precedes delivery and is a waiting state. Delivery is a project manager's step in +# production; the sandbox has no such step, so sandbox polls trigger it via POST /sandbox/delivery. +DELIVERED_STATUSES = frozenset({"delivered", "invoiced"}) +FAILED_STATUSES = frozenset({"failed", "failed delivery", "cancelled", "canceled", "error", "rejected"}) +WAITING_STATUSES = frozenset( + { + "preprocessing", + "upload", + "machine translation", + "wc raw", + "ingested", + "bucketing", + "analyzing", + "quote", + "in progress", + "completed", + } +) + +# A job not delivered within this window is treated as lost (same budget as Housekeeping). +PENDING_TIMEOUT = timedelta(days=7) +TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%SZ" + +POLL_DELAYS = (30, 60, 120) # seconds between rounds: 30 s, then 60 s, then 120 s +RUN_INITIAL_DELAY = 20 # seconds to wait before the first poll round in --run mode +STATUS_CHUNK = 200 # id_request values per POST /status call +SANDBOX_DELIVERY_CHUNK = 200 # id_request values per POST /sandbox/delivery call +CANCEL_CHUNK = 200 # id values per POST /translate/cancel call +RETRYABLE_HTTP = frozenset({425, 429, 500, 502, 503, 504}) +REPORT_LIST_CAP = 50 +IDENTITY_MIN_LETTERS = 200 +REPAIR_KINDS = ("fences", "inline code", "markers", "links") # order used in "repaired: ..." lines +WARNING_KINDS = frozenset({"inline code"}) # count mismatches of these kinds are warnings, not problems + +CONTENT_TYPES = { + ".md": "text/markdown", + ".html": "text/html", + ".htm": "text/html", + ".json": "application/json", + ".yml": "application/json", # toc.yml is sent as a JSON map of its `name` values, see extract_yaml_names + ".yaml": "application/json", +} + +BOM = "" + +# Markdown structure regexes (mirror normalize-localized-heading-anchors.py). +FENCE_RE = re.compile(r"^\s*(`{3,}|~{3,})") +HEADING_RE = re.compile(r"^#{1,6}\s+\S") +# Any `[!word` token: alert markers ([!NOTE]), includes ([!include[...]) and code refs ([!code-yaml[...]). +# Deliberately broad so a translated marker ([!NOTA]) is still counted and restored positionally. +ALERT_RE = re.compile(r"\[![A-Za-z][A-Za-z0-9-]*") +LINK_TARGET_RE = re.compile(r"\]\(([^)\s]+)") +INLINE_CODE_RE = re.compile(r"(`+)(?!`)[^`\n]+?\1(?!`)") +FRONTMATTER_RE = re.compile(r"\A()?---\r?\n(.*?)\r?\n---[ \t]*\r?\n", re.S) +FM_TRANSLATABLE_RE = re.compile(r"^(title|description):[ \t]*(.*?)[ \t]*$") +YAML_NAME_RE = re.compile(r"^(\s*-?\s*name:[ \t]*)(.*?)[ \t]*$") +YAML_NAME_KEY_RE = re.compile(r"n\d+") +YAML_SPECIAL_RE = re.compile(r"[:#\[\]{},&*!|>'\"%@`]") +YAML_INDICATOR_START = tuple("-?*&!%@`|>'\"[{:,]}#") +YAML_SCALARS = frozenset({"true", "false", "yes", "no", "null", "on", "off", "~"}) +HTML_TAG_RE = re.compile(r"<\s*([a-zA-Z][a-zA-Z0-9-]*)") +HTML_REF_RE = re.compile(r"""\b(?:href|src)\s*=\s*["']([^"']+)["']""") +PLACEHOLDER_RE = re.compile(r"(? str: + return datetime.now(UTC).strftime(TIMESTAMP_FORMAT) + + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + + +def normalize_base_url(url: str) -> str: + """Accept https://host, https://host/v2 and https://host/v2/; always return .../v2/.""" + url = url.strip().rstrip("/") + if not url.endswith("/v2"): + url += "/v2" + return url + "/" + + +def _patterns(items: Any) -> list[str]: + return [s["pattern"] if isinstance(s, dict) else str(s) for s in (items or [])] + + +class TranslationConfig: + def __init__( + self, + build_config: dict[str, Any], + environment: str | None = None, + service_type_override: str | None = None, + ) -> None: + section = build_config.get("translation") + if not section: + raise SystemExit("metadata/build-config.json has no 'translation' section.") + self.environments: dict[str, dict[str, Any]] = section.get("environments") or {} + if not self.environments: + raise SystemExit("translation.environments in metadata/build-config.json is empty.") + self.environment: str | None = (environment or "").strip() or None + env_def = self.environments.get(self.environment) if self.environment else None + self.base_url: str | None = normalize_base_url(str(env_def["baseUrl"])) if env_def else None + override = (service_type_override or "").strip() + env_service = (env_def or {}).get("serviceType") + self.service_type: str | None = override or (str(env_service) if env_service else None) + self.source_locale: str = section.get("sourceLocale", "en-US") + self.sources: list[str] = _patterns(section.get("sources")) + self.passthrough: list[str] = _patterns(section.get("passthrough")) + self.ignore: list[str] = _patterns(section.get("ignore")) + self.instructions: str = section.get("instructions", "") + self.batch_size: int = max(1, int(section.get("batchSize", 200))) + limits = section.get("sandboxLimits") or {} + self.max_files_per_run: int = int(limits.get("maxFilesPerRun", 20)) + self.max_chars_per_run: int = int(limits.get("maxCharsPerRun", 200000)) + self.shared_directories: list[str] = list(build_config.get("sharedDirectories", {}).get("directories", [])) + if not self.sources: + raise SystemExit("translation.sources in metadata/build-config.json is empty.") + + @property + def is_sandbox(self) -> bool: + return self.environment == "sandbox" + + @property + def environment_label(self) -> str: + return self.environment or "unset" + + def require_network(self, action: str, need_service_type: bool = True) -> None: + """Refuse any API call until the environment is unambiguous.""" + known = ", ".join(sorted(self.environments)) + if not self.environment: + raise SystemExit(f"{action} needs TRANSLATED_ENV set to one of: {known}.") + if self.environment not in self.environments: + raise SystemExit( + f"TRANSLATED_ENV='{self.environment}' is not defined in translation.environments ({known})." + ) + if need_service_type and not self.service_type: + raise SystemExit( + f"No service type is configured for environment '{self.environment}'. Run --probe to list the " + "account's service types, then set TRANSLATED_SERVICE_TYPE (or translation.environments." + f"{self.environment}.serviceType in metadata/build-config.json)." + ) + + +def load_locale_map(path: Path = LANGUAGE_METADATA_PATH) -> dict[str, str]: + """Map two-letter folder code -> Translated locale (RFC 3066, e.g. es -> es-ES).""" + with open(path, encoding="utf-8") as f: + meta = json.load(f) + languages = meta.get("languages", meta) + return { + code: str(entry["translatedLocale"]) + for code, entry in languages.items() + if isinstance(entry, dict) and entry.get("translatedLocale") + } + + +def require_locales(langs: list[str], locales: dict[str, str]) -> None: + missing = [lang for lang in langs if not locales.get(lang)] + if missing: + raise SystemExit( + f"No translatedLocale for {', '.join(missing)} in {LANGUAGE_METADATA_PATH}; " + 'add e.g. "translatedLocale": "xx-XX" to each language entry.' + ) + + +def get_target_languages(default_lang: str) -> list[str]: + if not LOCALIZED_DIR.exists(): + return [] + return sorted( + p.name for p in LOCALIZED_DIR.iterdir() if p.is_dir() and p.name != default_lang and (p / "content").is_dir() + ) + + +# --------------------------------------------------------------------------- +# Source discovery +# --------------------------------------------------------------------------- + + +def rel_str(path: Path, base: Path) -> str: + return str(path.relative_to(base)).replace("\\", "/") + + +def _glob_content(patterns: list[str], shared: set[str], ignore: list[str]) -> dict[str, str]: + files: dict[str, str] = {} + for pattern in patterns: + for path in sorted(CONTENT_DIR.glob(pattern)): + if not path.is_file(): + continue + rel = rel_str(path, CONTENT_DIR) + if rel.split("/", 1)[0] in shared or any(fnmatch.fnmatch(rel, ig) for ig in ignore): + continue + files[rel] = compute_file_hash(path) + return files + + +def get_scoped_sources(config: TranslationConfig) -> dict[str, str]: + """Return {relative path -> sha256} for every English file in translation scope.""" + return _glob_content(config.sources, set(config.shared_directories), config.ignore) + + +def get_passthrough_sources(config: TranslationConfig, sources: dict[str, str]) -> dict[str, str]: + """Files copied verbatim from English (e.g. whats-new/*.html); never sent for translation.""" + found = _glob_content(config.passthrough, set(config.shared_directories), config.ignore) + return {rel: h for rel, h in found.items() if rel not in sources} + + +def hash_bytes(data: bytes) -> str: + """Same normalization as config_loader.compute_file_hash (CRLF -> LF).""" + return "sha256:" + hashlib.sha256(data.replace(b"\r\n", b"\n")).hexdigest() + + +GitRunner = Callable[[list[str]], subprocess.CompletedProcess[bytes]] + + +def run_git(cmd: list[str]) -> subprocess.CompletedProcess[bytes]: + return subprocess.run(cmd, capture_output=True) + + +def resolve_git_ref(ref: str, run: GitRunner = run_git) -> str: + """Return the commit sha `ref` names, or raise SystemExit when this clone does not have it.""" + proc = run(["git", "rev-parse", "--verify", "--quiet", "--end-of-options", f"{ref}^{{commit}}"]) + if proc.returncode != 0: + raise SystemExit(f"--baseline-ref {ref} is not a commit in this clone (fetch it or check the spelling)") + return proc.stdout.decode("utf-8", errors="replace").strip() + + +def hashes_at_git_ref(ref: str, rel_paths: list[str], run: GitRunner = run_git) -> dict[str, str]: + """Hash content/{rel} as it was at a git ref. Missing files are omitted.""" + result: dict[str, str] = {} + for rel in rel_paths: + proc = run(["git", "show", f"{ref}:{CONTENT_DIR.as_posix()}/{rel}"]) + if proc.returncode == 0: + result[rel] = hash_bytes(proc.stdout) + return result + + +def matches_filters(rel: str, file_filters: list[str]) -> bool: + return not file_filters or any(fnmatch.fnmatch(rel, pat) for pat in file_filters) + + +# --------------------------------------------------------------------------- +# Status file (read by sync-localized-content.py; written only here) +# --------------------------------------------------------------------------- + + +def status_path(lang: str) -> Path: + return LOCALIZED_DIR / lang / STATUS_FILENAME + + +def load_status(lang: str) -> dict[str, Any]: + path = status_path(lang) + if path.exists(): + with open(path, encoding="utf-8") as f: + status: dict[str, Any] = json.load(f) + else: + status = {"language": lang, "sourceBaseline": str(CONTENT_DIR), "files": {}} + status.setdefault("files", {}) + status.setdefault("pendingJobs", {}) + status.setdefault("failures", {}) + return status + + +def summarize_status(status: dict[str, Any]) -> dict[str, Any]: + files = status["files"] + counts = Counter(str(f.get("status")) for f in files.values()) + total = len(files) + done = counts[STATUS_TRANSLATED] + counts[STATUS_COPIED] + return { + "translated": counts[STATUS_TRANSLATED], + "outdated": counts[STATUS_OUTDATED], + "untranslated": counts[STATUS_UNTRANSLATED], + "copied": counts[STATUS_COPIED], + "pinned": sum(1 for f in files.values() if f.get("manual") is True), + "total": total, + "completionPercent": round(done / total * 100, 1) if total else 0, + "pendingJobs": len(status["pendingJobs"]), + "failures": len(status["failures"]), + } + + +def save_status(lang: str, status: dict[str, Any]) -> None: + # Case-insensitive so Windows and Linux runs order the file identically (stable diffs). + status["files"] = dict(sorted(status["files"].items(), key=lambda kv: kv[0].lower())) + status["summary"] = summarize_status(status) + # Keep the optional sections out of the file when empty so diffs stay small. + for key in ("pendingJobs", "failures"): + if not status[key]: + status.pop(key) + path = status_path(lang) + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + json.dump(status, f, indent=2, ensure_ascii=False) + f.write("\n") + status.setdefault("pendingJobs", {}) + status.setdefault("failures", {}) + + +def record_failure(status: dict[str, Any], rel: str, error: str, source_hash: str, **extra: Any) -> None: + entry: dict[str, Any] = {"error": error, "at": utc_now(), "sourceHash": source_hash} + entry.update({k: v for k, v in extra.items() if v is not None}) + status["failures"][rel] = entry + + +# --------------------------------------------------------------------------- +# Content extraction / repair / verification +# --------------------------------------------------------------------------- + + +def read_text(path: Path) -> str: + with open(path, encoding="utf-8", newline="") as f: + return f.read() + + +def write_text(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "w", encoding="utf-8", newline="") as f: + f.write(text) + + +def to_lf(text: str) -> str: + return text.replace("\r\n", "\n") + + +def yaml_scalar(value: str, source_value: str | None = None) -> str: + """Return `value` as a YAML plain or quoted scalar. A value identical to the source's is + kept verbatim (the source already parses); anything YAML could misread is JSON-quoted.""" + value = value.strip() + if source_value is not None and value == source_value.strip(): + return source_value.strip() + if value.startswith('"') and value.endswith('"') and len(value) >= 2: + try: + json.loads(value) + except json.JSONDecodeError: + return json.dumps(value, ensure_ascii=False) + return value + if re.fullmatch(r"'(?:[^']|'')*'", value): + return value + needs_quotes = ( + not value + or value.lower() in YAML_SCALARS + or value.startswith(YAML_INDICATOR_START) + or ": " in value + or " #" in value + or value.endswith(":") + or YAML_SPECIAL_RE.search(value) is not None + ) + return json.dumps(value, ensure_ascii=False) if needs_quotes else value + + +def yaml_name_map(text: str) -> dict[str, str]: + names: dict[str, str] = {} + for i, line in enumerate(to_lf(text.lstrip(BOM)).split("\n")): + m = YAML_NAME_RE.match(line) + if m and m.group(2): + names[f"n{i}"] = m.group(2) + return names + + +def extract_yaml_names(text: str) -> tuple[str, int]: + """toc.yml: only `name:` values are translatable. Send them as a JSON object so + hrefs, homepage paths and structure never reach the translator.""" + names = yaml_name_map(text) + return json.dumps(names, ensure_ascii=False, indent=2), len(names) + + +def reinsert_yaml_names(source_text: str, translated_json: str) -> tuple[str, list[str]]: + """Rebuild toc.yml from the English source with translated `name:` values. + Returns (text, problems); on problems the text is the source.""" + try: + names = json.loads(translated_json.lstrip(BOM)) + except json.JSONDecodeError as e: + return source_text, [f"translated toc payload does not parse: {e}"] + if not isinstance(names, dict): + return source_text, ["translated toc payload is not a JSON object"] + expected = yaml_name_map(source_text) + problems: list[str] = [] + bad_keys = sorted(k for k in names if not YAML_NAME_KEY_RE.fullmatch(str(k))) + if bad_keys: + problems.append(f"toc payload has malformed keys {bad_keys[:5]}") + missing = sorted(set(expected) - set(names), key=lambda k: int(k[1:])) + extra = sorted(set(names) - set(expected) - set(bad_keys), key=lambda k: int(k[1:])) + if missing: + problems.append(f"toc payload is missing {len(missing)} name(s): {missing[:5]}") + if extra: + problems.append(f"toc payload has {len(extra)} unexpected key(s): {extra[:5]}") + newlines = sorted(k for k, v in names.items() if "\n" in str(v) or "\r" in str(v)) + if newlines: + problems.append(f"toc names contain line breaks: {newlines[:5]}") + if problems: + return source_text, problems + bom = BOM if source_text.startswith(BOM) else "" + crlf = "\r\n" in source_text + lines = to_lf(source_text.lstrip(BOM)).split("\n") + for key, value in names.items(): + idx = int(key[1:]) + m = YAML_NAME_RE.match(lines[idx]) + if m is None: + return source_text, [f"toc line {idx} no longer holds a name entry"] + lines[idx] = f"{m.group(1)}{yaml_scalar(str(value), expected[key])}" + text = "\n".join(lines) + return bom + (text.replace("\n", "\r\n") if crlf else text), [] + + +def encode_placeholders(text: str) -> str: + """{name} -> {{name}}: Translated protects Twig-style placeholders, not single braces.""" + return PLACEHOLDER_RE.sub(r"{{\1}}", text) + + +def decode_placeholders(text: str) -> str: + return ENCODED_PLACEHOLDER_RE.sub(r"{\1}", text) + + +def prepare_payload(rel: str, source_text: str) -> tuple[str, str, dict[str, Any]]: + """Return (content, content_type, extra job info) for a source file.""" + suffix = Path(rel).suffix.lower() + content_type = CONTENT_TYPES.get(suffix) + if content_type is None: + raise ValueError(f"no content type for {rel}") + if suffix in (".yml", ".yaml"): + content, count = extract_yaml_names(source_text) + return content, content_type, {"mode": "yaml-names", "units": count} + # Strip a BOM and send LF line endings so Windows and Linux checkouts produce the same payload; + # both are restored from the source when the delivery is written. + content = to_lf(source_text.lstrip(BOM)) + if suffix == ".json": + return encode_placeholders(content), content_type, {"mode": "json-placeholders"} + return content, content_type, {"mode": "raw"} + + +def split_frontmatter(text: str) -> tuple[str, str]: + m = FRONTMATTER_RE.match(text) + if not m: + return "", text + return text[: m.end()], text[m.end() :] + + +def frontmatter_values(fm: str) -> dict[str, str]: + values: dict[str, str] = {} + for line in fm.splitlines(): + m = FM_TRANSLATABLE_RE.match(line) + if m and m.group(2): + values[m.group(1)] = m.group(2) + return values + + +def merge_frontmatter(source_fm: str, translated_fm: str) -> tuple[str, list[str]]: + """Rebuild the frontmatter from the English source, taking only translated + `title`/`description` values. Keeps uid, author, dates and applies_to intact + regardless of what the translator did to them. Returns (frontmatter, problems).""" + if not source_fm: + return "", [] + problems: list[str] = [] + if not translated_fm: + problems.append("frontmatter missing or malformed in the delivery") + translated_values = frontmatter_values(translated_fm) + out: list[str] = [] + for line in source_fm.splitlines(keepends=True): + bare = line.rstrip("\r\n") + eol = line[len(bare) :] + m = FM_TRANSLATABLE_RE.match(bare) + if m and m.group(2): + key = m.group(1) + if key in translated_values: + out.append(f"{key}: {yaml_scalar(translated_values[key], m.group(2))}{eol}") + continue + if translated_fm: + problems.append(f"frontmatter '{key}' not found in the delivery") + out.append(line) + return "".join(out), problems + + +def split_fences(body: str) -> list[tuple[str, bool]]: + """Split a Markdown body into alternating (text, False) / (fenced block, True) segments. + A fenced block spans its opening fence line through its closing fence line; an unclosed + fence runs to the end. The list always starts and ends with a text segment.""" + segments: list[tuple[str, bool]] = [] + text: list[str] = [] + block: list[str] = [] + in_fence = False + for line in body.splitlines(keepends=True): + if FENCE_RE.match(line): + if in_fence: + block.append(line) + segments.append(("".join(block), True)) + block = [] + else: + segments.append(("".join(text), False)) + text = [] + block.append(line) + in_fence = not in_fence + elif in_fence: + block.append(line) + else: + text.append(line) + if block: + segments.append(("".join(block), True)) + segments.append(("".join(text), False)) + return segments + + +def count_fence_lines(body: str) -> int: + return sum(1 for line in body.splitlines() if FENCE_RE.match(line)) + + +def restore_positionally(text: str, source_text: str, regex: re.Pattern[str], group: int) -> tuple[str, int] | None: + """Replace every match of `regex` (its `group`) in `text` with the source's value at the + same position. Returns (text, changed) when the counts match, None otherwise.""" + src_values = [m.group(group) for m in regex.finditer(source_text)] + matches = list(regex.finditer(text)) + if len(src_values) != len(matches): + return None + out: list[str] = [] + pos = 0 + changed = 0 + for m, value in zip(matches, src_values, strict=True): + start, end = m.span(group) + if m.group(group) != value: + changed += 1 + out.append(text[pos:start]) + out.append(value) + pos = end + out.append(text[pos:]) + return "".join(out), changed + + +def count_diff(source_text: str, text: str, regex: re.Pattern[str], group: int, what: str) -> str: + s = Counter(m.group(group) for m in regex.finditer(source_text)) + t = Counter(m.group(group) for m in regex.finditer(text)) + missing = list((s - t).elements())[:5] + added = list((t - s).elements())[:5] + return f"{what} {sum(s.values())} -> {sum(t.values())} (missing {missing}, added {added})" + + +def letters(text: str) -> int: + return sum(1 for c in text if c.isalpha()) + + +def resplit_by_lines(joined: str, original_segments: list[str]) -> list[str]: + """Split `joined` back into pieces with the same line counts as `original_segments`. + Positional restoration never adds or removes newlines (every span regex excludes them), + so line counts identify the original prose/fence boundaries.""" + pieces: list[str] = [] + pos = 0 + for i, seg in enumerate(original_segments): + if i == len(original_segments) - 1: + pieces.append(joined[pos:]) + break + end = pos + for _ in range(seg.count("\n")): + end = joined.index("\n", end) + 1 + pieces.append(joined[pos:end]) + pos = end + return pieces + + +@dataclass +class RepairResult: + text: str + problems: list[str] = field(default_factory=list) + repaired: Counter[str] = field(default_factory=Counter) + warnings: list[str] = field(default_factory=list) + + +MD_RESTORE_RULES: tuple[tuple[re.Pattern[str], int, str, str], ...] = ( + (INLINE_CODE_RE, 0, "inline code", "inline code spans"), + (ALERT_RE, 0, "markers", "alert/include markers"), + (LINK_TARGET_RE, 1, "links", "link targets"), +) + + +def repair_markdown_body(source_body: str, translated_body: str, repair: bool = True) -> RepairResult: + """Repair-then-verify for a Markdown body (LF line endings, no frontmatter). + Fenced blocks, inline code, alert/include markers and link targets are restored from + the source positionally when their counts match; count mismatches and heading count + differences are problems. With repair=False, differences become problems (verify only). + Exception: an inline-code count mismatch is only a warning (MT routinely adds or drops a + backtick pair) and the delivered spans are kept as they are.""" + result = RepairResult(translated_body) + src_fences, tr_fences = count_fence_lines(source_body), count_fence_lines(translated_body) + if src_fences != tr_fences: + result.problems.append(f"code fences {src_fences} -> {tr_fences}") + return result + src_segments = split_fences(source_body) + tr_segments = split_fences(translated_body) + fixed_blocks = sum(1 for s, t in zip(src_segments, tr_segments, strict=True) if s[1] and s[0] != t[0]) + if fixed_blocks and repair: + result.repaired["fences"] += fixed_blocks + elif fixed_blocks: + result.problems.append(f"fenced code differs in {fixed_blocks} block(s)") + src_text = "".join(seg for seg, is_fence in src_segments if not is_fence) + tr_text_segments = [seg for seg, is_fence in tr_segments if not is_fence] + tr_text = "".join(tr_text_segments) + + for regex, group, kind, what in MD_RESTORE_RULES: + restored = restore_positionally(tr_text, src_text, regex, group) + if restored is None: + findings = result.warnings if kind in WARNING_KINDS else result.problems + findings.append(count_diff(src_text, tr_text, regex, group, what)) + elif restored[1] and repair: + result.repaired[kind] += restored[1] + tr_text = restored[0] + elif restored[1]: + result.problems.append(f"{what} differ in {restored[1]} place(s)") + + src_headings = sum(1 for line in src_text.splitlines() if HEADING_RE.match(line)) + tr_headings = sum(1 for line in tr_text.splitlines() if HEADING_RE.match(line)) + if src_headings != tr_headings: + result.problems.append(f"headings {src_headings} -> {tr_headings}") + if result.problems or not repair: + return result + prose = iter(resplit_by_lines(tr_text, tr_text_segments)) + result.text = "".join(src_seg if is_fence else next(prose) for src_seg, is_fence in src_segments) + return result + + +def markdown_prose(body: str) -> str: + return "".join(seg for seg, is_fence in split_fences(body) if not is_fence) + + +def verify_html_tags(source: str, translated: str) -> list[str]: + s_tags = Counter(x.lower() for x in HTML_TAG_RE.findall(source)) + t_tags = Counter(x.lower() for x in HTML_TAG_RE.findall(translated)) + if s_tags != t_tags: + return [f"tag counts differ {dict(s_tags - t_tags) or dict(t_tags - s_tags)}"] + return [] + + +def repair_html(source: str, translated: str, repair: bool = True) -> RepairResult: + result = RepairResult(translated) + result.problems.extend(verify_html_tags(source, translated)) + restored = restore_positionally(translated, source, HTML_REF_RE, 1) + if restored is None: + result.problems.append(count_diff(source, translated, HTML_REF_RE, 1, "href/src attributes")) + elif restored[1] and repair: + result.repaired["links"] += restored[1] + result.text = restored[0] + elif restored[1]: + result.problems.append(f"href/src attributes differ in {restored[1]} place(s)") + return result + + +def json_indent(text: str) -> int: + m = re.search(r'^( +)"', text, re.M) + return len(m.group(1)) if m else 2 + + +def verify_json_strings(source: str, translated: str) -> tuple[str, list[str]]: + """Decode placeholders, check keys and placeholders, re-serialise like the source. + Returns (text, problems); text is empty when there are problems.""" + try: + s_obj = json.loads(source.lstrip(BOM)) + t_obj = json.loads(decode_placeholders(translated.lstrip(BOM))) + except json.JSONDecodeError as e: + return "", [f"translated JSON does not parse: {e}"] + if not isinstance(s_obj, dict) or not isinstance(t_obj, dict): + return "", ["JSON root is not an object"] + if set(s_obj) != set(t_obj): + missing = sorted(set(s_obj) - set(t_obj))[:5] + added = sorted(set(t_obj) - set(s_obj))[:5] + return "", [f"keys differ: missing {missing} added {added}"] + problems = [ + f"placeholders changed in '{key}'" + for key, value in s_obj.items() + if isinstance(value, str) + and Counter(PLACEHOLDER_RE.findall(value)) != Counter(PLACEHOLDER_RE.findall(str(t_obj[key]))) + ] + if problems: + return "", problems + text = json.dumps(t_obj, indent=json_indent(source), ensure_ascii=False) + if to_lf(source).endswith("\n"): + text += "\n" + return text, [] + + +def match_trailing_newline(text: str, source: str) -> str: + if source.endswith("\n") and not text.endswith("\n"): + return text + "\n" + if not source.endswith("\n") and text.endswith("\n"): + return text.rstrip("\n") + return text + + +def finalize_translation( + rel: str, + job: dict[str, Any], + source_text: str, + translated: str, + repair: bool = True, + check_identity: bool = True, +) -> tuple[str, list[str], Counter[str], list[str]]: + """Turn delivered content into the file to write. + Returns (text, problems, repaired counts by kind, warnings). The text is only written when + problems is empty (or --lenient is given); warnings never block writing.""" + suffix = Path(rel).suffix.lower() + if job.get("mode") == "yaml-names": + text, toc_problems = reinsert_yaml_names(source_text, translated) + return text, toc_problems, Counter(), [] + + bom = BOM if source_text.startswith(BOM) else "" + crlf = "\r\n" in source_text + source_lf = to_lf(source_text.lstrip(BOM)) + delivered = to_lf(translated.lstrip(BOM)) + repaired: Counter[str] = Counter() + problems: list[str] = [] + warnings: list[str] = [] + + if suffix == ".md": + src_fm, src_body = split_frontmatter(source_lf) + if src_fm: + delivered = delivered.lstrip() + tr_fm, tr_body = split_frontmatter(delivered) + fm, fm_problems = merge_frontmatter(src_fm, tr_fm) + problems.extend(fm_problems) + result = repair_markdown_body(src_body, tr_body, repair) + problems.extend(result.problems) + repaired.update(result.repaired) + warnings.extend(result.warnings) + body = match_trailing_newline(result.text, src_body) + if ( + check_identity + and not problems + and letters(markdown_prose(body)) > IDENTITY_MIN_LETTERS + and markdown_prose(body) == markdown_prose(src_body) + ): + problems.append("delivered content identical to source") + text = fm + body + elif suffix in (".html", ".htm"): + result = repair_html(source_lf, delivered, repair) + problems.extend(result.problems) + repaired.update(result.repaired) + warnings.extend(result.warnings) + text = match_trailing_newline(result.text, source_lf) + if check_identity and not problems and letters(text) > IDENTITY_MIN_LETTERS and text == source_lf: + problems.append("delivered content identical to source") + elif suffix == ".json": + text, json_problems = verify_json_strings(source_lf, delivered) + problems.extend(json_problems) + else: + text = delivered + if crlf: + text = text.replace("\n", "\r\n") + return bom + text, problems, repaired, warnings + + +# --------------------------------------------------------------------------- +# Translated API client +# --------------------------------------------------------------------------- + + +class TranslatedApiError(Exception): + """A non-retryable API failure or exhausted retries. Carries the parsed api_error.""" + + def __init__(self, message: str, http_status: int | None = None, api_error: dict[str, Any] | None = None) -> None: + super().__init__(message) + self.http_status = http_status + self.api_error = api_error or {} + + def details(self) -> dict[str, Any]: + out: dict[str, Any] = {"message": str(self)} + if self.http_status is not None: + out["httpStatus"] = self.http_status + for key in ("code", "id_transaction"): + if self.api_error.get(key): + out[key] = self.api_error[key] + params = self.api_error.get("params") + if isinstance(params, list): + out["params"] = [ + {"field": p.get("field"), "reason": p.get("reason")} for p in params if isinstance(p, dict) + ] + return out + + +def describe_api_error(payload: str) -> tuple[str, dict[str, Any]]: + """Build a readable message from an api_error body when it parses, else from the raw text.""" + try: + parsed = json.loads(payload) + except json.JSONDecodeError: + return payload[:500], {} + if not isinstance(parsed, dict): + return payload[:500], {} + parts = [str(parsed.get(k)) for k in ("code", "message") if parsed.get(k)] + for p in parsed.get("params") or []: + if isinstance(p, dict): + parts.append(f"{p.get('field')}: {p.get('reason')}") + if parsed.get("id_transaction"): + parts.append(f"transaction {parsed['id_transaction']}") + return "; ".join(parts) or payload[:500], parsed + + +class TranslationClient(Protocol): + def translate(self, orders: list[dict[str, Any]]) -> list[dict[str, Any]]: ... + + def status_many(self, id_requests: list[int]) -> list[dict[str, Any]]: ... + + def cancel(self, id_requests: list[int]) -> dict[str, Any]: ... + + def sandbox_deliver(self, id_requests: list[int]) -> Any: ... + + def service_type_names(self) -> list[dict[str, Any]]: ... + + def languages(self) -> list[dict[str, Any]]: ... + + +class TranslatedClient: + def __init__( + self, + base_url: str, + api_key: str, + sleep: Callable[[float], None] = time.sleep, + opener: Callable[..., Any] = urllib.request.urlopen, + ) -> None: + self.base_url = base_url + self.api_key = api_key + self.sleep = sleep + self.opener = opener # urlopen-compatible; injectable so the self-test can fake HTTP answers offline + + def _request( + self, + method: str, + endpoint: str, + body: Any = None, + extra_headers: dict[str, str] | None = None, + retries: int = 3, + ) -> Any: + data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None + url = self.base_url + endpoint + headers = {"x-api-key": self.api_key, "Accept": "application/json"} + if data is not None: + headers["Content-Type"] = "application/json" + headers.update(extra_headers or {}) + last_error = "retries exhausted" + for attempt in range(retries + 1): + req = urllib.request.Request(url, data=data, method=method, headers=headers) + try: + with self.opener(req, timeout=180) as resp: + return json.loads(resp.read().decode("utf-8") or "null") + except urllib.error.HTTPError as e: + payload = e.read().decode("utf-8", errors="replace") + message, api_error = describe_api_error(payload) + last_error = f"{e.code}: {message}" + if e.code not in RETRYABLE_HTTP: + raise TranslatedApiError( + f"{method} {endpoint} failed with {last_error}", e.code, api_error + ) from None + except (urllib.error.URLError, OSError, http.client.HTTPException, json.JSONDecodeError) as e: + last_error = f"{type(e).__name__}: {e}" + if attempt < retries: + self.sleep(2**attempt * 2) + raise TranslatedApiError(f"{method} {endpoint} failed after {retries + 1} attempts ({last_error})") + + def _post(self, endpoint: str, body: Any, extra_headers: dict[str, str] | None = None, retries: int = 3) -> Any: + return self._request("POST", endpoint, body, extra_headers, retries) + + def _get(self, endpoint: str) -> Any: + return self._request("GET", endpoint) + + def translate(self, orders: list[dict[str, Any]]) -> list[dict[str, Any]]: + # One idempotency id per batch, reused on every retry so a timeout after server-side + # creation cannot produce a second (billed) set of requests. + items = self._post("translate", orders, {"x-idempotency-id": str(uuid.uuid4())}) + if not isinstance(items, list): + raise TranslatedApiError(f"POST translate returned an unexpected body: {str(items)[:300]}") + return [i for i in items if isinstance(i, dict)] + + def status_many(self, id_requests: list[int]) -> list[dict[str, Any]]: + # POST, not GET: the status endpoint rejects GET with 405. + result: list[dict[str, Any]] = [] + for start in range(0, len(id_requests), STATUS_CHUNK): + chunk = id_requests[start : start + STATUS_CHUNK] + items = self._post("status", {"id_request": chunk, "fetch_content": True, "limit": 1000}) + if isinstance(items, list): + result.extend(i for i in items if isinstance(i, dict)) + return result + + def cancel(self, id_requests: list[int]) -> dict[str, Any]: + result = self._post("translate/cancel", {"id": id_requests}) + return result if isinstance(result, dict) else {} + + def sandbox_deliver(self, id_requests: list[int]) -> Any: + """Sandbox only: deliver requests that stopped at "completed" (in production a project manager does + this). Not retried: the sandbox answers 500 for requests it cannot deliver yet, and the next poll round + asks again anyway. Returns the concatenated delivery_output items of all chunks.""" + items: list[Any] = [] + for start in range(0, len(id_requests), SANDBOX_DELIVERY_CHUNK): + chunk = id_requests[start : start + SANDBOX_DELIVERY_CHUNK] + result = self._post("sandbox/delivery", {"ids_requests": chunk}, retries=0) + items.extend(result if isinstance(result, list) else [result]) + return items + + def service_type_names(self) -> list[dict[str, Any]]: + items = self._get("symbol/service-type-names") + return [i for i in items if isinstance(i, dict)] if isinstance(items, list) else [] + + def languages(self) -> list[dict[str, Any]]: + items = self._get("symbol/languages") + return [i for i in items if isinstance(i, dict)] if isinstance(items, list) else [] + + +def require_client(config: TranslationConfig, action: str, need_service_type: bool = True) -> TranslatedClient: + config.require_network(action, need_service_type) + api_key = os.environ.get("TRANSLATED_API_KEY", "").strip() + if not api_key: + raise SystemExit("TRANSLATED_API_KEY is not set.") + assert config.base_url is not None + return TranslatedClient(config.base_url, api_key) + + +# --------------------------------------------------------------------------- +# Planning +# --------------------------------------------------------------------------- + + +@dataclass +class PlanOptions: + file_filters: list[str] = field(default_factory=list) + limit: int = 0 + force: bool = False + retry_failed: bool = False + + @property + def filtered(self) -> bool: + return bool(self.file_filters) or self.limit > 0 + + +@dataclass +class LanguagePlan: + lang: str + to_submit: list[tuple[str, str]] = field(default_factory=list) # (rel, reason) + in_flight: list[str] = field(default_factory=list) + orphans: list[str] = field(default_factory=list) # translated files whose English source is gone + pinned: list[str] = field(default_factory=list) # files[rel].manual == true + skipped_failed: list[tuple[str, str]] = field(default_factory=list) # failed at the current hash + unscoped: list[str] = field(default_factory=list) # status entries for files outside scope + passthrough: list[str] = field(default_factory=list) # passthrough files in scope (after --file) + to_copy: list[str] = field(default_factory=list) # ... whose copy is byte-stale or missing + current: int = 0 + passthrough_current: int = 0 + + +def target_file(lang: str, rel: str) -> Path: + return LOCALIZED_DIR / lang / "content" / rel + + +def passthrough_stale(lang: str, rel: str) -> bool: + target = target_file(lang, rel) + return not target.exists() or target.read_bytes() != (CONTENT_DIR / rel).read_bytes() + + +def build_plan( + lang: str, + sources: dict[str, str], + passthrough: dict[str, str], + status: dict[str, Any], + options: PlanOptions, +) -> LanguagePlan: + plan = LanguagePlan(lang) + files = status["files"] + pending = status["pendingJobs"] + failures = status["failures"] + for rel, src_hash in sources.items(): + if not matches_filters(rel, options.file_filters): + continue + entry = files.get(rel, {}) + if entry.get("manual") is True: + plan.pinned.append(rel) + continue + exists = target_file(lang, rel).exists() + current = entry.get("status") == STATUS_TRANSLATED and entry.get("sourceHash") == src_hash and exists + if options.force: + plan.to_submit.append((rel, "forced")) + continue + if current: + plan.current += 1 + continue + job = pending.get(rel) + if job and job.get("sourceHash") == src_hash: + plan.in_flight.append(rel) + continue + failure = failures.get(rel) + if failure and failure.get("sourceHash") == src_hash and not options.retry_failed: + plan.skipped_failed.append((rel, str(failure.get("error")))) + continue + if not exists or entry.get("status") != STATUS_TRANSLATED: + reason = "missing" if not exists else "untranslated" + else: + reason = "outdated" + plan.to_submit.append((rel, reason)) + + for rel in passthrough: + if not matches_filters(rel, options.file_filters): + continue + plan.passthrough.append(rel) + if passthrough_stale(lang, rel): + plan.to_copy.append(rel) + else: + plan.passthrough_current += 1 + + for rel in files: + if rel in sources or rel in passthrough: + continue + if not options.filtered and target_file(lang, rel).exists() and not (CONTENT_DIR / rel).exists(): + plan.orphans.append(rel) + elif not (target_file(lang, rel).exists() and not (CONTENT_DIR / rel).exists()): + plan.unscoped.append(rel) + return plan + + +# --------------------------------------------------------------------------- +# Run report +# --------------------------------------------------------------------------- + + +class RunReport: + def __init__(self) -> None: + self.per_lang: dict[str, dict[str, Any]] = {} + self.batch_failures = 0 + self.cancel_failures = 0 # pending requests --cancel-pending could not cancel + self.status_rounds_ok = 0 # POST /status calls that returned + self.status_rounds_failed = 0 # ... that raised after retries + self.notes: list[str] = [] + + @property + def poll_failed(self) -> bool: + """True when status was asked for but never answered: nothing could be checked.""" + return self.status_rounds_failed > 0 and self.status_rounds_ok == 0 + + @property + def failed(self) -> bool: + return self.batch_failures > 0 or self.poll_failed or self.cancel_failures > 0 + + def lang(self, lang: str) -> dict[str, Any]: + return self.per_lang.setdefault( + lang, + { + "submitted": [], + "delivered": [], + "repaired_files": [], + "repaired": Counter(), + "warnings": [], + "copied": [], + "superseded": [], + "cancelled": [], + "dropped": [], + "still_pending": [], + "failed": [], + "removed": [], + "chars": 0, + "words": 0, + "equivalent_words": 0, + "fee_words": 0, + }, + ) + + @staticmethod + def _capped(items: list[str]) -> list[str]: + shown = items[:REPORT_LIST_CAP] + if len(items) > REPORT_LIST_CAP: + shown.append(f"... and {len(items) - REPORT_LIST_CAP} more") + return shown + + def to_markdown(self, config: TranslationConfig) -> str: + out = [f"## Translation run ({config.environment_label}, service type `{config.service_type or 'unset'}`)", ""] + out.append( + "| Language | Submitted | Delivered | Repaired | Warnings | Copied | Superseded | Dropped | " + "Still pending | Failed | Removed |" + ) + out.append("|---|---|---|---|---|---|---|---|---|---|---|") + for lang, r in sorted(self.per_lang.items()): + out.append( + f"| {lang} | {len(r['submitted'])} | {len(r['delivered'])} | {len(r['repaired_files'])} | " + f"{len(r['warnings'])} | {len(r['copied'])} | {len(r['superseded'])} | {len(r['dropped'])} | " + f"{len(r['still_pending'])} | {len(r['failed'])} | {len(r['removed'])} |" + ) + for lang, r in sorted(self.per_lang.items()): + if r["chars"]: + out.append(f"\n{lang}: {r['chars']:,} characters submitted.") + if r["words"] or r["equivalent_words"] or r["fee_words"]: + out.append( + f"\n{lang}: billed words {r['words']:,} (equivalent {r['equivalent_words']:,}, fee {r['fee_words']:,})." + ) + if r["repaired"]: + kinds = ", ".join(f"{n} {kind}" for kind, n in sorted(r["repaired"].items())) + out.append(f"\n{lang}: repaired from English: {kinds} in {len(r['repaired_files'])} file(s).") + sections: list[tuple[str, list[str]]] = [ + (f"{lang}: failed (translation kept as before)", [f"`{rel}` — {why}" for rel, why in r["failed"]]), + (f"{lang}: written with warnings", [f"`{rel}` — {why}" for rel, why in r["warnings"]]), + (f"{lang}: superseded (in-flight job cancelled, resubmitted)", [f"`{x}`" for x in r["superseded"]]), + ( + f"{lang}: cancelled (pending request dropped, translation unchanged)", + [f"`{x}`" for x in r["cancelled"]], + ), + (f"{lang}: dropped pending jobs", [f"`{rel}` — {why}" for rel, why in r["dropped"]]), + (f"{lang}: still pending at Translated", [f"`{x}`" for x in r["still_pending"]]), + (f"{lang}: removed (English source gone)", [f"`{x}`" for x in r["removed"]]), + ] + for title, items in sections: + if items: + out.append(f"\n### {title}\n") + out.extend(f"- {item}" for item in self._capped(items)) + if self.notes: + out.append("\n### Notes\n") + out.extend(f"- {n}" for n in self._capped(self.notes)) + return "\n".join(out) + "\n" + + +# --------------------------------------------------------------------------- +# Commands: plan / baseline +# --------------------------------------------------------------------------- + + +def print_plan(lang: str, plan: LanguagePlan, status: dict[str, Any], limit: int = 0) -> None: + print( + f"\n{lang}: {plan.current} current, {len(plan.to_submit)} to submit, {len(plan.in_flight)} in flight, " + f"{len(plan.skipped_failed)} failed at this hash, {len(plan.pinned)} pinned, {len(plan.orphans)} orphaned, " + f"{len(plan.unscoped)} unscoped, {len(plan.to_copy)} passthrough to copy " + f"({plan.passthrough_current} passthrough current)" + ) + # Same cut as prepare_submission, so the printed character total is what --submit --limit would send. + queue = plan.to_submit[:limit] if limit else plan.to_submit + chars = 0 + for rel, reason in queue: + content, _, _ = prepare_payload(rel, read_text(CONTENT_DIR / rel)) + chars += len(content) + print(f" submit {reason:<12} {rel}") + if len(queue) < len(plan.to_submit): + print(f" ... {len(plan.to_submit) - len(queue)} more beyond --limit {limit}") + for rel in plan.in_flight: + print(f" pending {rel}") + for rel, error in plan.skipped_failed: + print(f" failed {rel}: {error}") + for rel in plan.pinned: + print(f" pinned {rel}") + for rel in plan.to_copy: + print(f" copy passthrough {rel}") + for rel in plan.orphans: + print(f" remove orphan {rel}") + for rel in plan.unscoped: + print(f" drop unscoped {rel}") + for rel, job in status["pendingJobs"].items(): + if rel not in plan.in_flight and rel not in {r for r, _ in plan.to_submit}: + print(f" pending (out of filter) {rel} (job {job.get('jobId')}, {job.get('environment', '?')})") + if queue: + print(f" ~{chars:,} characters would be submitted for {lang}") + + +def cmd_plan(langs: list[str], sources: dict[str, str], passthrough: dict[str, str], options: PlanOptions) -> None: + for lang in langs: + status = load_status(lang) + print_plan(lang, build_plan(lang, sources, passthrough, status, options), status, options.limit) + + +def check_existing_translation( + rel: str, source_text: str, translated_text: str +) -> tuple[str, list[str], Counter[str], list[str]]: + """Run an existing translation through the delivery path (repair, then verify) against its + English source. Returns (repaired text, problems, repaired counts by kind, warnings).""" + suffix = Path(rel).suffix.lower() + if suffix in (".yml", ".yaml"): + src_names, tr_names = yaml_name_map(source_text), yaml_name_map(translated_text) + problems = [] if set(src_names) == set(tr_names) else [f"toc names {len(src_names)} -> {len(tr_names)}"] + return translated_text, problems, Counter(), [] + job = {"mode": "json-placeholders" if suffix == ".json" else "raw"} + if suffix == ".json": + translated_text = encode_placeholders(translated_text) + try: + return finalize_translation(rel, job, source_text, translated_text, repair=True, check_identity=False) + except Exception as e: # noqa: BLE001 - reported, never crashes the baseline + return translated_text, [f"could not verify: {e}"], Counter(), [] + + +def format_repairs(repaired: Counter[str]) -> str: + return ", ".join(f"{repaired[kind]} {kind}" for kind in REPAIR_KINDS) + + +def cmd_baseline( + langs: list[str], + sources: dict[str, str], + passthrough: dict[str, str], + ref: str | None, + overwrite: bool, + repair_existing: bool = False, + run: GitRunner = run_git, +) -> None: + ref_hashes: dict[str, str] = {} + if ref: + sha = resolve_git_ref(ref, run) + print(f"baseline ref {ref} -> {sha}") + ref_hashes = hashes_at_git_ref(sha, list(sources), run) + verb = "repaired" if repair_existing else "would repair" + for lang in langs: + status = load_status(lang) + scoped_entries = [rel for rel in status["files"] if rel in sources] + if scoped_entries and not overwrite: + raise SystemExit( + f"{status_path(lang)} already tracks {len(scoped_entries)} file(s); pass --overwrite-baseline " + "to replace the baseline." + ) + current = outdated = missing = 0 + marked: list[tuple[str, str]] = [] + notes: list[str] = [] + repairs: Counter[str] = Counter() + repaired_files = 0 + warned = 0 + for rel, src_hash in sources.items(): + entry = status["files"].get(rel, {}) + if entry.get("manual") is True: + continue + target = target_file(lang, rel) + if not target.exists(): + status["files"][rel] = {"sourceHash": "", "status": STATUS_UNTRANSLATED} + missing += 1 + continue + text, problems, repaired, warnings = check_existing_translation( + rel, read_text(CONTENT_DIR / rel), read_text(target) + ) + if problems: + status["files"][rel] = {"sourceHash": "", "status": STATUS_UNTRANSLATED} + marked.append((rel, "; ".join(problems))) + continue + if warnings: + warned += 1 + notes.append(f" {'warning':<13} {rel}: {'; '.join(warnings)}") + if repaired: + repairs.update(repaired) + repaired_files += 1 + notes.append(f" {verb:<13} {rel}: {format_repairs(repaired)}") + if repair_existing: + write_text(target, text) + if ref and rel not in ref_hashes: + notes.append(f" new {rel}: not in content/ at {ref}; baselined at the current hash") + baseline_hash = ref_hashes.get(rel, src_hash) if ref else src_hash + status["files"][rel] = {"sourceHash": baseline_hash, "status": STATUS_TRANSLATED} + if baseline_hash == src_hash: + current += 1 + else: + outdated += 1 + marked.append((rel, f"English changed since {ref}")) + for rel in list(status["files"]): + if rel not in sources and rel not in passthrough: + status["files"].pop(rel) + print(f"{lang}: dropped unscoped entry {rel}") + save_status(lang, status) + print( + f"\n{lang}: baseline written — {current} current, {outdated} changed since {ref or 'HEAD'}, " + f"{len(marked) - outdated} structurally stale, {missing} missing, {repaired_files} {verb}, " + f"{warned} accepted with inline-code warnings" + ) + for line in notes: + print(line) + if repaired_files: + hint = "" if repair_existing else " (pass --repair-existing to write the repaired text)" + print(f" {lang}: {verb} {repaired_files} file(s): {format_repairs(repairs)}{hint}") + for rel, why in marked: + print(f" outdated {rel}: {why}") + + +# --------------------------------------------------------------------------- +# Commands: submit / dump-orders +# --------------------------------------------------------------------------- + + +@dataclass +class LanguageSubmission: + lang: str + locale: str + status: dict[str, Any] + plan: LanguagePlan + orders: list[dict[str, Any]] = field(default_factory=list) + jobs: dict[str, dict[str, Any]] = field(default_factory=dict) # rel -> pending job record + empty_copies: list[str] = field(default_factory=list) # nothing translatable: copied through + + @property + def chars(self) -> int: + return sum(int(j["chars"]) for j in self.jobs.values()) + + +def build_order( + config: TranslationConfig, id_content: str, content: str, content_type: str, locale: str +) -> dict[str, Any]: + order: dict[str, Any] = { + "id_content": id_content, + "content": content, + "content_type": content_type, + "source_language": config.source_locale, + "target_languages": [locale], + "service_type": config.service_type, + } + if config.instructions: + order["context"] = {"instructions": config.instructions} + return order + + +def prepare_submission( + config: TranslationConfig, + lang: str, + locale: str, + sources: dict[str, str], + passthrough: dict[str, str], + options: PlanOptions, +) -> LanguageSubmission: + """Plan one language and build its /translate orders without touching anything.""" + status = load_status(lang) + plan = build_plan(lang, sources, passthrough, status, options) + sub = LanguageSubmission(lang, locale, status, plan) + queue = plan.to_submit[: options.limit] if options.limit else plan.to_submit + for rel, reason in queue: + content, content_type, info = prepare_payload(rel, read_text(CONTENT_DIR / rel)) + if not content.strip() or info.get("units") == 0: + sub.empty_copies.append(rel) + continue + id_content = f"{lang}/{rel}" + sub.orders.append(build_order(config, id_content, content, content_type, locale)) + sub.jobs[rel] = { + **info, + "idContent": id_content, + "sourceHash": sources[rel], + "reason": reason, + "contentType": content_type, + "chars": len(content), + } + return sub + + +def enforce_sandbox_caps( + config: TranslationConfig, submissions: list[LanguageSubmission], allow_unbounded: bool +) -> None: + if not config.is_sandbox or allow_unbounded: + return + files = sum(len(s.jobs) for s in submissions) + chars = sum(s.chars for s in submissions) + if files > config.max_files_per_run or chars > config.max_chars_per_run: + raise SystemExit( + f"Refusing to submit {files} file(s) / {chars:,} characters to the sandbox in one run " + f"(limits: {config.max_files_per_run} files, {config.max_chars_per_run:,} characters). " + "Narrow with --limit/--file or pass --allow-unbounded." + ) + + +def apply_local_changes( + sub: LanguageSubmission, sources: dict[str, str], passthrough: dict[str, str], r: dict[str, Any] +) -> None: + """Orphan removal, unscoped cleanup, passthrough and empty-file copies; saved right away.""" + lang, status, plan = sub.lang, sub.status, sub.plan + for rel in plan.orphans: + target_file(lang, rel).unlink() + for section in ("files", "pendingJobs", "failures"): + status[section].pop(rel, None) + r["removed"].append(rel) + print(f"{lang}: removed orphan {rel}") + for rel in plan.unscoped: + status["files"].pop(rel, None) + print(f"{lang}: dropped unscoped status entry {rel}") + for rel in plan.passthrough: + if rel in plan.to_copy: + target = target_file(lang, rel) + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(CONTENT_DIR / rel, target) + r["copied"].append(rel) + print(f"{lang}: copied passthrough {rel}") + # Always (re)record the entry so legacy "translated" tags on passthrough files become "copied". + status["files"][rel] = {"sourceHash": passthrough[rel], "status": STATUS_COPIED} + for rel in sub.empty_copies: + write_text(target_file(lang, rel), read_text(CONTENT_DIR / rel)) + status["files"][rel] = {"sourceHash": sources[rel], "status": STATUS_TRANSLATED} + status["failures"].pop(rel, None) + save_status(lang, status) + + +def record_receipts( + config: TranslationConfig, + client: TranslationClient, + sub: LanguageSubmission, + batch: list[dict[str, Any]], + items: list[dict[str, Any]], + r: dict[str, Any], +) -> None: + lang, status = sub.lang, sub.status + by_id = {str(item.get("id_content")): item for item in items} + batch_ids = {str(o["id_content"]) for o in batch} + for rel, job in sub.jobs.items(): + if job["idContent"] not in batch_ids: + continue + item = by_id.get(job["idContent"]) + if item is None or item.get("id") is None: + record_failure(status, rel, "no job returned by POST translate", job["sourceHash"]) + r["failed"].append((rel, "no job returned by POST translate")) + print(f"{lang}: FAILED {rel}: no job returned by POST translate") + continue + old = status["pendingJobs"].get(rel) + status["pendingJobs"][rel] = { + **job, + "jobId": item.get("id"), + "targetLanguage": item.get("target_language", sub.locale), + "serviceType": config.service_type, + "environment": config.environment, + "submittedAt": utc_now(), + } + status["failures"].pop(rel, None) + r["submitted"].append(rel) + r["chars"] += int(job["chars"]) + print(f"{lang}: submitted {rel} ({job['reason']}, {job['chars']:,} chars, job {item.get('id')})") + if old and old.get("jobId") is not None and str(old.get("jobId")) != str(item.get("id")): + supersede_job(client, lang, rel, old, r) + + +def supersede_job(client: TranslationClient, lang: str, rel: str, old: dict[str, Any], r: dict[str, Any]) -> None: + r["superseded"].append(rel) + try: + client.cancel([int(old["jobId"])]) + print(f"{lang}: cancelled superseded job {old['jobId']} for {rel}") + except (TranslatedApiError, ValueError, TypeError) as e: + print(f"{lang}: WARNING could not cancel superseded job {old.get('jobId')} for {rel}: {e}") + + +def submit_language( + config: TranslationConfig, + client: TranslationClient, + sub: LanguageSubmission, + sources: dict[str, str], + passthrough: dict[str, str], + report: RunReport, +) -> None: + lang, status = sub.lang, sub.status + r = report.lang(lang) + apply_local_changes(sub, sources, passthrough, r) + try: + for start in range(0, len(sub.orders), config.batch_size): + batch = sub.orders[start : start + config.batch_size] + try: + items = client.translate(batch) + except TranslatedApiError as e: + report.batch_failures += 1 + details = e.details() + print(f"{lang}: BATCH FAILED ({len(batch)} orders): {e}") + for rel, job in sub.jobs.items(): + if any(str(o["id_content"]) == job["idContent"] for o in batch): + record_failure(status, rel, f"POST translate failed: {e}", job["sourceHash"], apiError=details) + r["failed"].append((rel, f"POST translate failed: {e}")) + continue + record_receipts(config, client, sub, batch, items, r) + save_status(lang, status) + finally: + save_status(lang, status) + + +def prepare_all( + config: TranslationConfig, + langs: list[str], + sources: dict[str, str], + passthrough: dict[str, str], + locales: dict[str, str], + options: PlanOptions, + allow_unbounded: bool, +) -> list[LanguageSubmission]: + require_locales(langs, locales) + submissions = [prepare_submission(config, lang, locales[lang], sources, passthrough, options) for lang in langs] + enforce_sandbox_caps(config, submissions, allow_unbounded) + return submissions + + +def cmd_submit( + config: TranslationConfig, + client: TranslationClient, + langs: list[str], + sources: dict[str, str], + passthrough: dict[str, str], + locales: dict[str, str], + options: PlanOptions, + allow_unbounded: bool, + report: RunReport, +) -> None: + for sub in prepare_all(config, langs, sources, passthrough, locales, options, allow_unbounded): + submit_language(config, client, sub, sources, passthrough, report) + + +def cmd_dump_orders( + config: TranslationConfig, + out_dir: Path, + langs: list[str], + sources: dict[str, str], + passthrough: dict[str, str], + locales: dict[str, str], + options: PlanOptions, + allow_unbounded: bool, +) -> None: + submissions = prepare_all(config, langs, sources, passthrough, locales, options, allow_unbounded) + summary: dict[str, Any] = { + "environment": config.environment_label, + "baseUrl": config.base_url, + "serviceType": config.service_type, + "languages": {}, + } + for sub in submissions: + for order in sub.orders: + rel = str(order["id_content"]).split("/", 1)[1] + path = out_dir / sub.lang / f"{rel}.json" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(order, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + summary["languages"][sub.lang] = { + "orders": len(sub.orders), + "characters": sub.chars, + "passthroughCopies": sub.plan.to_copy, + "emptyCopies": sub.empty_copies, + "orphans": sub.plan.orphans, + "unscoped": sub.plan.unscoped, + "pinned": sub.plan.pinned, + "skippedFailed": [rel for rel, _ in sub.plan.skipped_failed], + } + print(f"{sub.lang}: {len(sub.orders)} order(s), {sub.chars:,} characters -> {out_dir / sub.lang}") + out_dir.mkdir(parents=True, exist_ok=True) + (out_dir / "summary.json").write_text(json.dumps(summary, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + print(f"summary -> {out_dir / 'summary.json'} (nothing was sent; status files untouched)") + + +# --------------------------------------------------------------------------- +# Commands: poll +# --------------------------------------------------------------------------- + + +def parse_submitted_at(job: dict[str, Any], lang: str, rel: str) -> datetime: + raw = job.get("submittedAt") + try: + return datetime.strptime(str(raw), TIMESTAMP_FORMAT).replace(tzinfo=UTC) + except ValueError: + print(f"{lang}: WARNING malformed submittedAt {raw!r} for {rel}; treating as now") + job["submittedAt"] = utc_now() + return datetime.now(UTC) + + +def words_of(item: dict[str, Any], key: str) -> int: + value = item.get(key) + return int(value) if isinstance(value, (int, float)) else 0 + + +def handle_job_state( + lang: str, + rel: str, + job: dict[str, Any], + item: dict[str, Any] | None, + sources: dict[str, str], + status: dict[str, Any], + lenient: bool, + r: dict[str, Any], + unknown_states: set[str], +) -> bool: + """Process one pending job against its /status item. Returns True when it is still pending.""" + state = str((item or {}).get("status", "unknown")).lower() + if item is not None and state in DELIVERED_STATUSES: + status["pendingJobs"].pop(rel) + for key in ("words", "equivalent_words", "fee_words"): + r[key] += words_of(item, key) + problems, repaired, warnings = apply_delivery(lang, rel, job, item, sources, status, lenient) + if problems: + r["failed"].append((rel, "; ".join(problems))) + print(f"{lang}: REJECTED {rel}: {'; '.join(problems)}") + else: + r["delivered"].append(rel) + if repaired: + r["repaired"].update(repaired) + r["repaired_files"].append(rel) + if warnings: + r["warnings"].append((rel, "; ".join(warnings))) + print(f"{lang}: delivered {rel}" + (f" (repaired {dict(repaired)})" if repaired else "")) + return False + if state in FAILED_STATUSES: + status["pendingJobs"].pop(rel) + record_failure( + status, + rel, + f"Translated status '{state}'", + str(job.get("sourceHash", "")), + jobId=job.get("jobId"), + apiStatus=state, + ) + r["failed"].append((rel, f"Translated status '{state}'")) + print(f"{lang}: FAILED {rel}: status {state}") + return False + if state not in WAITING_STATUSES and state not in unknown_states: + unknown_states.add(state) + print(f"{lang}: WARNING unknown Translated status '{state}' (job {job.get('jobId')}); treating as waiting") + if datetime.now(UTC) - parse_submitted_at(job, lang, rel) > PENDING_TIMEOUT: + status["pendingJobs"].pop(rel) + record_failure( + status, + rel, + f"not delivered within {PENDING_TIMEOUT.days} days (last status '{state}')", + str(job.get("sourceHash", "")), + jobId=job.get("jobId"), + apiStatus=state, + ) + r["failed"].append((rel, "timed out")) + print(f"{lang}: TIMED OUT {rel}") + return False + return True + + +def drop_foreign_jobs(config: TranslationConfig, lang: str, status: dict[str, Any], r: dict[str, Any]) -> bool: + """Pending jobs submitted in another environment cannot be polled here; drop and report them.""" + changed = False + for rel, job in list(status["pendingJobs"].items()): + env = job.get("environment") + if env != config.environment: + status["pendingJobs"].pop(rel) + why = f"dropped: submitted in {env}" if env else "dropped: environment not recorded" + r["dropped"].append((rel, why)) + print(f"{lang}: {why} ({rel}, job {job.get('jobId')})") + changed = True + return changed + + +@dataclass +class PollState: + """Memory shared by all poll rounds of one run.""" + + unknown_states: set[str] = field(default_factory=set) + delivery_triggered: set[int] = field(default_factory=set) # sandbox request ids already sent to /sandbox/delivery + delivery_errors: Counter[str] = field(default_factory=Counter) # lang -> failed POST /sandbox/delivery calls + last_delivery_error: dict[str, str] = field(default_factory=dict) # lang -> last error text printed + + +def delivery_failures(payload: Any) -> list[tuple[str, str]]: + """Flatten a /sandbox/delivery response into (subject, error) pairs for what was not delivered. + + The endpoint answers with either a list of jobs ({id_job, delivery_status, requests: [...], error_message, + error_body}) or a flat list of requests ({id_request, delivery_status, error_message, status_code, ...}).""" + failures: list[tuple[str, str]] = [] + for item in payload if isinstance(payload, list) else []: + if not isinstance(item, dict) or "delivery_status" not in item: + continue + failed = str(item.get("delivery_status", "")).lower() != "succeeded" + message = str(item.get("error_message") or item.get("error_body") or "no error message") + requests = item.get("requests") + if isinstance(requests, list): + nested = delivery_failures(requests) + if nested: + failures.extend(nested) + elif failed: + failures.append((f"job {item.get('id_job')}", message)) + elif failed: + failures.append((f"request {item.get('id_request')}", message)) + return failures + + +def sandbox_delivery_candidates( + pending: dict[str, dict[str, Any]], by_id: dict[str, dict[str, Any]], triggered: set[int] +) -> list[int]: + """Still-pending sandbox requests whose /status state is exactly "completed" and that were not pushed yet + in this run. Anything earlier in the pipeline is left alone: the sandbox answers 500 for those.""" + ids: set[int] = set() + for job in pending.values(): + item = by_id.get(str(job.get("jobId"))) + if item is not None and str(item.get("status", "")).lower() == "completed": + ids.add(int(job["jobId"])) + return sorted(ids - triggered) + + +def trigger_sandbox_delivery( + client: TranslationClient, + lang: str, + pending: dict[str, dict[str, Any]], + by_id: dict[str, dict[str, Any]], + state: PollState, +) -> None: + """Sandbox only: ask Translated to deliver requests that would otherwise wait at "completed" forever. + Failures are printed and the jobs stay pending; the next status round shows what got through. A failing + endpoint is counted every round but printed only when its error text changes, so a stuck sandbox does not + flood the log.""" + ids = sandbox_delivery_candidates(pending, by_id, state.delivery_triggered) + if not ids: + return + try: + payload = client.sandbox_deliver(ids) + except TranslatedApiError as e: + state.delivery_errors[lang] += 1 + if state.last_delivery_error.get(lang) != str(e): + state.last_delivery_error[lang] = str(e) + print(f"{lang}: WARNING POST sandbox/delivery failed: {e}") + return + state.delivery_triggered.update(ids) + print(f"{lang}: triggered sandbox delivery for {len(ids)} request(s)") + for subject, message in delivery_failures(payload): + print(f"{lang}: sandbox delivery failed for {subject}: {message}") + + +def poll_round( + config: TranslationConfig, + client: TranslationClient, + lang: str, + sources: dict[str, str], + lenient: bool, + report: RunReport, + state: PollState, + first_round: bool, +) -> int: + """One /status round for one language. Returns the number of jobs still pending.""" + status = load_status(lang) + r = report.lang(lang) + changed = drop_foreign_jobs(config, lang, status, r) + pending = status["pendingJobs"] + ids: set[int] = set() + for rel, job in list(pending.items()): + try: + ids.add(int(job["jobId"])) + except (KeyError, TypeError, ValueError): + # A hand-edited status file must not take the whole poll down; drop the entry and report it. + pending.pop(rel) + record_failure(status, rel, "invalid jobId in status file", str(job.get("sourceHash", ""))) + r["failed"].append((rel, "invalid jobId in status file")) + print(f"{lang}: FAILED {rel}: invalid jobId {job.get('jobId')!r} in status file") + changed = True + if not pending: + if changed: + save_status(lang, status) + return 0 + try: + by_id = {str(item.get("id")): item for item in client.status_many(sorted(ids))} + except TranslatedApiError as e: + report.status_rounds_failed += 1 + print(f"{lang}: WARNING POST status failed: {e}") + if changed: + save_status(lang, status) + return len(pending) + report.status_rounds_ok += 1 + outstanding = 0 + for rel, job in list(pending.items()): + item = by_id.get(str(job.get("jobId"))) + if handle_job_state(lang, rel, job, item, sources, status, lenient, r, state.unknown_states): + outstanding += 1 + if first_round: + print(f"{lang}: waiting {rel} ({(item or {}).get('status', 'unknown')})") + else: + changed = True + if config.is_sandbox and outstanding: + trigger_sandbox_delivery(client, lang, pending, by_id, state) + if changed: + save_status(lang, status) + return outstanding + + +def cmd_poll( + config: TranslationConfig, + client: TranslationClient, + langs: list[str], + sources: dict[str, str], + wait_minutes: float, + lenient: bool, + report: RunReport, + initial_delay: float = 0, + sleep: Callable[[float], None] = time.sleep, +) -> None: + deadline = time.monotonic() + wait_minutes * 60 + if initial_delay and any(load_status(lang)["pendingJobs"] for lang in langs): + print(f"waiting {initial_delay:.0f}s before the first status round") + sleep(initial_delay) + state = PollState() + round_no = 0 + while True: + outstanding = 0 + for lang in langs: + if round_no and time.monotonic() >= deadline: + break + outstanding += poll_round(config, client, lang, sources, lenient, report, state, round_no == 0) + remaining = deadline - time.monotonic() + if outstanding == 0 or remaining <= 0: + break + delay = min(POLL_DELAYS[min(round_no, len(POLL_DELAYS) - 1)], max(1.0, remaining)) + print(f"{outstanding} job(s) still pending; polling again in {delay:.0f}s ({int(remaining)}s left)") + sleep(delay) + round_no += 1 + for lang in langs: + report.lang(lang)["still_pending"] = sorted(load_status(lang)["pendingJobs"]) + for lang, count in sorted(state.delivery_errors.items()): + report.notes.append( + f"{lang}: POST sandbox/delivery failed {count} time(s); last error: {state.last_delivery_error[lang]}" + ) + if report.poll_failed: + note = ( + f"every POST status call failed ({report.status_rounds_failed} attempt(s)); no delivery could be " + "checked in this run, pending jobs are unchanged" + ) + report.notes.append(note) + print(f"ERROR: {note}", file=sys.stderr) + + +def apply_delivery( + lang: str, + rel: str, + job: dict[str, Any], + item: dict[str, Any], + sources: dict[str, str], + status: dict[str, Any], + lenient: bool, +) -> tuple[list[str], Counter[str], list[str]]: + """Repair, verify and write one delivered translation. Returns (problems, repaired counts, warnings). + Problems reject the delivery (unless --lenient); warnings are written and reported.""" + job_hash = str(job.get("sourceHash", "")) + if status["files"].get(rel, {}).get("manual") is True: + # Pinned after submission: the hand-maintained file wins, the delivery is not written. + print(f"{lang}: {rel} is pinned (manual: true); delivery discarded") + return ["pinned; delivery discarded"], Counter(), [] + translated = item.get("translated_content") + if not isinstance(translated, str) or not translated: + record_failure(status, rel, "delivered without translated_content", job_hash, jobId=job.get("jobId")) + return ["delivered without translated_content"], Counter(), [] + source_path = CONTENT_DIR / rel + if not source_path.exists(): + record_failure(status, rel, "English source no longer exists", job_hash, jobId=job.get("jobId")) + return ["English source no longer exists"], Counter(), [] + stale = job_hash != sources.get(rel) + try: + text, problems, repaired, warnings = finalize_translation(rel, job, read_text(source_path), translated) + except Exception as e: # noqa: BLE001 - reported to the reviewer, never crashes the run + record_failure(status, rel, f"could not reinsert translation: {e}", job_hash, jobId=job.get("jobId")) + return [f"could not reinsert translation: {e}"], Counter(), [] + # --lenient only makes sense for raw Markdown/HTML, where the assembled text is still usable. + # A structured payload (toc names, UI strings) with problems rebuilds to English or nothing. + structured = job.get("mode") in ("yaml-names", "json-placeholders") + if problems and (not lenient or not text or structured): + error = "; ".join(problems) + if stale: + error = f"source changed while translating; {error}" + if lenient: + why = "no usable text" if not text else "structured payload cannot be written partially" + print(f"{lang}: --lenient ignored for {rel}: {why}") + record_failure(status, rel, error, job_hash, jobId=job.get("jobId")) + return problems, repaired, warnings + write_text(target_file(lang, rel), text) + # A stale job is written at the hash it was translated from; the next submit re-plans it. + status["files"][rel] = {"sourceHash": job_hash if stale else sources[rel], "status": STATUS_TRANSLATED} + status["failures"].pop(rel, None) + if problems: + print(f"{lang}: written despite problems ({'; '.join(problems)}): {rel}") + if warnings: + print(f"{lang}: written with warnings ({'; '.join(warnings)}): {rel}") + if stale: + print(f"{lang}: source changed while translating {rel}; written at the old hash, will resubmit") + return [], repaired, warnings + + +# --------------------------------------------------------------------------- +# Commands: cancel pending +# --------------------------------------------------------------------------- + + +def confirmed_cancellations(response: dict[str, Any], requested: list[int]) -> set[int]: + """Ids a /translate/cancel response confirms. Without an id_request list the 200 itself confirms the call.""" + listed = response.get("id_request") + if not isinstance(listed, list): + return set(requested) + return {int(i) for i in listed if isinstance(i, (int, float))} & set(requested) + + +def cancel_requests(client: TranslationClient, ids: list[int]) -> tuple[set[int], dict[int, str]]: + """POST /translate/cancel in chunks. Returns (confirmed ids, id -> error for the rest). + + Translated cancels all-or-nothing per call and only while a request is still in an early state (upload, + wc raw, ingested, bucketing), so a refused chunk is retried one id at a time: the cancellable requests + still go and every other one gets its own error.""" + confirmed: set[int] = set() + errors: dict[int, str] = {} + + def attempt(batch: list[int]) -> str | None: + try: + confirmed.update(confirmed_cancellations(client.cancel(batch), batch)) + except TranslatedApiError as e: + return str(e) + return None + + for start in range(0, len(ids), CANCEL_CHUNK): + chunk = ids[start : start + CANCEL_CHUNK] + error = attempt(chunk) + if error is None: + continue + for job_id in chunk: + single = attempt([job_id]) if len(chunk) > 1 else error + if single is not None: + errors[job_id] = single + for job_id in ids: + if job_id not in confirmed and job_id not in errors: + errors[job_id] = "not confirmed by the cancel response" + return confirmed, errors + + +def cmd_cancel_pending( + config: TranslationConfig, client: TranslationClient, langs: list[str], force: bool, report: RunReport +) -> None: + """Cancel every pending request of the active environment and drop its record. `files` and `failures` are + untouched; pending jobs from another environment are left alone and reported.""" + if not config.is_sandbox and not force: + raise SystemExit( + f"Refusing to cancel pending {config.environment_label} requests without --force: cancelling production " + "requests discards paid work." + ) + for lang in langs: + status = load_status(lang) + r = report.lang(lang) + pending = status["pendingJobs"] + foreign: Counter[str] = Counter() + by_job: dict[int, str] = {} + for rel, job in pending.items(): + env = str(job.get("environment") or "an unrecorded environment") + if env != config.environment: + foreign[env] += 1 + continue + try: + by_job[int(job["jobId"])] = rel + except (KeyError, TypeError, ValueError): + print(f"{lang}: WARNING invalid jobId {job.get('jobId')!r} for {rel}; left pending") + for env, n in sorted(foreign.items()): + print(f"{lang}: {n} pending job(s) from {env} left untouched") + if not by_job: + print(f"{lang}: nothing to cancel") + continue + confirmed, errors = cancel_requests(client, sorted(by_job)) + for job_id, error in sorted(errors.items()): + report.cancel_failures += 1 + print(f"{lang}: WARNING could not cancel request {job_id} for {by_job[job_id]}; kept pending: {error}") + cancelled = sorted(by_job[job_id] for job_id in confirmed) + for rel in cancelled: + pending.pop(rel) + r["cancelled"].extend(cancelled) + if cancelled: + save_status(lang, status) + print(f"{lang}: cancelled {len(cancelled)} request(s)") + for rel in cancelled: + print(f" cancelled {rel}") + + +# --------------------------------------------------------------------------- +# Commands: probe +# --------------------------------------------------------------------------- + + +def cmd_probe(config: TranslationConfig, client: TranslationClient, langs: list[str], locales: dict[str, str]) -> int: + service_types = client.service_type_names() + languages = client.languages() + names = [str(s.get("name")) for s in service_types] + print(f"\n{len(service_types)} service type(s):") + for s in service_types: + flags = [k.replace("_enabled", "") for k in ("human_enabled", "machine_enabled") if s.get(k)] + print(f" {s.get('name')}" + (f" [{', '.join(flags)}]" if flags else "")) + keys = [str(lang_item.get("key")) for lang_item in languages] + print(f"\n{len(languages)} language(s): {', '.join(keys)}") + ok = True + wanted = {"sourceLocale": config.source_locale} + wanted.update({f"translatedLocale[{lang}]": locales.get(lang, "") for lang in langs}) + for what, code in wanted.items(): + if code in keys: + print(f"OK {what} = {code}") + else: + ok = False + print( + f"FAIL {what} = {code} is not in /symbol/languages; closest: {difflib.get_close_matches(code, keys, 5)}" + ) + if config.service_type: + if config.service_type in names: + print(f"OK service type '{config.service_type}' exists") + else: + ok = False + print( + f"FAIL service type '{config.service_type}' is not in /symbol/service-type-names; " + f"closest: {difflib.get_close_matches(config.service_type, names, 5)}" + ) + else: + print("NOTE no service type resolved; set TRANSLATED_SERVICE_TYPE to one of the names above") + return 0 if ok else 1 + + +# --------------------------------------------------------------------------- +# Self-test (offline; no key, no network) +# --------------------------------------------------------------------------- + + +class SelfTest: + def __init__(self) -> None: + self.passed = 0 + self.failures: list[str] = [] + + def check(self, name: str, ok: bool, detail: str = "") -> None: + if ok: + self.passed += 1 + print(f" ok {name}") + else: + self.failures.append(name) + print(f" FAIL {name}" + (f" -- {detail[:300]}" if detail else "")) + + def expect_exit(self, name: str, fn: Callable[[], object], fragment: str = "") -> None: + try: + fn() + except SystemExit as e: + self.check(name, fragment in str(e), f"message: {e}") + return + self.check(name, False, "no SystemExit raised") + + def section(self, title: str) -> None: + print(f"\n[{title}]") + + +def captured(fn: Callable[[], object]) -> str: + """Run fn and return what it printed to stdout.""" + buf = io.StringIO() + with redirect_stdout(buf): + fn() + return buf.getvalue() + + +FIXTURE_MD = ( + BOM + "---\ntitle: Getting started\ndescription: Intro to TE3\nuid: getting-started\nauthor: Nedas\n---\n" + "# Getting started\n\nOpen `View → Options` and read [the guide](guide.md).\n\n" + "> [!NOTE]\n> Use `Selected.Tables` here.\n\n" + "```csharp\n// Assembly references must be at the very top\nvar x = 1;\n```\n\n" + "## Next steps\n\nSee [next](next.md#anchor) and [!include[part](includes/part.md)].\n" +) +FIXTURE_JSON = '{\n "greeting": "Hello {name}, {count} items",\n "bye": "Bye"\n}\n' +FIXTURE_TOC = ( + BOM + + "- name: Home\n href: index.md\n- name: Getting started\n href: getting-started/\n items:\n - name: Install\n href: install.md\n" +) +FIXTURE_HTML = '\n\nGo\n\n

Text

\n\n\n' + + +def fin( + source: str, delivered: str, rel: str = "x.md", check_identity: bool = False, repair: bool = True +) -> tuple[str, list[str], Counter[str], list[str]]: + _, _, info = prepare_payload(rel, source) + return finalize_translation(rel, info, source, delivered, repair=repair, check_identity=check_identity) + + +def selftest_corpus(t: SelfTest, config: TranslationConfig) -> None: + t.section("corpus identity round-trip (prepare_payload -> finalize_translation)") + sources = get_scoped_sources(config) + bad: list[str] = [] + for rel in sources: + text = read_text(CONTENT_DIR / rel) + content, _, info = prepare_payload(rel, text) + try: + out, problems, repaired, warnings = finalize_translation(rel, info, text, content, check_identity=False) + except Exception as e: # noqa: BLE001 + bad.append(f"{rel}: raised {e}") + continue + if out != text or problems or repaired or warnings: + bad.append(f"{rel}: {'differs' if out != text else ''} {problems} {dict(repaired)} {warnings}") + t.check(f"{len(sources)} scoped sources round-trip byte-for-byte with zero problems", not bad, "; ".join(bad[:5])) + passthrough = get_passthrough_sources(config, sources) + t.check("passthrough files are outside the translation scope", not set(passthrough) & set(sources)) + + +def selftest_fixtures(t: SelfTest) -> None: + t.section("markdown repair-then-verify") + identity = FIXTURE_MD.lstrip(BOM) + out, problems, repaired, _ = fin(FIXTURE_MD, identity) + t.check( + "identity delivery reproduces the source (BOM restored)", out == FIXTURE_MD and not problems and not repaired + ) + _, problems, _, _ = fin(FIXTURE_MD, identity.replace("## Next steps\n", "")) + t.check("dropped heading -> problem", any("headings" in p for p in problems), str(problems)) + out, problems, repaired, _ = fin(FIXTURE_MD, identity.replace("(guide.md)", "(guia.md)")) + t.check( + "changed link target (equal count) -> repaired", out == FIXTURE_MD and not problems and repaired["links"] == 1 + ) + _, problems, _, _ = fin(FIXTURE_MD, identity + "\n[more](extra.md)\n") + t.check("extra link -> problem", any("link targets" in p for p in problems), str(problems)) + _, problems, _, _ = fin(FIXTURE_MD, identity.replace("var x = 1;\n```\n", "var x = 1;\n")) + t.check("missing fence line -> problem", any("code fences" in p for p in problems), str(problems)) + out, problems, repaired, _ = fin(FIXTURE_MD, identity.replace("// Assembly references", "// Las referencias")) + t.check( + "translated fenced comment -> restored to English", + out == FIXTURE_MD and not problems and repaired["fences"] == 1, + ) + out, problems, repaired, _ = fin(FIXTURE_MD, identity.replace("`View → Options`", "`Ver → Opciones`")) + t.check( + "changed inline code (equal count) -> repaired", + out == FIXTURE_MD and not problems and repaired["inline code"] == 1, + ) + dropped_span = identity.replace("`Selected.Tables`", "Selected.Tables") + out, problems, repaired, warnings = fin(FIXTURE_MD, dropped_span) + t.check( + "dropped inline code span -> warning, not a problem; delivered text kept", + not problems + and not repaired + and warnings == ["inline code spans 2 -> 1 (missing ['`Selected.Tables`'], added [])"] + and out == BOM + dropped_span, + f"{problems} {warnings}", + ) + extra_span = identity.replace("Open `View", "Open `true` `View") + out, problems, _, warnings = fin(FIXTURE_MD, extra_span) + t.check( + "added inline code span -> warning, not a problem; delivered text kept", + not problems + and warnings == ["inline code spans 2 -> 3 (missing [], added ['`true`'])"] + and out == BOM + extra_span, + f"{problems} {warnings}", + ) + out, problems, repaired, warnings = fin(FIXTURE_MD, dropped_span.replace("(guide.md)", "(guia.md)")) + t.check( + "inline-code drift does not block the other repairs (link target still restored)", + not problems and len(warnings) == 1 and repaired["links"] == 1 and out == BOM + dropped_span, + f"{problems} {warnings} {dict(repaired)}", + ) + _, problems, _, warnings = fin(FIXTURE_MD, dropped_span, repair=False) + t.check( + "inline-code drift is a warning in verify-only mode too", not problems and len(warnings) == 1, str(problems) + ) + out, problems, repaired, _ = fin(FIXTURE_MD, identity.replace("[!NOTE]", "[!NOTA]")) + t.check( + "[!NOTE] -> [!NOTA] (equal count) -> repaired", out == FIXTURE_MD and not problems and repaired["markers"] == 1 + ) + out, problems, _, _ = fin(FIXTURE_MD, identity.replace("[!NOTE]", "[!note]")) + t.check("[!note] lowercase -> repaired to [!NOTE]", out == FIXTURE_MD and not problems) + _, problems, _, _ = fin(FIXTURE_MD, identity.replace("> [!NOTE]\n", "> NOTE\n")) + t.check("dropped alert marker -> problem", any("markers" in p for p in problems), str(problems)) + out, problems, _, _ = fin( + FIXTURE_MD, + identity.replace("uid: getting-started\nauthor: Nedas", "uid: empezar\nauthor: Pedro").replace( + "title: Getting started", "title: Empezar" + ), + ) + t.check( + "uid/author rewritten -> restored from English, title kept", + "uid: getting-started\nauthor: Nedas" in out and "title: Empezar\n" in out and not problems, + ) + out, problems, _, _ = fin(FIXTURE_MD, identity.replace("title: Getting started", "title: Guía: introducción")) + t.check("title gaining ': ' -> quoted", 'title: "Guía: introducción"\n' in out and not problems, out[:80]) + out, _, _, _ = fin(FIXTURE_MD, identity.replace("title: Getting started", "title: Scripts de C#")) + t.check("title with '#' -> quoted", 'title: "Scripts de C#"\n' in out) + _, problems, _, _ = fin(FIXTURE_MD, identity.replace("description: Intro to TE3\n", "")) + t.check("description dropped from frontmatter -> problem", any("description" in p for p in problems), str(problems)) + _, problems, _, _ = fin(FIXTURE_MD, identity.split("---\n", 2)[2]) + t.check("frontmatter dropped -> problem", any("frontmatter missing" in p for p in problems), str(problems)) + out, problems, _, _ = fin(FIXTURE_MD, "\n" + identity) + t.check("blank line before frontmatter -> tolerated", out == FIXTURE_MD and not problems) + out, problems, _, _ = fin(FIXTURE_MD, identity) + t.check("BOM stripped by translator -> restored", out.startswith(BOM)) + crlf_src = FIXTURE_MD.replace("\n", "\r\n") + out, problems, _, _ = fin(crlf_src, identity) + t.check("LF delivery for a CRLF source -> CRLF restored", out == crlf_src and not problems) + out, problems, _, _ = fin(FIXTURE_MD, identity.replace("\n", "\r\n")) + t.check("CRLF delivery for an LF source -> LF", out == FIXTURE_MD and not problems) + out, problems, _, _ = fin(FIXTURE_MD, identity.rstrip("\n")) + t.check("trailing newline restored", out == FIXTURE_MD and not problems) + long_src = "# T\n\n" + ("The quick brown fox jumps over the lazy dog. " * 8) + "\n" + _, problems, _, _ = fin(long_src, long_src, check_identity=True) + t.check( + "delivered body identical to source (>200 letters) -> problem", + any("identical" in p for p in problems), + str(problems), + ) + _, problems, _, _ = fin(long_src, long_src.replace("quick", "rápido"), check_identity=True) + t.check("translated long body -> no identity problem", not problems, str(problems)) + _, problems, _, _ = fin(FIXTURE_MD, identity, check_identity=True) + t.check("short identical body (<200 letters) -> accepted", not problems, str(problems)) + _, problems, _, _ = fin(FIXTURE_MD, identity.replace("// Assembly references", "// Las referencias"), repair=False) + t.check( + "verify-only mode reports fenced differences instead of repairing", + any("fenced code differs" in p for p in problems), + str(problems), + ) + + t.section("json (_ui-strings.json)") + payload, _, info = prepare_payload("_ui-strings.json", FIXTURE_JSON) + t.check( + "{name} encoded as {{name}} in the payload", + "{{name}}" in payload and "{{count}}" in payload and "{name}" not in payload.replace("{{name}}", ""), + ) + out, problems, _, _ = finalize_translation("_ui-strings.json", info, FIXTURE_JSON, payload) + t.check("json identity round-trip", out == FIXTURE_JSON and not problems, str(problems)) + _, problems, _, _ = finalize_translation( + "_ui-strings.json", info, FIXTURE_JSON, '{\n "greeting": "Hola {{name}}, {{count}} items"\n}\n' + ) + t.check("json key dropped -> problem", any("keys differ" in p for p in problems), str(problems)) + _, problems, _, _ = finalize_translation( + "_ui-strings.json", info, FIXTURE_JSON, payload.replace("{{count}}", "{{recuento}}") + ) + t.check( + "{count} translated to {recuento} -> problem after decode", + any("placeholders" in p for p in problems), + str(problems), + ) + _, problems, _, _ = finalize_translation( + "_ui-strings.json", info, FIXTURE_JSON, payload.replace("{{count}}", "{count}") + ) + t.check("single-brace placeholder returned unencoded -> still accepted", not problems, str(problems)) + out, problems, _, _ = finalize_translation("_ui-strings.json", info, BOM + FIXTURE_JSON, BOM + payload) + t.check("json BOM handling", out == BOM + FIXTURE_JSON and not problems) + _, problems, _, _ = finalize_translation("_ui-strings.json", info, FIXTURE_JSON, "not json") + t.check("unparseable json -> problem", any("does not parse" in p for p in problems)) + + t.section("toc.yml (yaml-names)") + payload, _, info = prepare_payload("toc.yml", FIXTURE_TOC) + names = json.loads(payload) + t.check( + "toc payload holds only names", + set(names.values()) == {"Home", "Getting started", "Install"} + and all(YAML_NAME_KEY_RE.fullmatch(k) for k in names), + ) + out, problems, _, _ = finalize_translation("toc.yml", info, FIXTURE_TOC, payload) + t.check("toc identity round-trip (BOM kept)", out == FIXTURE_TOC and not problems, str(problems)) + crlf_toc = FIXTURE_TOC.replace("\n", "\r\n") + out, problems, _, _ = finalize_translation("toc.yml", info, crlf_toc, payload) + t.check("toc CRLF source round-trip", out == crlf_toc and not problems) + out, problems, _, _ = finalize_translation("toc.yml", info, FIXTURE_TOC, BOM + payload) + t.check("toc payload with BOM -> parsed", out == FIXTURE_TOC and not problems, str(problems)) + translated = dict(names) + key_home = next(k for k, v in names.items() if v == "Home") + translated[key_home] = "Inicio: página" + out, problems, _, _ = finalize_translation("toc.yml", info, FIXTURE_TOC, json.dumps(translated)) + t.check("toc name gaining ': ' -> quoted", '- name: "Inicio: página"\n' in out and not problems, out) + translated[key_home] = "true" + out, _, _, _ = finalize_translation("toc.yml", info, FIXTURE_TOC, json.dumps(translated)) + t.check("toc name 'true' -> quoted", '- name: "true"\n' in out) + translated[key_home] = "- Inicio" + out, _, _, _ = finalize_translation("toc.yml", info, FIXTURE_TOC, json.dumps(translated)) + t.check("toc name starting with '-' -> quoted", '- name: "- Inicio"\n' in out) + translated[key_home] = "Inicio" + _, problems, _, _ = finalize_translation( + "toc.yml", info, FIXTURE_TOC, json.dumps({k: v for k, v in translated.items() if k != key_home}) + ) + t.check("toc payload missing a key -> problem", any("missing" in p for p in problems), str(problems)) + _, problems, _, _ = finalize_translation("toc.yml", info, FIXTURE_TOC, json.dumps({**translated, "n99": "Extra"})) + t.check("toc payload with an unexpected key -> problem", any("unexpected" in p for p in problems), str(problems)) + _, problems, _, _ = finalize_translation("toc.yml", info, FIXTURE_TOC, json.dumps({**translated, "x": "Bad"})) + t.check("toc payload with a malformed key -> problem", any("malformed" in p for p in problems), str(problems)) + _, problems, _, _ = finalize_translation("toc.yml", info, FIXTURE_TOC, json.dumps(["Home"])) + t.check( + "toc payload not a dict -> problem (no exception)", + any("not a JSON object" in p for p in problems), + str(problems), + ) + _, problems, _, _ = finalize_translation( + "toc.yml", info, FIXTURE_TOC, json.dumps({**translated, key_home: "Ini\ncio"}) + ) + t.check("toc name with a line break -> problem", any("line breaks" in p for p in problems), str(problems)) + t.check( + "yaml_scalar keeps a source-identical special value verbatim", + yaml_scalar("C# Scripts", "C# Scripts") == "C# Scripts", + ) + t.check( + "yaml_scalar quotes yes/no/null/on/off", + all(yaml_scalar(v).startswith('"') for v in ("yes", "No", "null", "on", "OFF")), + ) + t.check( + "yaml_scalar keeps valid quoted values", + yaml_scalar('"a: b"') == '"a: b"' and yaml_scalar("'it''s'") == "'it''s'", + ) + t.check( + "yaml_scalar quotes leading indicators", + all(yaml_scalar(v).startswith('"') for v in ("?x", "*x", "&x", "!x", "%x", "@x", "`x", "|x", ">x", "[x", "{x")), + ) + + t.section("html") + payload, _, info = prepare_payload("404.html", FIXTURE_HTML) + out, problems, _, _ = finalize_translation("404.html", info, FIXTURE_HTML, payload) + t.check("html identity round-trip", out == FIXTURE_HTML and not problems) + out, problems, repaired, _ = finalize_translation( + "404.html", info, FIXTURE_HTML, payload.replace("page.html", "pagina.html") + ) + t.check("changed href (equal count) -> repaired", out == FIXTURE_HTML and not problems and repaired["links"] == 1) + _, problems, _, _ = finalize_translation("404.html", info, FIXTURE_HTML, payload.replace("

Text

\n", "Text\n")) + t.check("dropped tag -> problem", any("tag counts" in p for p in problems), str(problems)) + _, problems, _, _ = finalize_translation( + "404.html", info, FIXTURE_HTML, payload.replace("

Text

", '

Text

') + ) + t.check("extra href -> problem", any("href/src" in p for p in problems), str(problems)) + crlf_html = FIXTURE_HTML.replace("\n", "\r\n") + out, problems, _, _ = finalize_translation("404.html", info, crlf_html, payload) + t.check("html CRLF source restored", out == crlf_html and not problems) + + t.section("configuration and client helpers") + t.check( + "normalize_base_url", + { + normalize_base_url(u) + for u in ("https://api.translated.com", "https://api.translated.com/v2", "https://api.translated.com/v2/") + } + == {"https://api.translated.com/v2/"}, + ) + build_config = mini_build_config() + cfg = TranslationConfig(build_config) + t.expect_exit( + "network action without TRANSLATED_ENV -> SystemExit", lambda: cfg.require_network("--submit"), "TRANSLATED_ENV" + ) + t.expect_exit( + "unknown TRANSLATED_ENV -> SystemExit", + lambda: TranslationConfig(build_config, "staging").require_network("--submit"), + "not defined", + ) + prod = TranslationConfig(build_config, "production") + t.expect_exit( + "production without service type -> SystemExit mentioning --probe", + lambda: prod.require_network("--submit"), + "--probe", + ) + prod.require_network("--probe", need_service_type=False) + t.check("--probe is allowed without a service type", True) + t.check( + "TRANSLATED_SERVICE_TYPE override", + TranslationConfig(build_config, "production", "enterprise").service_type == "enterprise", + ) + sandbox = TranslationConfig(build_config, "sandbox") + sandbox_tier = build_config["translation"]["environments"]["sandbox"]["serviceType"] + t.check( + "sandbox environment resolved (service type taken from build-config.json, not hard-coded)", + sandbox.is_sandbox + and bool(sandbox_tier) + and sandbox.service_type == sandbox_tier + and sandbox.base_url == "https://api.sandbox.translated.com/v2/", + f"service type {sandbox.service_type!r}, config {sandbox_tier!r}", + ) + t.check( + "is_sandbox is the environment name, not a URL substring", + not TranslationConfig(build_config, "production", "x").is_sandbox, + ) + msg, parsed = describe_api_error( + '{"error":true,"code":"INVALID_PARAMS","message":"Bad","id_transaction":"abc","params":[{"field":"service_type","reason":"is not valid"}]}' + ) + t.check( + "api_error parsed into a readable message", + msg == "INVALID_PARAMS; Bad; service_type: is not valid; transaction abc" + and parsed["code"] == "INVALID_PARAMS", + ) + t.expect_exit("missing translatedLocale fails fast", lambda: require_locales(["es", "xx"], {"es": "es-ES"}), "xx") + t.expect_exit( + "sandbox without --limit/--file refused", lambda: check_sandbox_filter(sandbox, PlanOptions(), False), "--limit" + ) + check_sandbox_filter(sandbox, PlanOptions(limit=1), False) + check_sandbox_filter(sandbox, PlanOptions(), True) + t.check("sandbox with --limit or --allow-unbounded passes", True) + parser = build_parser() + t.expect_exit( + "--repair-existing without --baseline refused", + lambda: check_option_combinations(parser.parse_args(["--plan", "--repair-existing"])), + "--baseline", + ) + check_option_combinations(parser.parse_args(["--baseline", "--repair-existing", "--overwrite-baseline"])) + t.check("--baseline --repair-existing accepted", True) + help_text = " ".join(parser.format_help().split()) + t.check("--baseline-ref help names no provider", "previous translation provider" in help_text) + + t.section("git ref resolution (monkeypatched git)") + + def git_ok(cmd: list[str]) -> subprocess.CompletedProcess[bytes]: + return subprocess.CompletedProcess(cmd, 0, b"deadbeef0000\n", b"") + + def git_missing(cmd: list[str]) -> subprocess.CompletedProcess[bytes]: + return subprocess.CompletedProcess(cmd, 128, b"", b"fatal: Needed a single revision\n") + + seen: list[list[str]] = [] + + def git_spy(cmd: list[str]) -> subprocess.CompletedProcess[bytes]: + seen.append(cmd) + return git_ok(cmd) + + t.check("resolve_git_ref returns the sha", resolve_git_ref("d376766", git_spy) == "deadbeef0000") + t.check( + "resolve_git_ref uses rev-parse --verify --quiet --end-of-options ^{commit}", + seen == [["git", "rev-parse", "--verify", "--quiet", "--end-of-options", "d376766^{commit}"]], + str(seen), + ) + t.expect_exit( + "unknown --baseline-ref -> SystemExit", + lambda: resolve_git_ref("bogus", git_missing), + "is not a commit in this clone", + ) + if shutil.which("git"): + t.expect_exit( + "unknown --baseline-ref against real git -> SystemExit", + lambda: resolve_git_ref("no-such-ref-for-the-self-test"), + "is not a commit in this clone", + ) + + +# --- fake client end-to-end --------------------------------------------------- + + +class FakeTranslatedClient: + """In-memory stand-in for TranslatedClient with the same surface.""" + + def __init__(self) -> None: + self.orders: list[dict[str, Any]] = [] + self.jobs: dict[int, dict[str, Any]] = {} + self.next_id = 100 + self.translate_calls = 0 + self.raise_on_translate_call: int | None = None + self.drop_receipts: set[str] = set() + self.states: dict[str, str] = {} + self.deliveries: dict[str, str] = {} + self.cancelled: list[int] = [] + self.cancel_calls: list[list[int]] = [] + self.uncancellable: set[str] = set() # id_content past the cancellable states: the whole call is refused + self.status_calls: list[list[int]] = [] + self.raise_on_status = False + self.delivery_calls: list[list[int]] = [] + self.fail_delivery: dict[str, str] = {} # id_content -> error_message answered by /sandbox/delivery + self.delivery_error: str | None = None # when set, sandbox_deliver raises TranslatedApiError(...) + + def translate(self, orders: list[dict[str, Any]]) -> list[dict[str, Any]]: + self.translate_calls += 1 + if self.raise_on_translate_call == self.translate_calls: + raise TranslatedApiError( + "POST translate failed with 400: INVALID_PARAMS; boom", + 400, + { + "code": "INVALID_PARAMS", + "message": "boom", + "id_transaction": "t1", + "params": [{"field": "content", "reason": "x"}], + }, + ) + receipts: list[dict[str, Any]] = [] + for order in orders: + self.orders.append(order) + id_content = str(order["id_content"]) + if id_content in self.drop_receipts: + continue + self.next_id += 1 + self.jobs[self.next_id] = { + "id_content": id_content, + "content": order["content"], + "target_language": order["target_languages"][0], + } + receipts.append( + { + "id": self.next_id, + "id_content": id_content, + "target_language": order["target_languages"][0], + "status": "in progress", + } + ) + return receipts + + def status_many(self, id_requests: list[int]) -> list[dict[str, Any]]: + self.status_calls.append(list(id_requests)) + if self.raise_on_status: + raise TranslatedApiError("POST status failed after 4 attempts (TimeoutError: timed out)") + items: list[dict[str, Any]] = [] + for job_id in id_requests: + job = self.jobs.get(job_id) + if job is None: + continue + state = self.states.get(job["id_content"], "delivered") + item: dict[str, Any] = { + "id": job_id, + "id_content": job["id_content"], + "status": state, + "translated_content": None, + } + if state in DELIVERED_STATUSES: + item["translated_content"] = self.deliveries.get(job["id_content"], job["content"]) + item.update({"words": 10, "equivalent_words": 8, "fee_words": 0}) + items.append(item) + return items + + def cancel(self, id_requests: list[int]) -> dict[str, Any]: + self.cancel_calls.append(list(id_requests)) + late = [i for i in id_requests if self.jobs.get(i, {}).get("id_content") in self.uncancellable] + if late: + raise TranslatedApiError( + f"POST translate/cancel failed with 422: NOT_CANCELLABLE; request {late[0]} is not cancellable", + 422, + {"code": "NOT_CANCELLABLE"}, + ) + self.cancelled.extend(id_requests) + return {"id_request": id_requests, "uuid_key": [], "status": "cancelled", "message": "ok"} + + def sandbox_deliver(self, id_requests: list[int]) -> Any: + # Answers in the flat request shape; only "completed" requests move on to "delivered". + self.delivery_calls.append(list(id_requests)) + if self.delivery_error is not None: + raise TranslatedApiError(self.delivery_error) + items: list[dict[str, Any]] = [] + for job_id in id_requests: + job = self.jobs.get(job_id) + error: str | None + if job is None: + error = "request not found" + else: + id_content = job["id_content"] + error = self.fail_delivery.get(id_content) + if error is None and self.states.get(id_content) != "completed": + error = f"request is {self.states.get(id_content, 'delivered')}, not completed" + if error is None: + self.states[id_content] = "delivered" + items.append( + { + "id_request": job_id, + "delivery_status": "failed" if error else "succeeded", + "should_be_notified": False, + "has_notification_channel": False, + "error_message": error, + "status_code": 400 if error else None, + } + ) + return items + + def service_type_names(self) -> list[dict[str, Any]]: + return [ + {"name": "economy", "human_enabled": False, "machine_enabled": True}, + {"name": "premium", "human_enabled": True, "machine_enabled": False}, + ] + + def languages(self) -> list[dict[str, Any]]: + return [ + {"key": "en-US", "value": "English"}, + {"key": "es-ES", "value": "Spanish"}, + {"key": "zh-CN", "value": "Chinese"}, + ] + + +MINI_FILES: dict[str, str] = { + "index.md": BOM + "---\ntitle: Home\n---\n# Home\n\nShort intro with `code` and [link](guide.md).\n", + "guide.md": "# Guide\n\nA short guide.\n\n> [!NOTE]\n> Note text.\n", + "toc.yml": "- name: Home\n href: index.md\n- name: Guide\n href: guide.md\n", + "_ui-strings.json": '{\n "greeting": "Hello {name}",\n "bye": "Bye"\n}\n', + "whats-new/1-0-0.html": "

Release notes

\n", +} +# Extra source used by the baseline checks only (not in MINI_FILES so the order counts above stay put). +CODE_MD = "# Code\n\n```csharp\n// Assembly references go first\nvar x = 1;\n```\n" +CODE_MD_TRANSLATED_FENCE = CODE_MD.replace("// Assembly references go first", "// Las referencias van primero") +OLD_INDEX_MD = BOM + "---\ntitle: Home\n---\n# Home\n\nOlder intro.\n" +INLINE_MD = "# Inline\n\nSet `enabled` to true.\n" +INLINE_MD_EXTRA_SPAN = INLINE_MD.replace("Set `enabled` to true.", "Establece `enabled` en `true`.") +INLINE_WARNING = "inline code spans 1 -> 2 (missing [], added ['`true`'])" + + +def mini_build_config(batch_size: int = 200, max_files: int = 3) -> dict[str, Any]: + return { + "sharedDirectories": {"directories": ["assets", "api"]}, + "translation": { + "environments": { + "sandbox": {"baseUrl": "https://api.sandbox.translated.com", "serviceType": "economy"}, + "production": {"baseUrl": "https://api.translated.com/v2/", "serviceType": None}, + }, + "sourceLocale": "en-US", + "sources": ["**/*.md", "404.html", "toc.yml", "_ui-strings.json"], + "passthrough": ["whats-new/**/*.html"], + "batchSize": batch_size, + "sandboxLimits": {"maxFilesPerRun": max_files, "maxCharsPerRun": 100000}, + "instructions": "Keep structure.", + }, + } + + +@contextmanager +def mini_repo() -> Iterator[Path]: + tmp = Path(tempfile.mkdtemp(prefix="translate-selftest-")) + cwd = os.getcwd() + for rel, text in MINI_FILES.items(): + write_text(tmp / CONTENT_DIR / rel, text) + (tmp / LOCALIZED_DIR / "es" / "content").mkdir(parents=True) + os.chdir(tmp) + try: + yield tmp + finally: + os.chdir(cwd) + shutil.rmtree(tmp, ignore_errors=True) + + +@dataclass +class Harness: + config: TranslationConfig + client: FakeTranslatedClient = field(default_factory=FakeTranslatedClient) + report: RunReport = field(default_factory=RunReport) + locales: dict[str, str] = field(default_factory=lambda: {"es": "es-ES"}) + + @property + def sources(self) -> dict[str, str]: + return get_scoped_sources(self.config) + + @property + def passthrough(self) -> dict[str, str]: + return get_passthrough_sources(self.config, self.sources) + + def submit(self, options: PlanOptions | None = None, allow_unbounded: bool = True) -> None: + cmd_submit( + self.config, + self.client, + ["es"], + self.sources, + self.passthrough, + self.locales, + options or PlanOptions(), + allow_unbounded, + self.report, + ) + + def poll( + self, + lenient: bool = False, + config: TranslationConfig | None = None, + wait: float = 0, + sleep: Callable[[float], None] | None = None, + ) -> None: + cmd_poll( + config or self.config, + self.client, + ["es"], + self.sources, + wait, + lenient, + self.report, + sleep=sleep or (lambda _s: None), + ) + + def plan(self, options: PlanOptions | None = None) -> LanguagePlan: + return build_plan("es", self.sources, self.passthrough, load_status("es"), options or PlanOptions()) + + +def prod_harness(batch_size: int = 200) -> Harness: + return Harness(TranslationConfig(mini_build_config(batch_size), "production", "mt-test")) + + +def selftest_fake_client(t: SelfTest) -> None: + t.section("fake client end-to-end: submit -> poll (identity deliveries)") + with mini_repo(): + h = prod_harness() + h.submit() + status = load_status("es") + pending = status["pendingJobs"] + t.check( + "4 orders sent, 4 pending jobs recorded", + len(h.client.orders) == 4 and len(pending) == 4, + str(sorted(pending)), + ) + t.check( + "pending jobs carry environment, jobId, sourceHash", + all( + j.get("environment") == "production" and j.get("jobId") and j.get("sourceHash") == h.sources[rel] + for rel, j in pending.items() + ), + ) + json_order = next(o for o in h.client.orders if o["id_content"].endswith("_ui-strings.json")) + t.check("json order content has encoded placeholders", "{{name}}" in json_order["content"]) + toc_order = next(o for o in h.client.orders if o["id_content"].endswith("toc.yml")) + t.check( + "toc order is a JSON map of names without hrefs", + "index.md" not in toc_order["content"] + and "Home" in toc_order["content"] + and toc_order["content_type"] == "application/json", + ) + t.check( + "orders carry service type, locale and instructions", + all( + o["service_type"] == "mt-test" and o["target_languages"] == ["es-ES"] and o["context"]["instructions"] + for o in h.client.orders + ), + ) + t.check( + "passthrough copied and recorded 'copied'", + target_file("es", "whats-new/1-0-0.html").read_bytes() + == (CONTENT_DIR / "whats-new/1-0-0.html").read_bytes() + and status["files"]["whats-new/1-0-0.html"]["status"] == STATUS_COPIED + and h.report.lang("es")["copied"] == ["whats-new/1-0-0.html"], + ) + h.poll() + status = load_status("es") + t.check( + "one /status call for all pending ids", + len(h.client.status_calls) == 1 and len(h.client.status_calls[0]) == 4, + str(h.client.status_calls), + ) + t.check( + "all 4 files translated at the current source hash, nothing pending", + not status["pendingJobs"] + and all( + status["files"][rel] == {"sourceHash": h.sources[rel], "status": STATUS_TRANSLATED} for rel in h.sources + ), + ) + t.check( + "written translations are byte-identical to the sources", + all(target_file("es", rel).read_bytes() == (CONTENT_DIR / rel).read_bytes() for rel in h.sources), + ) + r = h.report.lang("es") + t.check( + "report: 4 delivered, billed words aggregated", + len(r["delivered"]) == 4 and r["words"] == 40 and r["equivalent_words"] == 32, + ) + t.check( + "summary has copied/pinned/pendingJobs/failures", + {"copied", "pinned", "pendingJobs", "failures", "translated"} <= set(status["summary"]) + and status["summary"]["copied"] == 1, + ) + md = h.report.to_markdown(h.config) + t.check("report header shows environment and service type", "(production, service type `mt-test`)" in md) + h.submit() + t.check("second submit is a no-op", len(h.client.orders) == 4) + h.report = RunReport() + h.submit(PlanOptions(force=True, file_filters=["guide.md"])) + t.check( + "--force --file resubmits a current file", + len(h.client.orders) == 5 and h.client.orders[-1]["id_content"] == "es/guide.md", + ) + + t.section("fake client: broken delivery gates the plan until --retry-failed") + with mini_repo(): + h = prod_harness() + h.client.deliveries["es/guide.md"] = MINI_FILES["guide.md"].replace("# Guide\n\n", "") + h.submit() + h.poll() + status = load_status("es") + failure = status["failures"].get("guide.md", {}) + t.check( + "failure recorded with sourceHash and reason", + failure.get("sourceHash") == h.sources["guide.md"] and "headings" in failure.get("error", ""), + str(failure), + ) + t.check("file not written", not target_file("es", "guide.md").exists()) + t.check("other files delivered", status["files"]["index.md"]["status"] == STATUS_TRANSLATED) + plan = h.plan() + t.check( + "plan skips the failed file at this hash", + [rel for rel, _ in plan.skipped_failed] == ["guide.md"] + and "guide.md" not in {rel for rel, _ in plan.to_submit}, + ) + t.check( + "--retry-failed re-plans it", + "guide.md" in {rel for rel, _ in h.plan(PlanOptions(retry_failed=True)).to_submit}, + ) + t.check("--force re-plans it", "guide.md" in {rel for rel, _ in h.plan(PlanOptions(force=True)).to_submit}) + write_text(CONTENT_DIR / "guide.md", MINI_FILES["guide.md"] + "\nMore.\n") + t.check( + "English change re-plans it (failure hash differs)", "guide.md" in {rel for rel, _ in h.plan().to_submit} + ) + h.report = RunReport() + h.submit(PlanOptions(retry_failed=True)) + t.check( + "resubmission clears the failure", + "guide.md" not in load_status("es")["failures"] and "guide.md" in load_status("es")["pendingJobs"], + ) + + t.section("fake client: lenient writes usable Markdown despite problems, never structured payloads") + with mini_repo(): + h = prod_harness() + h.client.deliveries["es/guide.md"] = MINI_FILES["guide.md"].replace("# Guide\n\n", "") + h.client.deliveries["es/_ui-strings.json"] = '{\n "greeting": "Hola {{name}}"\n}\n' + h.client.deliveries["es/toc.yml"] = json.dumps({"n0": "Inicio"}) + h.submit() + h.poll(lenient=True) + status = load_status("es") + t.check( + "--lenient writes the Markdown file (heading mismatch) and marks it translated", + target_file("es", "guide.md").exists() + and status["files"]["guide.md"]["status"] == STATUS_TRANSLATED + and "guide.md" not in status["failures"], + ) + t.check( + "--lenient does not write a JSON delivery with a dropped key", + not target_file("es", "_ui-strings.json").exists() + and "keys differ" in status["failures"].get("_ui-strings.json", {}).get("error", ""), + str(status["failures"]), + ) + t.check( + "--lenient does not write a toc delivery with a missing key", + not target_file("es", "toc.yml").exists() + and "missing" in status["failures"].get("toc.yml", {}).get("error", ""), + str(status["failures"]), + ) + t.check("pending jobs of the rejected deliveries are dropped", not status["pendingJobs"]) + + t.section("fake client: pinned file delivered after submission") + with mini_repo(): + h = prod_harness() + h.submit() + status = load_status("es") + status["files"]["guide.md"] = {"sourceHash": "", "status": STATUS_UNTRANSLATED, "manual": True} + save_status("es", status) + h.poll() + status = load_status("es") + t.check( + "pinned file: delivery discarded, pending job dropped, reported, no failure entry", + not target_file("es", "guide.md").exists() + and "guide.md" not in status["pendingJobs"] + and ("guide.md", "pinned; delivery discarded") in h.report.lang("es")["failed"] + and "guide.md" not in status["failures"] + and status["files"]["guide.md"].get("manual") is True, + str(status), + ) + t.check( + "other deliveries in the same round written", status["files"]["index.md"]["status"] == STATUS_TRANSLATED + ) + + t.section("fake client: poll robustness") + with mini_repo(): + h = prod_harness() + h.submit() + status = load_status("es") + status["pendingJobs"]["guide.md"]["jobId"] = "not-a-number" + del status["pendingJobs"]["index.md"]["jobId"] + save_status("es", status) + h.poll() + status = load_status("es") + t.check( + "non-numeric/missing jobId -> 'invalid jobId' failures, entries dropped, round continues", + status["failures"].get("guide.md", {}).get("error") == "invalid jobId in status file" + and status["failures"].get("index.md", {}).get("error") == "invalid jobId in status file" + and not status["pendingJobs"] + and status["files"]["toc.yml"]["status"] == STATUS_TRANSLATED + and len(h.client.status_calls) == 1 + and len(h.client.status_calls[0]) == 2, + str(status["failures"]), + ) + t.check("a successful status round does not flag the run", not h.report.poll_failed and not h.report.failed) + with mini_repo(): + h = prod_harness() + h.submit() + h.client.raise_on_status = True + h.poll() + status = load_status("es") + t.check( + "every status call failed -> run flagged (exit 1), pending jobs kept, note in the report", + h.report.poll_failed + and h.report.failed + and len(status["pendingJobs"]) == 4 + and "every POST status call failed" in h.report.to_markdown(h.config), + h.report.to_markdown(h.config), + ) + + t.section("fake client: API states") + with mini_repo(): + h = prod_harness() + h.client.states["es/guide.md"] = "cancelled" + h.client.states["es/index.md"] = "completed" + h.client.states["es/toc.yml"] = "weird-state" + h.client.states["es/_ui-strings.json"] = "invoiced" + h.submit() + h.poll() + status = load_status("es") + t.check( + "status 'cancelled' -> failure with raw api status", + status["failures"].get("guide.md", {}).get("apiStatus") == "cancelled" + and "cancelled" in status["failures"]["guide.md"]["error"], + ) + t.check("status 'completed' is still waiting", "index.md" in status["pendingJobs"]) + t.check("status 'completed' in production never calls /sandbox/delivery", not h.client.delivery_calls) + t.check("unknown status is treated as waiting", "toc.yml" in status["pendingJobs"]) + t.check( + "status 'invoiced' counts as delivered", + status["files"].get("_ui-strings.json", {}).get("status") == STATUS_TRANSLATED, + ) + t.check("report lists still-pending jobs", set(h.report.lang("es")["still_pending"]) == {"index.md", "toc.yml"}) + + t.section("fake client: timeout and malformed submittedAt") + with mini_repo(): + h = prod_harness() + h.client.states["es/guide.md"] = "in progress" + h.client.states["es/index.md"] = "in progress" + h.submit() + status = load_status("es") + old = (datetime.now(UTC) - timedelta(days=8)).strftime(TIMESTAMP_FORMAT) + status["pendingJobs"]["guide.md"]["submittedAt"] = old + status["pendingJobs"]["index.md"]["submittedAt"] = "not-a-date" + save_status("es", status) + h.poll() + status = load_status("es") + t.check( + "8-day-old in-progress job -> timed out failure", + "not delivered within" in status["failures"].get("guide.md", {}).get("error", ""), + str(status["failures"]), + ) + t.check( + "malformed submittedAt -> treated as now, still pending", + "index.md" in status["pendingJobs"] and status["pendingJobs"]["index.md"]["submittedAt"] != "not-a-date", + ) + + t.section("fake client: source changed while in flight -> superseded") + with mini_repo(): + h = prod_harness() + h.client.states["es/guide.md"] = "in progress" + h.submit() + old_id = load_status("es")["pendingJobs"]["guide.md"]["jobId"] + write_text(CONTENT_DIR / "guide.md", MINI_FILES["guide.md"] + "\nChanged.\n") + h.report = RunReport() + h.submit() + status = load_status("es") + t.check( + "resubmitted with a new job id", + status["pendingJobs"]["guide.md"]["jobId"] != old_id + and old_id not in {j["jobId"] for j in status["pendingJobs"].values()}, + ) + t.check( + "old job cancelled and reported as superseded", + h.client.cancelled == [old_id] and h.report.lang("es")["superseded"] == ["guide.md"], + ) + t.check("in-flight job with unchanged source is not resubmitted", h.client.translate_calls == 2) + + t.section("fake client: orphans, unscoped, pinned, filters") + with mini_repo(): + h = prod_harness() + write_text(target_file("es", "old.md"), "# Old\n") + write_text(CONTENT_DIR / "other/thing.yaml", "a: 1\n") + status = load_status("es") + status["files"]["old.md"] = {"sourceHash": "sha256:x", "status": STATUS_TRANSLATED} + status["files"]["other/thing.yaml"] = {"sourceHash": "sha256:y", "status": STATUS_TRANSLATED} + status["files"]["guide.md"] = {"sourceHash": "", "status": STATUS_UNTRANSLATED, "manual": True} + save_status("es", status) + plan = h.plan() + t.check( + "plan reports pinned, orphan and unscoped entries", + plan.pinned == ["guide.md"] and plan.orphans == ["old.md"] and plan.unscoped == ["other/thing.yaml"], + ) + plan = h.plan(PlanOptions(file_filters=["index.md"])) + t.check( + "--file filter plans only the matched file and no orphans", + [rel for rel, _ in plan.to_submit] == ["index.md"] and not plan.orphans, + ) + t.check("--limit is applied at submit time", len(h.plan(PlanOptions(limit=1)).to_submit) == 3) + first = h.plan().to_submit[0][0] + first_chars = len(prepare_payload(first, read_text(CONTENT_DIR / first))[0]) + out = captured(lambda: print_plan("es", h.plan(), load_status("es"), limit=1)) + t.check( + "--plan honours --limit: one submit line, rest counted as beyond --limit, chars match", + out.count("\n submit ") == 1 + and f" submit missing {first}" in out + and "2 more beyond --limit 1" in out + and f"~{first_chars:,} characters would be submitted" in out, + out, + ) + h.submit(PlanOptions(file_filters=["index.md"])) + t.check("orphan kept when a filter is active", target_file("es", "old.md").exists()) + h.submit(PlanOptions(limit=1)) + t.check( + "orphan kept when --limit is active; limit honoured", + target_file("es", "old.md").exists() and len(h.client.orders) == 2, + ) + h.report = RunReport() + h.submit() + status = load_status("es") + t.check( + "orphan deleted and reported without filters", + not target_file("es", "old.md").exists() + and "old.md" not in status["files"] + and h.report.lang("es")["removed"] == ["old.md"], + ) + t.check( + "unscoped entry dropped, pinned file untouched and never sent", + "other/thing.yaml" not in status["files"] + and status["files"]["guide.md"].get("manual") is True + and not any(o["id_content"] == "es/guide.md" for o in h.client.orders), + ) + + t.section("fake client: receipts and batch failures") + with mini_repo(): + h = prod_harness() + h.client.drop_receipts.add("es/guide.md") + h.submit() + status = load_status("es") + t.check( + "missing receipt -> 'no job returned' failure, others recorded", + "no job returned" in status["failures"].get("guide.md", {}).get("error", "") + and len(status["pendingJobs"]) == 3, + ) + with mini_repo(): + h = prod_harness(batch_size=1) + h.client.raise_on_translate_call = 2 + h.submit() + status = load_status("es") + failed = [rel for rel, f in status["failures"].items() if "POST translate failed" in f["error"]] + t.check( + "batch 2 failure recorded with api error details; batches 1, 3, 4 persisted", + len(failed) == 1 + and status["failures"][failed[0]].get("apiError", {}).get("code") == "INVALID_PARAMS" + and len(status["pendingJobs"]) == 3, + str(status["failures"]), + ) + t.check("run flagged as failed (exit 1)", h.report.batch_failures == 1) + + t.section("fake client: environments and sandbox caps") + with mini_repo(): + sandbox = TranslationConfig(mini_build_config(max_files=3), "sandbox") + h = Harness(sandbox) + t.expect_exit( + "sandbox refuses 4 files over the 3-file cap", + lambda: h.submit(allow_unbounded=False), + "Refusing to submit 4 file(s)", + ) + t.check( + "nothing sent and no status file written by the refused run", + not h.client.orders and not status_path("es").exists(), + ) + h.submit(PlanOptions(limit=2), allow_unbounded=False) + t.check("sandbox run within the cap passes", len(h.client.orders) == 2) + h.submit(allow_unbounded=True) + t.check("--allow-unbounded lifts the cap", len(h.client.orders) == 4) + t.check( + "jobs recorded with environment 'sandbox'", + all(j["environment"] == "sandbox" for j in load_status("es")["pendingJobs"].values()), + ) + prod = TranslationConfig(mini_build_config(), "production", "mt-test") + h.poll(config=prod) + status = load_status("es") + dropped = h.report.lang("es")["dropped"] + t.check( + "polling in production drops sandbox jobs with a report line", + not status["pendingJobs"] + and len(dropped) == 4 + and all(why == "dropped: submitted in sandbox" for _, why in dropped), + str(dropped), + ) + t.check("dropped jobs made no /status call", not h.client.status_calls) + + t.section("fake client: inline-code count drift is written with a warning") + with mini_repo(): + h = prod_harness() + h.submit() + drifted = MINI_FILES["guide.md"].replace("Note text.", "Texto de `nota`.") + h.client.deliveries["es/guide.md"] = drifted + out = captured(h.poll) + status = load_status("es") + r = h.report.lang("es") + warning = "inline code spans 0 -> 1 (missing [], added ['`nota`'])" + t.check( + "delivery with an extra backtick pair is written as delivered and recorded translated", + read_text(target_file("es", "guide.md")) == drifted + and status["files"]["guide.md"] == {"sourceHash": h.sources["guide.md"], "status": STATUS_TRANSLATED} + and "guide.md" not in status["failures"] + and "guide.md" in r["delivered"] + and not r["failed"], + out, + ) + t.check( + "warning printed and collected per language in the report", + f"es: written with warnings ({warning}): guide.md" in out and r["warnings"] == [("guide.md", warning)], + out + str(r["warnings"]), + ) + md = h.report.to_markdown(h.config) + t.check( + "report has a Warnings column and a 'written with warnings' section", + "| Warnings |" in md + and "| es | 4 | 4 | 0 | 1 | 1 | 0 | 0 | 0 | 0 | 0 |" in md + and "### es: written with warnings" in md + and f"- `guide.md` — {warning}" in md, + md, + ) + + t.section("baseline and dump-orders") + with mini_repo() as tmp: + cfg = TranslationConfig(mini_build_config()) + write_text(CONTENT_DIR / "code.md", CODE_MD) + write_text(CONTENT_DIR / "inline.md", INLINE_MD) + sources = get_scoped_sources(cfg) + passthrough = get_passthrough_sources(cfg, sources) + write_text(target_file("es", "index.md"), MINI_FILES["index.md"].replace("title: Home", "title: Inicio")) + write_text(target_file("es", "guide.md"), MINI_FILES["guide.md"].replace("# Guide\n\n", "")) + write_text(target_file("es", "toc.yml"), MINI_FILES["toc.yml"].replace("Home", "Inicio")) + write_text(target_file("es", "code.md"), CODE_MD_TRANSLATED_FENCE) + write_text(target_file("es", "inline.md"), INLINE_MD_EXTRA_SPAN) + status = load_status("es") + status["files"]["other/thing.yaml"] = {"sourceHash": "sha256:y", "status": STATUS_TRANSLATED} + status["files"]["whats-new/1-0-0.html"] = {"sourceHash": "sha256:z", "status": STATUS_COPIED} + save_status("es", status) + out = captured(lambda: cmd_baseline(["es"], sources, passthrough, None, overwrite=False)) + status = load_status("es") + t.check( + "baseline: valid translation marked translated at the current hash", + status["files"]["index.md"] == {"sourceHash": sources["index.md"], "status": STATUS_TRANSLATED}, + ) + t.check( + "baseline: toc with equal names marked translated", + status["files"]["toc.yml"]["status"] == STATUS_TRANSLATED, + ) + t.check( + "baseline: structurally stale translation (heading mismatch) marked untranslated", + status["files"]["guide.md"] == {"sourceHash": "", "status": STATUS_UNTRANSLATED} + and "outdated guide.md: headings 1 -> 0" in out, + out, + ) + t.check( + "baseline: missing translation marked untranslated", + status["files"]["_ui-strings.json"]["status"] == STATUS_UNTRANSLATED, + ) + t.check( + "baseline: unscoped dropped, passthrough untouched", + "other/thing.yaml" not in status["files"] + and status["files"]["whats-new/1-0-0.html"]["sourceHash"] == "sha256:z", + ) + t.check( + "baseline: repairable translation (translated fence) is 'translated', file unchanged, 'would repair' shown", + status["files"]["code.md"] == {"sourceHash": sources["code.md"], "status": STATUS_TRANSLATED} + and read_text(target_file("es", "code.md")) == CODE_MD_TRANSLATED_FENCE + and "would repair code.md: 1 fences, 0 inline code, 0 markers, 0 links" in out + and "es: would repair 1 file(s): 1 fences, 0 inline code, 0 markers, 0 links (pass --repair-existing" in out + and "1 would repair" in out, + out, + ) + t.check( + "baseline: inline-code count drift is accepted as translated with a warning, file unchanged", + status["files"]["inline.md"] == {"sourceHash": sources["inline.md"], "status": STATUS_TRANSLATED} + and read_text(target_file("es", "inline.md")) == INLINE_MD_EXTRA_SPAN + and f"warning inline.md: {INLINE_WARNING}" in out + and "1 accepted with inline-code warnings" in out, + out, + ) + t.expect_exit( + "baseline refuses to overwrite without --overwrite-baseline", + lambda: cmd_baseline(["es"], sources, passthrough, None, overwrite=False), + "--overwrite-baseline", + ) + out = captured(lambda: cmd_baseline(["es"], sources, passthrough, None, overwrite=True, repair_existing=True)) + status = load_status("es") + t.check( + "baseline: --overwrite-baseline rewrites", + status["files"]["index.md"]["status"] == STATUS_TRANSLATED, + ) + t.check( + "baseline --repair-existing: fence body rewritten from English, recorded translated, 'repaired' shown", + read_text(target_file("es", "code.md")) == CODE_MD + and status["files"]["code.md"] == {"sourceHash": sources["code.md"], "status": STATUS_TRANSLATED} + and "repaired code.md: 1 fences, 0 inline code, 0 markers, 0 links" in out + and "es: repaired 1 file(s): 1 fences, 0 inline code, 0 markers, 0 links" in out + and "would repair" not in out, + out, + ) + t.check( + "baseline --repair-existing: heading mismatch still untranslated, file untouched", + status["files"]["guide.md"] == {"sourceHash": "", "status": STATUS_UNTRANSLATED} + and read_text(target_file("es", "guide.md")) == MINI_FILES["guide.md"].replace("# Guide\n\n", ""), + ) + t.check( + "baseline --repair-existing: inline-code drift still accepted, nothing rewritten", + status["files"]["inline.md"]["status"] == STATUS_TRANSLATED + and read_text(target_file("es", "inline.md")) == INLINE_MD_EXTRA_SPAN, + ) + crlf_target = target_file("es", "code.md") + write_text(crlf_target, CODE_MD_TRANSLATED_FENCE.replace("\n", "\r\n")) + cmd_baseline(["es"], sources, passthrough, None, overwrite=True, repair_existing=True) + t.check( + "baseline --repair-existing: written like a delivery (source line endings, no BOM added)", + read_text(crlf_target) == CODE_MD, + ) + + def fake_git(cmd: list[str]) -> subprocess.CompletedProcess[bytes]: + if cmd[1] == "rev-parse": + return subprocess.CompletedProcess(cmd, 0, b"0123abcd\n", b"") + if cmd[1] == "show" and cmd[2].endswith(":content/index.md"): + return subprocess.CompletedProcess(cmd, 0, OLD_INDEX_MD.encode("utf-8"), b"") + return subprocess.CompletedProcess(cmd, 128, b"", b"fatal: path does not exist\n") + + out = captured(lambda: cmd_baseline(["es"], sources, passthrough, "old", overwrite=True, run=fake_git)) + status = load_status("es") + t.check( + "baseline --baseline-ref: resolved sha printed, changed file hashed at the ref, new file noted", + "baseline ref old -> 0123abcd" in out + and status["files"]["index.md"]["sourceHash"] == hash_bytes(OLD_INDEX_MD.encode("utf-8")) + and status["files"]["index.md"]["sourceHash"] != sources["index.md"] + and "outdated index.md: English changed since old" in out + and "new toc.yml: not in content/ at old; baselined at the current hash" in out + and status["files"]["toc.yml"]["sourceHash"] == sources["toc.yml"], + out, + ) + t.expect_exit( + "baseline with an unknown --baseline-ref exits before touching anything", + lambda: cmd_baseline( + ["es"], + sources, + passthrough, + "bogus", + overwrite=True, + run=lambda cmd: subprocess.CompletedProcess(cmd, 128, b"", b""), + ), + "--baseline-ref bogus is not a commit in this clone", + ) + cmd_baseline(["es"], sources, passthrough, None, overwrite=True) + prod = TranslationConfig(mini_build_config(), "production", "mt-test") + before = status_path("es").read_bytes() + out_dir = tmp / "orders" + cmd_dump_orders(prod, out_dir, ["es"], sources, passthrough, {"es": "es-ES"}, PlanOptions(), False) + summary = json.loads((out_dir / "summary.json").read_text(encoding="utf-8")) + t.check( + "dump-orders writes one file per order plus summary.json", + (out_dir / "es" / "guide.md.json").exists() + and (out_dir / "es" / "_ui-strings.json.json").exists() + and summary["languages"]["es"]["orders"] == 2 + and summary["environment"] == "production", + ) + t.check("dump-orders leaves the status file untouched", status_path("es").read_bytes() == before) + dumped = json.loads((out_dir / "es" / "guide.md.json").read_text(encoding="utf-8")) + t.check( + "dumped order has the /translate shape", + set(dumped) + == { + "id_content", + "content", + "content_type", + "source_language", + "target_languages", + "service_type", + "context", + }, + ) + + t.section("probe against the fake client") + with mini_repo(): + prod = TranslationConfig(mini_build_config(), "production", "premium") + client = FakeTranslatedClient() + t.check( + "probe passes for known locales and service type", cmd_probe(prod, client, ["es"], {"es": "es-ES"}) == 0 + ) + t.check("probe fails for an unknown locale", cmd_probe(prod, client, ["es"], {"es": "es-MX"}) == 1) + t.check( + "probe fails for an unknown service type", + cmd_probe(TranslationConfig(mini_build_config(), "production", "gold"), client, ["es"], {"es": "es-ES"}) + == 1, + ) + + +def sandbox_harness() -> Harness: + return Harness(TranslationConfig(mini_build_config(), "sandbox")) + + +def selftest_sandbox_delivery(t: SelfTest) -> None: + t.section("sandbox delivery: response parsing, candidate selection, no retry") + job_shape = [ + { + "id_job": 7, + "delivery_status": "failed", + "requests": [ + {"id_request": 1, "delivery_status": "succeeded", "error_message": None}, + {"id_request": 2, "delivery_status": "failed", "error_message": "callback refused"}, + ], + "error_message": "partial", + "error_body": None, + }, + {"id_job": 8, "delivery_status": "failed", "requests": [], "error_message": None, "error_body": "boom"}, + {"id_job": 9, "delivery_status": "succeeded", "requests": [], "error_message": None, "error_body": None}, + ] + t.check( + "delivery_output (job shape): nested request failure and request-less job failure reported", + delivery_failures(job_shape) == [("request 2", "callback refused"), ("job 8", "boom")], + str(delivery_failures(job_shape)), + ) + flat_shape = [ + {"id_request": 3, "delivery_status": "succeeded", "error_message": None, "status_code": None}, + {"id_request": 4, "delivery_status": "failed", "error_message": "not completed", "status_code": 400}, + ] + t.check( + "delivery_output (request shape); unexpected bodies report nothing", + delivery_failures(flat_shape) == [("request 4", "not completed")] + and delivery_failures({"message": "ok"}) == [] + and delivery_failures(None) == [] + and delivery_failures([{"unexpected": True}]) == [], + ) + pending: dict[str, dict[str, Any]] = { + "a.md": {"jobId": 1}, + "b.md": {"jobId": 2}, + "c.md": {"jobId": 3}, + "d.md": {"jobId": 4}, + "e.md": {"jobId": 5}, + } + by_id: dict[str, dict[str, Any]] = { + "1": {"status": "completed"}, + "2": {"status": "in progress"}, + "3": {"status": "analyzing"}, + "4": {"status": "Completed"}, + } + t.check( + "candidates: exactly 'completed' (any case), never earlier states or ids unknown to /status, never twice", + sandbox_delivery_candidates(pending, by_id, set()) == [1, 4] + and sandbox_delivery_candidates(pending, by_id, {1}) == [4] + and sandbox_delivery_candidates(pending, by_id, {1, 4}) == [], + str(sandbox_delivery_candidates(pending, by_id, set())), + ) + http_calls: list[str] = [] + + def opener_500(req: urllib.request.Request, timeout: float | None = None) -> Any: + http_calls.append(req.full_url) + body = b"Internal server error." + raise urllib.error.HTTPError( + req.full_url, 500, "Internal server error.", email.message.Message(), io.BytesIO(body) + ) + + client = TranslatedClient("https://sandbox.invalid/v2/", "key", sleep=lambda _s: None, opener=opener_500) + delivery_error = "" + try: + client.sandbox_deliver([1, 2]) + except TranslatedApiError as e: + delivery_error = str(e) + delivery_http_calls = len(http_calls) + http_calls.clear() + with suppress(TranslatedApiError): + client.cancel([1]) + t.check( + "POST sandbox/delivery is sent once on a 500 (no retries) while other endpoints still retry", + delivery_http_calls == 1 + and len(http_calls) == 4 + and "500: Internal server error." in delivery_error + and delivery_error.startswith("POST sandbox/delivery failed"), + f"{delivery_http_calls} delivery call(s), {len(http_calls)} cancel call(s): {delivery_error}", + ) + + t.section("fake client: sandbox delivery of completed requests") + with mini_repo(): + h = sandbox_harness() + for rel in h.sources: + h.client.states[f"es/{rel}"] = "completed" + h.submit() + ids = sorted(int(j["jobId"]) for j in load_status("es")["pendingJobs"].values()) + out = captured(lambda: h.poll(wait=1)) + status = load_status("es") + t.check( + "round 1 triggers one delivery call with exactly the completed request ids", + h.client.delivery_calls == [ids] and "es: triggered sandbox delivery for 4 request(s)" in out, + out + str(h.client.delivery_calls), + ) + t.check( + "round 2 finds them delivered: files written, nothing pending, two status rounds, run not flagged", + not status["pendingJobs"] + and all(status["files"][rel]["status"] == STATUS_TRANSLATED for rel in h.sources) + and len(h.client.status_calls) == 2 + and not h.report.failed, + str(status), + ) + + with mini_repo(): + h = sandbox_harness() + h.client.states["es/guide.md"] = "completed" + h.client.fail_delivery["es/guide.md"] = "callback refused" + h.client.states["es/index.md"] = "analyzing" + h.client.states["es/toc.yml"] = "in progress" + h.submit() + status = load_status("es") + status["pendingJobs"]["index.md"]["submittedAt"] = (datetime.now(UTC) - timedelta(hours=1)).strftime( + TIMESTAMP_FORMAT + ) + save_status("es", status) + guide_id = int(status["pendingJobs"]["guide.md"]["jobId"]) + index_id = int(status["pendingJobs"]["index.md"]["jobId"]) + snapshots: list[dict[str, Any]] = [] + + def between_rounds(_delay: float) -> None: + snapshots.append(load_status("es")) + if len(snapshots) == 2: # two rounds saw the same states; now let everything through + h.client.fail_delivery.clear() + for id_content in ("es/guide.md", "es/index.md", "es/toc.yml"): + h.client.states[id_content] = "delivered" + + out = captured(lambda: h.poll(wait=1, sleep=between_rounds)) + status = load_status("es") + t.check( + "failed delivery entry: warning printed, job kept pending, no failure recorded, others delivered", + f"es: sandbox delivery failed for request {guide_id}: callback refused" in out + and "guide.md" in snapshots[0]["pendingJobs"] + and "guide.md" not in snapshots[0]["failures"] + and snapshots[0]["files"]["_ui-strings.json"]["status"] == STATUS_TRANSLATED, + out, + ) + t.check( + "only completed requests are pushed: an hour-old 'analyzing' job is not", + h.client.delivery_calls == [[guide_id]] and f"request {index_id}" not in out, + out + str(h.client.delivery_calls), + ) + t.check( + "no second trigger for the same request within the run", + out.count("triggered sandbox delivery") == 1 and len(h.client.delivery_calls) == 1, + out, + ) + t.check( + "run completes once the sandbox delivers: nothing pending, all translated, three status rounds", + not status["pendingJobs"] + and not status["failures"] + and all(status["files"][rel]["status"] == STATUS_TRANSLATED for rel in h.sources) + and len(h.client.status_calls) == 3 + and not h.report.failed, + str(status), + ) + + with mini_repo(): + h = sandbox_harness() + h.client.states["es/guide.md"] = "completed" + error_500 = "POST sandbox/delivery failed after 1 attempts (500: Internal server error.)" + error_503 = "POST sandbox/delivery failed after 1 attempts (503: maintenance)" + h.client.delivery_error = error_500 + h.submit() + snapshots = [] + + def between_error_rounds(_delay: float) -> None: + snapshots.append(load_status("es")) + if len(snapshots) == 2: # rounds 1-2 failed with the same text; round 3 fails differently + h.client.delivery_error = error_503 + elif len(snapshots) == 3: # round 4 succeeds, round 5 sees the delivery + h.client.delivery_error = None + + out = captured(lambda: h.poll(wait=1, sleep=between_error_rounds)) + status = load_status("es") + md = h.report.to_markdown(h.config) + t.check( + "endpoint errors: job stays pending with no failure recorded, other deliveries written", + "guide.md" in snapshots[0]["pendingJobs"] + and "guide.md" not in snapshots[0]["failures"] + and snapshots[0]["files"]["index.md"]["status"] == STATUS_TRANSLATED, + str(snapshots[0]), + ) + t.check( + "endpoint errors: warned once per distinct message, every failure counted in the report note", + out.count("es: WARNING POST sandbox/delivery failed") == 2 + and f"es: WARNING POST sandbox/delivery failed: {error_500}" in out + and f"es: WARNING POST sandbox/delivery failed: {error_503}" in out + and f"- es: POST sandbox/delivery failed 3 time(s); last error: {error_503}" in md, + out + md, + ) + t.check( + "endpoint errors: asked again every round (4 calls), delivered once the endpoint recovers, not flagged", + len(h.client.delivery_calls) == 4 + and len(h.client.status_calls) == 5 + and not status["pendingJobs"] + and status["files"]["guide.md"]["status"] == STATUS_TRANSLATED + and not h.report.failed, + out + str(h.client.delivery_calls), + ) + + with mini_repo(): + h = prod_harness() + h.client.states["es/guide.md"] = "completed" + h.client.states["es/index.md"] = "in progress" + h.submit() + out = captured(h.poll) + t.check( + "production: completed and in-flight jobs wait; /sandbox/delivery is never called", + not h.client.delivery_calls + and "sandbox delivery" not in out + and {"guide.md", "index.md"} <= set(load_status("es")["pendingJobs"]), + out, + ) + + +def selftest_cancel_pending(t: SelfTest) -> None: + t.section("cancel pending requests") + t.check( + "cancel response: listed ids confirmed, missing ids not; a response without the list confirms the call", + confirmed_cancellations({"id_request": [1, 2], "status": "cancelled"}, [1, 2, 3]) == {1, 2} + and confirmed_cancellations({"status": "cancelled", "message": "ok"}, [4]) == {4}, + ) + with mini_repo(): + h = sandbox_harness() + h.submit() + status = load_status("es") + status["pendingJobs"]["kb/foreign.md"] = { + "jobId": 999, + "environment": "production", + "sourceHash": "sha256:f", + "submittedAt": utc_now(), + } + save_status("es", status) + files_before = json.dumps(status["files"], sort_keys=True) + ids = sorted(int(j["jobId"]) for j in status["pendingJobs"].values() if j["environment"] == "sandbox") + out = captured(lambda: cmd_cancel_pending(h.config, h.client, ["es"], False, h.report)) + status = load_status("es") + t.check( + "sandbox: one cancel call with exactly the active-environment ids; their records dropped", + h.client.cancel_calls == [ids] and list(status["pendingJobs"]) == ["kb/foreign.md"], + out + str(h.client.cancel_calls), + ) + t.check( + "sandbox: files[] untouched, nothing under failures, report lists the cancelled files, run not flagged", + json.dumps(status["files"], sort_keys=True) == files_before + and not status["failures"] + and sorted(h.report.lang("es")["cancelled"]) == sorted(h.sources) + and not h.report.failed, + str(status), + ) + md = h.report.to_markdown(h.config) + t.check( + "sandbox: count, file list and foreign-job notice printed; report has a cancelled section", + "es: cancelled 4 request(s)" in out + and " cancelled guide.md" in out + and "es: 1 pending job(s) from production left untouched" in out + and "### es: cancelled (pending request dropped, translation unchanged)" in md + and "- `guide.md`" in md, + out + md, + ) + with mini_repo(): + h = prod_harness() + h.submit() + t.expect_exit( + "production without --force refused (exit 1)", + lambda: cmd_cancel_pending(h.config, h.client, ["es"], False, h.report), + "--force", + ) + t.check( + "refusal made no cancel call and kept the jobs", + not h.client.cancel_calls and len(load_status("es")["pendingJobs"]) == 4, + ) + h.client.uncancellable.add("es/guide.md") + out = captured(lambda: cmd_cancel_pending(h.config, h.client, ["es"], True, h.report)) + status = load_status("es") + t.check( + "production --force: refused chunk retried per id; uncancellable job kept pending with a warning, exit 1", + len(h.client.cancel_calls) == 5 + and list(status["pendingJobs"]) == ["guide.md"] + and "guide.md" not in status["failures"] + and "es: WARNING could not cancel request" in out + and "NOT_CANCELLABLE" in out + and "es: cancelled 3 request(s)" in out + and h.report.cancel_failures == 1 + and h.report.failed + and sorted(h.report.lang("es")["cancelled"]) == ["_ui-strings.json", "index.md", "toc.yml"], + out + str(h.client.cancel_calls), + ) + + +def cmd_self_test(config: TranslationConfig) -> int: + t = SelfTest() + selftest_corpus(t, config) + selftest_fixtures(t) + selftest_fake_client(t) + selftest_sandbox_delivery(t) + selftest_cancel_pending(t) + print(f"\nself-test: {t.passed} passed, {len(t.failures)} failed") + for name in t.failures: + print(f" FAILED: {name}") + return 1 if t.failures else 0 + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +def check_sandbox_filter(config: TranslationConfig, options: PlanOptions, allow_unbounded: bool) -> None: + if config.is_sandbox and not allow_unbounded and not options.filtered: + raise SystemExit( + "Sandbox runs need --limit or --file (or --allow-unbounded) so a full-corpus submission cannot happen by accident." + ) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Translate docs content with Translated (TranslationOS).") + actions = parser.add_mutually_exclusive_group(required=True) + actions.add_argument("--plan", action="store_true", help="show what would be submitted; no API calls") + actions.add_argument("--submit", action="store_true", help="submit missing/outdated files") + actions.add_argument("--poll", action="store_true", help="fetch delivered translations") + actions.add_argument("--run", action="store_true", help="submit, then poll (CI mode)") + actions.add_argument( + "--baseline", action="store_true", help="mark existing, structurally valid translations as current" + ) + actions.add_argument("--self-test", action="store_true", help="offline self-test (no key, no network)") + actions.add_argument("--probe", action="store_true", help="check service types and languages against the API") + actions.add_argument("--dump-orders", metavar="DIR", help="write the /translate bodies to DIR instead of sending") + actions.add_argument( + "--cancel-pending", + action="store_true", + help="cancel every pending request of the active environment and drop its record (production needs --force)", + ) + parser.add_argument("--lang", action="append", help="limit to a language folder (repeatable)") + parser.add_argument("--file", action="append", default=[], help="glob relative to content/ (repeatable)") + parser.add_argument("--limit", type=int, default=0, help="submit at most N files per language") + parser.add_argument("--wait", type=float, default=0, help="minutes to keep polling for deliveries") + parser.add_argument( + "--force", + action="store_true", + help="resubmit matched files even when they are current; with --cancel-pending: allow cancelling in production", + ) + parser.add_argument("--lenient", action="store_true", help="write translations that fail structural verification") + parser.add_argument("--allow-unbounded", action="store_true", help="lift the sandbox per-run caps") + parser.add_argument( + "--retry-failed", action="store_true", help="re-plan files that failed at the current source hash" + ) + parser.add_argument("--report", help="write a markdown run summary to this path") + parser.add_argument( + "--baseline-ref", + help="git ref whose content/ hashes become the baseline " + "(e.g. the last merge from the previous translation provider)", + ) + parser.add_argument( + "--overwrite-baseline", action="store_true", help="allow --baseline to replace an existing baseline" + ) + parser.add_argument( + "--repair-existing", + action="store_true", + help="with --baseline: write code, markers and link targets restored from English back into existing " + "translations (without it the baseline only reports what it would repair)", + ) + return parser + + +def check_option_combinations(args: argparse.Namespace) -> None: + if args.repair_existing and not args.baseline: + raise SystemExit("--repair-existing is only valid together with --baseline.") + + +def main() -> int: + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + args = build_parser().parse_args() + check_option_combinations(args) + + if not CONTENT_DIR.exists(): + print("Run from the docs repo root (content/ not found).", file=sys.stderr) + return 1 + + build_config = load_build_config() + config = TranslationConfig( + build_config, os.environ.get("TRANSLATED_ENV"), os.environ.get("TRANSLATED_SERVICE_TYPE") + ) + if args.self_test: + return cmd_self_test(config) + + default_lang = get_default_language(build_config) + langs = args.lang or get_target_languages(default_lang) + if not langs: + print("No target languages found under localizedContent/.", file=sys.stderr) + return 1 + sources = get_scoped_sources(config) + passthrough = get_passthrough_sources(config, sources) + options = PlanOptions(args.file, args.limit, args.force, args.retry_failed) + print( + f"{len(sources)} source files in scope, {len(passthrough)} passthrough; languages: {', '.join(langs)}; " + f"environment: {config.environment_label} ({config.base_url or 'no endpoint'}), " + f"service type: {config.service_type or 'unset'}" + ) + + if args.plan: + cmd_plan(langs, sources, passthrough, options) + return 0 + if args.baseline: + cmd_baseline(langs, sources, passthrough, args.baseline_ref, args.overwrite_baseline, args.repair_existing) + return 0 + locales = load_locale_map() + if args.dump_orders: + cmd_dump_orders( + config, Path(args.dump_orders), langs, sources, passthrough, locales, options, args.allow_unbounded + ) + return 0 + if args.probe: + return cmd_probe(config, require_client(config, "--probe", need_service_type=False), langs, locales) + + action = "--run" if args.run else "--submit" if args.submit else "--poll" if args.poll else "--cancel-pending" + client = require_client(config, action) + if args.submit or args.run: + check_sandbox_filter(config, options, args.allow_unbounded) + report = RunReport() + try: + if args.cancel_pending: + cmd_cancel_pending(config, client, langs, args.force, report) + if args.submit or args.run: + cmd_submit(config, client, langs, sources, passthrough, locales, options, args.allow_unbounded, report) + if args.poll or args.run: + cmd_poll( + config, client, langs, sources, args.wait, args.lenient, report, RUN_INITIAL_DELAY if args.run else 0 + ) + finally: + summary = report.to_markdown(config) + print("\n" + summary) + if args.report: + Path(args.report).parent.mkdir(parents=True, exist_ok=True) + Path(args.report).write_text(summary, encoding="utf-8") + return 1 if report.failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/crowdin.yml b/crowdin.yml deleted file mode 100644 index 6a8b9a631..000000000 --- a/crowdin.yml +++ /dev/null @@ -1,25 +0,0 @@ -"files": [ - { - "source": "/content/**/*.md", - "translation": "/localizedContent/%two_letters_code%/%original_path%/%original_file_name%", - "ignore": [ - "/content/**/toc.md" - ] - }, - { - "source": "/content/404.html", - "translation": "/localizedContent/%two_letters_code%/content/404.html" - }, - { - "source": "/content/getting-started/app/**/*.html", - "translation": "/localizedContent/%two_letters_code%/%original_path%/%original_file_name%" - }, - { - "source": "/content/toc.yml", - "translation": "/localizedContent/%two_letters_code%/content/toc.yml" - }, - { - "source": "/content/_ui-strings.json", - "translation": "/localizedContent/%two_letters_code%/content/_ui-strings.json" - } -] diff --git a/localizedContent/es/.translation-status.json b/localizedContent/es/.translation-status.json index 2359bacfc..71dd6d999 100644 --- a/localizedContent/es/.translation-status.json +++ b/localizedContent/es/.translation-status.json @@ -2,1376 +2,1580 @@ "language": "es", "sourceBaseline": "content", "files": { + "404.html": { + "sourceHash": "sha256:8e5748cfcc9ca6d991e8e65c36fb2b411cfec8c78e4f7c7be154ca6200c2f80e", + "status": "translated" + }, + "_ui-strings.json": { + "sourceHash": "sha256:01a51cba4185663722f9194cdaf9c8c61d9d65dac841e841120c8661b7485fbf", + "status": "translated" + }, "features/advanced-refresh.md": { - "sourceHash": "sha256:a3f181fa26c3fb2a96949d37a42a9b2729ea01279baa4ce78cab95fca8047ff7", + "sourceHash": "sha256:4c8a5deb7eaece9c11a76f650eab3352e5a16e51cc445eccd356ec7ddb451c5c", + "status": "translated" + }, + "features/ai-assistant.md": { + "sourceHash": "sha256:8267b87af307df2b39e440252675d40ed0f2265952db810cac3899c60f622680", "status": "translated" }, "features/Best-Practice-Analyzer.md": { - "sourceHash": "sha256:b95e98bcacdc9bec745a801592ec906a4bb1490a44f997c700f4f6244436fe78", + "sourceHash": "sha256:253dba4e0485c6b8052ae8da292cece72490d32b7fea72112aada89dbd2b8264", "status": "translated" }, "features/built-in-bpa-rules.md": { - "sourceHash": "sha256:233d6d9f44bae808b345197b2c7b5691cb0b4af32ddb66468ef5debb4c1653fa", + "sourceHash": "sha256:bdffc9c4bc7c941d725928c7b068e04907b5f111d273daacd59d38e57faf0a30", "status": "translated" }, "features/code-actions.md": { - "sourceHash": "sha256:2965d0ab377e3989e5adefdf317b44318049f00f8c51c35df2a7632dee475fbf", + "sourceHash": "sha256:82baf7662cf3be05e7a4b399cd9cf3cf6b7ab76eb6eea7233b020be46862b47b", "status": "translated" }, "features/Command-line-Options.md": { - "sourceHash": "sha256:6b2a4865311b0da759029cb643f838fa0243438a352cb8e855deb53ae8804146", + "sourceHash": "sha256:8f0e81ec57e6bc1e49ea2967760f68bc18e29f170de2a89bd83fc54b953b6741", "status": "translated" }, "features/creating-macros.md": { - "sourceHash": "sha256:031b3f5aca8eec28c78609ef421d48595cdc2df96f073d7a52ab16bebdf44326", + "sourceHash": "sha256:b1a3ff506c962feae7461cf83f78b86ab387d573bc53060d7f12f5c22963778c", "status": "translated" }, "features/csharp-scripts.md": { - "sourceHash": "sha256:fe043bad73d6f8e483b6413ca58f6a62f5ded53074863ab11e5cde5fc0975ac3", + "sourceHash": "sha256:e7ea43de890f061f9c5c23ba7d5b446c13003a59722836cb94c2912ce2bba1db", "status": "translated" }, - "features/Custom-Actions-hidden.md": { - "sourceHash": "sha256:0100c53a221855d5b1a8fa5c2a810fd9f56eda751fedb1eccad1b76d0cb8189c", + "features/CSharpScripts/Advanced/script-add-databricks-metadata-descriptions.md": { + "sourceHash": "sha256:3e3502fa81133433b74714be43bbc8eb911a90f8848ff98d0f1828f56036f9f9", "status": "translated" }, - "features/dax-debugger.md": { - "sourceHash": "sha256:20fdc750de8ab9f6d153f82b51c392201b53c89da26b3ee6a4508f87ac3a8403", + "features/CSharpScripts/Advanced/script-convert-dlol-to-import.md": { + "sourceHash": "sha256:35795fbeb43ca8cd0f1da2d5f94fdc58825d5951526475c99bd52f1cacf77ccc", "status": "translated" }, - "features/dax-editor.md": { - "sourceHash": "sha256:1f2c0c195b157d031bc3ced3a532165fb97a69de0c1636749b0775f487dcd236", + "features/CSharpScripts/Advanced/script-convert-dlsql-to-dlol.md": { + "sourceHash": "sha256:f977fe473183a92168b7d227519993669391f41e3aa535e167b89b9239fe6124", "status": "translated" }, - "features/dax-optimizer-integration.md": { - "sourceHash": "sha256:445aebdd1cfe2ebba6ad4d31786808f963aaa394d2971079a9dbf1bdf31dcde3", + "features/CSharpScripts/Advanced/script-convert-import-to-dlol.md": { + "sourceHash": "sha256:2740a932e1e7cb4f2a2eb7eb9129d2847db63209cbb2baaf6b20e3b45b4d71c4", "status": "translated" }, - "features/dax-package-manager.md": { - "sourceHash": "sha256:a8f12f885d8ffa3af88d600aa9f9f584f081bc527407cb9c3332a7780c7d1903", + "features/CSharpScripts/Advanced/script-count-things.md": { + "sourceHash": "sha256:67e92ecd96ccbe2ec1373a8d3280182a126d832821001b70e21c990a4742deb2", "status": "translated" }, - "features/dax-query.md": { - "sourceHash": "sha256:8c45adcffa202f972c37ddd6b1278da37dbe3ecdc0b62f85abf27a723f7f8f0c", + "features/CSharpScripts/Advanced/script-create-and-replace-M-parameter.md": { + "sourceHash": "sha256:0ce06f6f7719671d46c05fabbbc25a25721c9069615c6484b6dbe2bf058b8505", "status": "translated" }, - "features/dax-scripts.md": { - "sourceHash": "sha256:e88d01ebe77b65464d9b7abd6688a27c3fee2e3f8539d8ebd51c2e5b813e105d", + "features/CSharpScripts/Advanced/script-create-databricks-relationships.md": { + "sourceHash": "sha256:7100c6e93a2961e64c4962a5e22d0bca278b2f5c97b1c4509fbc20be236b7b5d", "status": "translated" }, - "features/deployment.md": { - "sourceHash": "sha256:9e60fe917808f281dd83193a448915452f828350dca386fe8c2b2bfef15c0365", + "features/CSharpScripts/Advanced/script-create-date-table.md": { + "sourceHash": "sha256:a95991c83be2e3b7d7fbfa7c8b1eff5f59467487b66aaed2fe7a43e510f002ca", "status": "translated" }, - "features/hierarchical-display.md": { - "sourceHash": "sha256:2f5c506f7b35ecfea164cccfee74ce6de9c8dd317c01fff96fd04e132512c0dc", + "features/CSharpScripts/Advanced/script-databricks-semantic-model-set-up.md": { + "sourceHash": "sha256:d18ee6c4ecb762d70373d3632b828a41a5dd76b283a0f742065aed5bc9ce0233", "status": "translated" }, - "features/import-tables.partial.md": { - "sourceHash": "sha256:b82db90bbf187352379ec8762cbd205610e72490e130136debac3b75ec70ce42", + "features/CSharpScripts/Advanced/script-find-replace-selected-measures.md": { + "sourceHash": "sha256:01f8139d9460fda1438695c2ff55c02a0671fc22ec442b111dd44236cd0f22c1", "status": "translated" }, - "features/index.md": { - "sourceHash": "sha256:50f03bfd484f4801fd99569881b836a2c93d4d8b179ab731f588cb3c0c9b2a35", + "features/CSharpScripts/Advanced/script-format-power-query.md": { + "sourceHash": "sha256:a18bc6f534e163a0cc87d75f47b02f13c3f6d47d4dd988ce53f55bd52002126b", "status": "translated" }, - "features/metadata-translation-editor.md": { - "sourceHash": "sha256:4262cf9f4d08a6f141fe470c9ac670d33edd49f9c4755bcc17fc8bbb26f31a41", + "features/CSharpScripts/Advanced/script-implement-incremental-refresh.md": { + "sourceHash": "sha256:ebeaff67ce308880b8ba5be4b9cd198c30a5f48af87df7b477d48ed313bb1907", "status": "translated" }, - "features/perspective-editor.md": { - "sourceHash": "sha256:c52ab63a25c110c9302b50b563d52d81d7bbe7a4b7fc999908cae7cff5a1b77f", + "features/CSharpScripts/Advanced/script-implement-user-defined-aggregations.md": { + "sourceHash": "sha256:e4a1668f2fe37b9dfedf705f1ba2052a3f696c10e3422420ee24e7f9a983aa8c", "status": "translated" }, - "features/pivot-grid.md": { - "sourceHash": "sha256:b6af168f855063133df7efcafa67927ff16748fe3cdd59c4fb0b9786d93be066", + "features/CSharpScripts/Advanced/script-output-things.md": { + "sourceHash": "sha256:b2e5c8db2ba620c4e8ccb72e729b96c474b926b2cf8e5124736be1fcfc4291f4", "status": "translated" }, - "features/refresh-overrides.md": { - "sourceHash": "sha256:d2363ece74c675093076e1d3bb692fb2841cb9c705e52a458aec64df266d9f36", + "features/CSharpScripts/Advanced/script-remove-measures-with-error.md": { + "sourceHash": "sha256:d0a4c46a77e8af7bb00b109e8875d4b549bc0c03f22ec28d2f1bd2d95ce4f20d", "status": "translated" }, - "features/save-to-folder.md": { - "sourceHash": "sha256:ef91c2f9f796fef7e91358ead257c2b3cf32c57092114cb95c4f16ca4552a503", + "features/CSharpScripts/Beginner/script-count-rows.md": { + "sourceHash": "sha256:691212c34c7c4dc0b39809eedd600fa594cdcb8d8e0d116fa9e6804a07fd1088", "status": "translated" }, - "features/save-with-supporting-files.md": { - "sourceHash": "sha256:8b436e18eaae2061c8521ac117fa08e8ed83c43a218b7631c1409caaedf1974d", + "features/CSharpScripts/Beginner/script-create-field-parameter.md": { + "sourceHash": "sha256:68a3acca96d3e372683c73129f700024e3229daa1752be3805653accc56fd91d", "status": "translated" }, - "features/script-helper-methods.md": { - "sourceHash": "sha256:52ece0ac2ca74b3aceefd177057e773977da7b27b44977e432c3ed207677b6c6", + "features/CSharpScripts/Beginner/script-create-m-parameter.md": { + "sourceHash": "sha256:9e2f4adee2211c2ef4a90fb017b289dbe5bdd935e290172030e5b6f32f1bf2bb", "status": "translated" }, - "features/semantic-bridge-metric-view-object-model.md": { - "sourceHash": "sha256:d27290a24b2917dcaa396d9d0aa9c1d2680b00dfba2e976d15007749b3831d08", + "features/CSharpScripts/Beginner/script-create-measure-table.md": { + "sourceHash": "sha256:0a641a1fa1d5b7b7ab73799b15d9434407b3fecc1d721ed1d43a58a4ff1d30e2", "status": "translated" }, - "features/semantic-bridge-metric-view-validation.md": { - "sourceHash": "sha256:81d096d6308ae4b99b860082843c6e89e753a6374efd9787264bbd158329a96d", + "features/CSharpScripts/Beginner/script-create-sum-measures-from-columns.md": { + "sourceHash": "sha256:50bd3f2a8ef34e438493f91da92be743f7cd4296a694d3243460abeb47f28c89", "status": "translated" }, - "features/semantic-bridge.md": { - "sourceHash": "sha256:a8da16703cd2c553fbdbf78e8337d1a2a9499b964f48373e4817012153c33548", + "features/CSharpScripts/Beginner/script-create-table-groups.md": { + "sourceHash": "sha256:74dd9a2bda903d85972caedcf9e230ca4843fa87fa490bd82122498db8a261c4", "status": "translated" }, - "features/table-groups.md": { - "sourceHash": "sha256:6a2a5c2d24ce7f1f8171211e61ddb8f2774619d945fb24bcf33fc83db6c0f8e8", + "features/CSharpScripts/Beginner/script-display-unique-column-values.md": { + "sourceHash": "sha256:9305105469bd45fb0ce7edbb66fdc9c7ec9be16d377bca79c8265ca16451aeeb", "status": "translated" }, - "features/tmdl.md": { - "sourceHash": "sha256:4b1e71e59f7377cb6694e22848a9831907d8bce20459dbb5814a4badcde68463", + "features/CSharpScripts/Beginner/script-edit-hidden-partitions.md": { + "sourceHash": "sha256:fe1dfb281e64a7367180a7705a5559be43d8c9368e1c5e7acccab093ee1430d4", "status": "translated" }, - "features/toc.md": { - "sourceHash": "sha256:124251b5b1d7b83b2daba79f72ec18bf2961bd43075635ebda15094992f326ea", + "features/CSharpScripts/Beginner/script-format-numeric-measures.md": { + "sourceHash": "sha256:e2c47703f04f15d22d23fd66c8abda9a27e5ff15ffbf6c9e73a9dbc54ba3952d", "status": "translated" }, - "features/Useful-script-snippets.md": { - "sourceHash": "sha256:1a220bb878ce665e3ba52328c2929758bf399c56613d9a072b2d5894710d4be2", + "features/CSharpScripts/Beginner/script-show-data-source-dependencies.md": { + "sourceHash": "sha256:6bd1324dda48a5363b2d1e9d9e893f61f2e3364913c64848180c5069442a1600", "status": "translated" }, - "features/using-bpa-sample-rules-expressions.md": { - "sourceHash": "sha256:fbf4aca9a2718fc53171edac32f27bbacd194e1b3644c4f19a62c996435697a2", + "features/CSharpScripts/csharp-script-library-advanced.md": { + "sourceHash": "sha256:1bffc5de50a63a02a1499b6c690f8f021a456f69bcea94de8d30589792f76e9f", "status": "translated" }, - "features/using-bpa.md": { - "sourceHash": "sha256:a750489f209d1bb3d314368e15c88913e3fad6c08402139c29aa77d172755684", + "features/CSharpScripts/csharp-script-library-beginner.md": { + "sourceHash": "sha256:8297f36b1b4788ff484bc7fbda33135465e258039c2c4fb91a10d7dd8d8dceb7", "status": "translated" }, - "features/Workspace-Database.md": { - "sourceHash": "sha256:037cf90e068ae6f0297fad2560012d9c08072abee11af6833c07e4d9bd4f4f7f", + "features/CSharpScripts/csharp-script-library.md": { + "sourceHash": "sha256:46b1ba3cb8415ab08589e29d961cbd45c89f8a94e545c381ef189be5e0641b55", "status": "translated" }, - "features/workspace-mode.partial.md": { - "sourceHash": "sha256:14bcfb19450618ce483f54692c50573694a0f9f1432923610e4bfb3b42d56155", + "features/CSharpScripts/Template/csharp-script-Template.md": { + "sourceHash": "sha256:9759ddcf839ac9befcbc5e895af14e4c555a327e1866fb48f373933d5e86758e", "status": "translated" }, - "features/CSharpScripts/csharp-script-library-advanced.md": { - "sourceHash": "sha256:d9896cd8b9c754d2c382efcb22525c1e4af263988d656cff7b95d250115fb299", + "features/Custom-Actions-hidden.md": { + "sourceHash": "sha256:a803f1066adffe1f7f0a0f8fc0c71c70b268737b756491c39ec54c70d49f204c", "status": "translated" }, - "features/CSharpScripts/csharp-script-library-beginner.md": { - "sourceHash": "sha256:728395ae6dc4b6686c418b09ed9d090c41ca9066a4c734e82469075c34fc4881", + "features/dax-debugger.md": { + "sourceHash": "sha256:97d080142ccf2afca94b197cf7f0153dcbac57b9e0c0559c947b2da455c5814d", "status": "translated" }, - "features/CSharpScripts/csharp-script-library.md": { - "sourceHash": "sha256:4a83b9ee945a704d09879d38757a8e8838acdce54c519a9191d3748c80f2c6dc", + "features/dax-editor.md": { + "sourceHash": "sha256:60ef7116000aa877e6e88ae05e6e517f32081b183a45972e1864515dbef58c6b", "status": "translated" }, - "features/Semantic-Model/direct-lake-sql-model.md": { - "sourceHash": "sha256:24c924ac00b363280270397b20e38bfcb58ec284ffcbafd332acdef9cdf70517", + "features/dax-optimizer-integration.md": { + "sourceHash": "sha256:7f360b8d39e0a763558717f52a112f4e908cd7e30b99c94580b0f478277858f1", "status": "translated" }, - "features/Semantic-Model/direct-query-over-as.md": { - "sourceHash": "sha256:85af1a152e4cf86a04fdd91bcf518bea567e30e55688e99f2201d524eaad22a9", + "features/dax-package-manager.md": { + "sourceHash": "sha256:1b6e0543d34a7b9f09bd8da1f53bafccaa4edbb4d8639dd057c4675820f3242f", "status": "translated" }, - "features/Semantic-Model/semantic-model-types.md": { - "sourceHash": "sha256:64a1be153fec80ec6235186a2ec316d53fe9af11dfc9d1cbee637c0ed6fd41dd", + "features/dax-query.md": { + "sourceHash": "sha256:f4307107c9930ea338c695ff713f38b64d0c5ff2e831dba1682f4131604f17e7", "status": "translated" }, - "features/views/bpa-view.md": { - "sourceHash": "sha256:c7ec9d9df36d5f768d0d6384f7b1baa7ddd134a5222d34fa32fbec4f9eb44395", + "features/dax-scripts.md": { + "sourceHash": "sha256:0de5ac712cbc629c2f24101dc01d425ab7121175a44c2004157164fa0e6208ec", "status": "translated" }, - "features/views/data-refresh-view.md": { - "sourceHash": "sha256:b248eb0c684a13d7f3d787b0ddd6a1637f4556eed8da40138dab30ccc308003e", + "features/deployment.md": { + "sourceHash": "sha256:dd753224144a686b1e621ea6bfdd3ea03601bd032fae1be49da58a0b1b6c24ee", "status": "translated" }, - "features/views/diagram-view.md": { - "sourceHash": "sha256:980b80557f3d1bf12969c4d974f53b1e76e211c152d544447fcb9b77dd32294e", + "features/hierarchical-display.md": { + "sourceHash": "sha256:6a726e430a507cfa17e106e498360d1518df8da7e656883c3ff845202d936cf5", "status": "translated" }, - "features/views/find-replace.md": { - "sourceHash": "sha256:f42cbffa4ba076ede971a7f74eb1a04c51a671f709167e80d123a27708f348b6", + "features/import-tables.partial.md": { + "sourceHash": "sha256:ca53e315bb2c4d8a1c45350141d2fcff7eb386c03d66950a8bff4d2761d1fd55", "status": "translated" }, - "features/views/macros-view.md": { - "sourceHash": "sha256:520dd7be90d9a286f15c145d0c3f151213fc79a4c9f8b25af4de9b766f20b012", + "features/index.md": { + "sourceHash": "sha256:5bac9edafd8e6fe76387a56ec818c4ca1665313ad6f2300212d522e57a77ee73", "status": "translated" }, - "features/views/messages-view.md": { - "sourceHash": "sha256:f4d90ae99db74ddc3742853ad8ca848480cafe1c392565525394db5b24003a6a", + "features/metadata-translation-editor.md": { + "sourceHash": "sha256:7dd826b950714972b3429347f09fed22bcdbaf3f149e4f47dc6382ecf26e69e5", "status": "translated" }, - "features/views/properties-view.md": { - "sourceHash": "sha256:f001df97118de15a898b6d160c3ad5df453589bdace10f50391eb8223802f30e", + "features/perspective-editor.md": { + "sourceHash": "sha256:d7bd4b9834bb8fac3a1072e4c84d43efe36d64e7443b50f0778f0f76f189476f", "status": "translated" }, - "features/views/tom-explorer-view.md": { - "sourceHash": "sha256:3d2dbacf216f262ac4cc0380a1545b11c2bd9ae3d953e4a4afb870431c777d96", + "features/pivot-grid.md": { + "sourceHash": "sha256:13469e84b4715d3989eb9f9220222807cad8eaa30bb5dc6eb1837da1e13d82ee", "status": "translated" }, - "features/views/user-interface.md": { - "sourceHash": "sha256:986ae970de12c4745c8c55268cc2278f50ed0f0f612237b8701645df0a3571f8", + "features/refresh-overrides.md": { + "sourceHash": "sha256:5cf34fc846be329c7ecdf10fb5927e1e3b9f5a3de4bafde5f63f30894e17fe8e", "status": "translated" }, - "features/CSharpScripts/Advanced/script-add-databricks-metadata-descriptions.md": { - "sourceHash": "sha256:1cdf755e7b2b922a65e1e5458e87ad870bf94d7fa559cdd811f0c4cbd8a671f8", + "features/save-to-folder.md": { + "sourceHash": "sha256:b4301ebe814e9c08764c9e634e51bc5a46047cd2534c61a540fb9ff0a5d89f0d", "status": "translated" }, - "features/CSharpScripts/Advanced/script-convert-dlsql-to-dlol.md": { - "sourceHash": "sha256:a9ccc97caf8639e63c4b196449dbebb7efade5aa9e0991f3f460f6b6a46f7415", + "features/save-with-supporting-files.md": { + "sourceHash": "sha256:b61b721a1d9f2cf0652155795e046291d1e8d5c80fb865f528ec03e2ada0d98d", "status": "translated" }, - "features/CSharpScripts/Advanced/script-convert-import-to-dlol.md": { - "sourceHash": "sha256:f6e6afd6430cc5eaf734a29eee11ba1bdc300efcd2f3e9a8566ab933cad8a0d7", + "features/script-helper-methods.md": { + "sourceHash": "sha256:5cb24ef463fa580075848ea2facacb0b906a1e08ff6cb1c62f7e41ae3824fbf6", "status": "translated" }, - "features/CSharpScripts/Advanced/script-count-things.md": { - "sourceHash": "sha256:a57cc71eca07c7ef5625dcd0de4ef25dbd135d124376ee97b5c163f135e094ac", + "features/semantic-bridge-metric-view-fields-and-dimensions.md": { + "sourceHash": "sha256:58bd40eea2c9da6f1cf4b1b24ca50f04ed021a171289ba554edccd4874fbafa4", "status": "translated" }, - "features/CSharpScripts/Advanced/script-create-and-replace-M-parameter.md": { - "sourceHash": "sha256:98e78822d061113b577caa032c5cec2ecdc01dc3cc54415d895d6635b5372a40", + "features/semantic-bridge-metric-view-object-model.md": { + "sourceHash": "sha256:6028084095ef69f4cd3c5f0e0770809dafca75ba3636e9f02335c7984e06c826", "status": "translated" }, - "features/CSharpScripts/Advanced/script-create-databricks-relationships.md": { - "sourceHash": "sha256:2c057aa107ad51f212de201495d4b957a9caedb5b4020295677215937c1bb989", + "features/semantic-bridge-metric-view-tabular-translation.md": { + "sourceHash": "sha256:74be78dd2533ebab27bb4d9dff59a263f12f19b90e8bc746d6206feb4d7f08b5", "status": "translated" }, - "features/CSharpScripts/Advanced/script-create-date-table.md": { - "sourceHash": "sha256:d3a6814c2600eb4104b75aa09736fe94c707a5b9314affe7228ffad221a7c58d", + "features/semantic-bridge-metric-view-validation.md": { + "sourceHash": "sha256:3b323ce1afb0fe35d9dc2067e7d8b0365eff7b7df5f6363c797a38f0267f51b1", "status": "translated" }, - "features/CSharpScripts/Advanced/script-databricks-semantic-model-set-up.md": { - "sourceHash": "sha256:a88f9235ec3544b569919b71dfd67918aadda89f3594bd4a930dc2cd0cd6c3b1", + "features/semantic-bridge.md": { + "sourceHash": "sha256:148612d364d9d289779fdf6bb5119e69a5a06f1162e11a909c5456284fbfcdbb", "status": "translated" }, - "features/CSharpScripts/Advanced/script-find-replace-selected-measures.md": { - "sourceHash": "sha256:8810d2b2c4b290a475cf2c25547d55e11581052adecd15722d33722f1dee8d7f", + "features/Semantic-Model/direct-lake-sql-model.md": { + "sourceHash": "sha256:5b7665db988503e68a3f05a8cb10f7883663bfda426d0c1856c6b1e50021e4c1", "status": "translated" }, - "features/CSharpScripts/Advanced/script-format-power-query.md": { - "sourceHash": "sha256:e0d227f4dee9e9555941f4f1692f9149db96601ef93cfe170fac0aec1ef15a7d", + "features/Semantic-Model/direct-query-over-as.md": { + "sourceHash": "sha256:280fbda0a57da7ba6b11294d3809f7b52c4f25cdf7ca5fa07e85264e5cd1e74d", "status": "translated" }, - "features/CSharpScripts/Advanced/script-implement-incremental-refresh.md": { - "sourceHash": "sha256:d073d4bf9223b7c04a68b06f0430d5116eef349187beeb4ae624f0b28a40ba67", + "features/Semantic-Model/semantic-model-types.md": { + "sourceHash": "sha256:d314cae6177dff5be507edbe44a1132f70dad1f9683cb60ed4aee5aa05d24578", "status": "translated" }, - "features/CSharpScripts/Advanced/script-implement-user-defined-aggregations.md": { - "sourceHash": "sha256:e6d7233c69c13b465e0a8c444a458d13115711ce3e093651a0177bd074eac83b", + "features/table-groups.md": { + "sourceHash": "sha256:75438952d25956a239efcb3acfdbd4d91fb124b02959bfad8065b83cfc8eb78a", "status": "translated" }, - "features/CSharpScripts/Advanced/script-output-things.md": { - "sourceHash": "sha256:55aa64a311c8d33ea0012e86cca9c7d9e39b492bf1f9012f442234966871c568", + "features/te-cli/includes/te-cli-preview-notice.md": { + "sourceHash": "sha256:3def00decf392f4d017bd0d3132c2e690fbaae2d09b381b0e3588fded89b7fb8", "status": "translated" }, - "features/CSharpScripts/Advanced/script-remove-measures-with-error.md": { - "sourceHash": "sha256:98a4e7ce55c0cbe7bc418827593433cf4cd4c24d02b266114b80cb2b14ef29bc", + "features/te-cli/te-cli-auth.md": { + "sourceHash": "sha256:64acf273c54477802fb09fe452573ec74bb01b3c1409a457db20cb6a53ec4557", "status": "translated" }, - "features/CSharpScripts/Beginner/script-count-rows.md": { - "sourceHash": "sha256:880b4cecde42c22e2ac5449590145195c30dd0b53499dc57eb5bfce960575ab3", + "features/te-cli/te-cli-automation.md": { + "sourceHash": "sha256:0cbf17e32e1455bacd1898f40190ec310ba4ed4855de3892e10c2d5010cceb9b", "status": "translated" }, - "features/CSharpScripts/Beginner/script-create-field-parameter.md": { - "sourceHash": "sha256:b900174834019481edb57daee36c1cd06ab33e5e04ded0ad7989bd211c034df3", + "features/te-cli/te-cli-cicd.md": { + "sourceHash": "sha256:f3cb9d43acbfbd218059a68e2424221db17065ff2924e1d3f9c8dd77f3fba631", "status": "translated" }, - "features/CSharpScripts/Beginner/script-create-m-parameter.md": { - "sourceHash": "sha256:495d56bf23e3a8b96b109e692530b55539dd59f8d6861ffa17a0aadebd2ef00e", + "features/te-cli/te-cli-commands.md": { + "sourceHash": "sha256:8477ea050acdfe20c798929180a94e35a433dd73dab7214d57a3486d773f18fc", "status": "translated" }, - "features/CSharpScripts/Beginner/script-create-measure-table.md": { - "sourceHash": "sha256:287bd8089fa3be09b2d5cc7830934549c219b6957cd71b76e36c75400168b8af", + "features/te-cli/te-cli-config.md": { + "sourceHash": "sha256:cc5e0827d7ebc71a3aa4ade3edf245d425ef227b1a796d04f1cb28267760b9a8", "status": "translated" }, - "features/CSharpScripts/Beginner/script-create-sum-measures-from-columns.md": { - "sourceHash": "sha256:9aeacf4afb6eb0ea78b0783bb0f17203b478f3b504e17080f9e01d40973d068b", + "features/te-cli/te-cli-install.md": { + "sourceHash": "sha256:e3b97de653c2b76bff2553ba5618b8031699a28303b1bd1ad5de33887ac579ab", "status": "translated" }, - "features/CSharpScripts/Beginner/script-create-table-groups.md": { - "sourceHash": "sha256:297ec32a4bd6104b89273ae6ef1708b653f81bea40c8c5fac8ef8a8e73e59cb9", + "features/te-cli/te-cli-interactive.md": { + "sourceHash": "sha256:797076275a2c1c0f4cff8042250aed209107f1b50ecfd9279d6695983e5881b1", "status": "translated" }, - "features/CSharpScripts/Beginner/script-display-unique-column-values.md": { - "sourceHash": "sha256:18b6074f21c2f083eb8bc5486d060ba6f9655db11f4f5e7e087460eeae839128", + "features/te-cli/te-cli-limitations.md": { + "sourceHash": "sha256:86caaf7b4ef53a92af84f2cab900f231555ad280139385e86e15eb7aa277ff4a", "status": "translated" }, - "features/CSharpScripts/Beginner/script-edit-hidden-partitions.md": { - "sourceHash": "sha256:12372b7806e13174ec41bb1dfe207f63a039e7e416b50a7a441325dc1a788e22", + "features/te-cli/te-cli-migrate.md": { + "sourceHash": "sha256:2dbe1b5ad09e9d096a6816db117ddbb07b7611c427689362cd72753546a283e7", "status": "translated" }, - "features/CSharpScripts/Beginner/script-format-numeric-measures.md": { - "sourceHash": "sha256:efcac83bb47fb34182536fd7626e1e9159186aed8addc1218a089bcf8f995836", + "features/te-cli/te-cli-skill.md": { + "sourceHash": "sha256:4b6eba64e68df9170c9ebf0e046932dfd63a256258881874f081cafe47606fa5", "status": "translated" }, - "features/CSharpScripts/Beginner/script-show-data-source-dependencies.md": { - "sourceHash": "sha256:4754d28371a514ff7e5ce78cd6d87f0a4a9a4907336e0fca1f0af4a05774d6c9", + "features/te-cli/te-cli.md": { + "sourceHash": "sha256:7996d6b15f1739097767af168689007e4f7b03bff5b730bb65e50fb42bd2f3c4", "status": "translated" }, - "features/CSharpScripts/Template/csharp-script-Template.md": { - "sourceHash": "sha256:97b462002593a46b2335518b6e0773f101ec09a5ae39ef2800a4dd584940d835", + "features/tmdl.md": { + "sourceHash": "sha256:f05bb3b99f5266a4f692fb9b628b151114647378f6ef55d9058688cfb59afb85", + "status": "translated" + }, + "features/toc.md": { + "sourceHash": "", + "status": "untranslated" + }, + "features/Useful-script-snippets.md": { + "sourceHash": "sha256:72e52b00b78c38dd76279535adbae7a80a1d2b755ecadd80c33b967d7998b988", + "status": "translated" + }, + "features/using-bpa-sample-rules-expressions.md": { + "sourceHash": "sha256:8c516b0268e11a5686c8d9e171cf7b3fe433594f88ca5b6e18c2708224c19bef", + "status": "translated" + }, + "features/using-bpa.md": { + "sourceHash": "sha256:2bae3a2476d435a2fece243326c3b5061cc80dde7f28fc53d38b4d3ebbe256ce", + "status": "translated" + }, + "features/views/bpa-view.md": { + "sourceHash": "sha256:dcccdbd044bbd074e52b5abf8e72d8ec7b714d81617f0261b49ab8a776de1670", + "status": "translated" + }, + "features/views/data-refresh-view.md": { + "sourceHash": "sha256:e7e5f52207d8c255922157fda644998d286de3dbc1a053af89e9b2566456eb48", + "status": "translated" + }, + "features/views/diagram-view.md": { + "sourceHash": "sha256:f548e5788e92d4961a62520bc56d4ea0ace76833d1f17fdbad7d74ff4cb25ae7", + "status": "translated" + }, + "features/views/find-replace.md": { + "sourceHash": "sha256:32a2e329d26e3712c1115ee0cb1965780387888d05c5af11b7bb2b056c8f3d42", + "status": "translated" + }, + "features/views/macros-view.md": { + "sourceHash": "sha256:6f8046bc8d5bcf581c147a3263fefeb4f6dbc160cccd29a0a8b09db2949f055e", + "status": "translated" + }, + "features/views/messages-view.md": { + "sourceHash": "sha256:c444a771025eab4067d298298fa8391fd1e0157c38582320b097f9d2320d4657", + "status": "translated" + }, + "features/views/properties-view.md": { + "sourceHash": "sha256:58dbde868a400d90bf5cfc1c77996b76c61c38b53de7900bd585dd91d3635e7f", + "status": "translated" + }, + "features/views/tom-explorer-view.md": { + "sourceHash": "sha256:052acb70972248b2677de696ddc8e2988ce7641af381d36d440eddea17dba98d", + "status": "translated" + }, + "features/views/user-interface.md": { + "sourceHash": "sha256:abb08b4fcdd7abc175ac495e5dc0e11168c55a249f31eb315a8093a8a260e50f", + "status": "translated" + }, + "features/Workspace-Database.md": { + "sourceHash": "sha256:356ced6221e49a7fc0552fd195c48bd3af5503e9599fe71f473d320010a27c0f", + "status": "translated" + }, + "features/workspace-mode.partial.md": { + "sourceHash": "sha256:f18c36e62b6d46dfca6ec6daba5b5e6964f9020a23b6d8683b995a6715bcf27b", + "status": "translated" + }, + "getting-started/app/index.html": { + "sourceHash": "sha256:6a2a51cf6bd96a68cdbf553d4d8d5622899b22acca9623068a5dd06b16d00b78", "status": "translated" }, "getting-started/azure-marketplace.md": { - "sourceHash": "sha256:7e5c57b8926ef7ca70b7da036ae439e63bf3559959742e13394bd7ecf6d17166", + "sourceHash": "sha256:3b2b5db9e85d9639eed51fbb91c18dead4a7feebc4e4d324804aaf41011243de", "status": "translated" }, "getting-started/boosting-productivity-te3.md": { - "sourceHash": "sha256:1dd6814ad7f714f01e4b7ca3e3c6565cd1c6deaff949c02fb6c1d2d493e95e92", + "sourceHash": "sha256:6cbd04a7b267e22d41c812a2efedda6cfc840a8c9e7146b0144cae6a1b077a61", "status": "translated" }, "getting-started/bpa.md": { - "sourceHash": "sha256:1942bcbb9b7d7f130e4f9869e1d541a12efc14d706b082ae013a016f779c9d3d", + "sourceHash": "sha256:2fe5b3652da37a39f1e8f413a6fe4b6d4a406227a941c730743ef29e579e221d", "status": "translated" }, "getting-started/creating-and-testing-dax.md": { - "sourceHash": "sha256:0650899dbd3142d41cb52eeb857e1829a3482874388865d999b577fa9fb7b704", + "sourceHash": "sha256:a44ead38de3eff665cc936036392d074088113bd7ab15be2209e587518290df1", "status": "translated" }, "getting-started/cs-scripts-and-macros.md": { - "sourceHash": "sha256:a32b793efb495fb0a23a981df218b49b34e58d0d6c2c19f2d037b8ae229887ce", + "sourceHash": "sha256:4f8a1fbaa0acc5a25fb1574c379e24f9ffaf280e74ce1bbc755f8922f6feae36", "status": "translated" }, "getting-started/dax-script-introduction.md": { - "sourceHash": "sha256:4393ece02e4cc62ad20d3ee63c1cf86866c6b70cbf5fd5124806da2adace7336", + "sourceHash": "sha256:1c500ed5798529eb123c3f097b3052dd48a97e2bb4a6fb8e2ff8a3c0660048ae", "status": "translated" }, "getting-started/desktop-limitations.md": { - "sourceHash": "sha256:77d974f5d7444f6d09d4dceb67c21946e514884fd10b2b2777d43b3693dac3cc", + "sourceHash": "sha256:4cb45a0e90a6407c47b35fda020c92600243839644ba8899d7981803abd52221", "status": "translated" }, "getting-started/editions.md": { - "sourceHash": "sha256:16752e36d2acf132ad4ce88ebcd226c065b77b11fc0ccca64612fe095709e2c4", + "sourceHash": "sha256:ada26d8ba83b53d2dd528def9455c52c3755406f44161eeb4b804ac87b2ef5af", "status": "translated" }, "getting-started/general-introduction.md": { - "sourceHash": "sha256:61337e48a6edff56d82bc3cbe0d2618d8139c93d434c1342ef312b7b8ba80d78", + "sourceHash": "sha256:4751981c02c68d5ba523befe0b4fdca2c6d9dd316ffa82839653c41c7550def0", "status": "translated" }, "getting-started/Getting-Started-te2.md": { - "sourceHash": "sha256:33dfe416723db01f219fafff7262251d16f9fda0d75570bac748a42d67930012", + "sourceHash": "sha256:07a52d7e6a05b99cee21912889a9848d74163b7b4273f46b74bad4bfe0fa9b00", "status": "translated" }, "getting-started/getting-started.md": { - "sourceHash": "sha256:28ea3283bb5bef0b4fd62eea31b3d4a5bb0d65abbe7d498451e2952d915756cd", + "sourceHash": "sha256:7468ddd7943d4547083cb4f1388d5a6d3613f6a459f4148942f04393b29aabb1", + "status": "translated" + }, + "getting-started/github-flow.md": { + "sourceHash": "sha256:1d6e200523cd43563e31dbbea87d34006012ba667d50d308cd3c336e91720db8", "status": "translated" }, "getting-started/importing-tables-data-modeling.md": { - "sourceHash": "sha256:539c1165edc406679fcb0d9dedcaa96ebf598346c49df92018cf74f52da894c6", + "sourceHash": "sha256:fd8e6ecb5d903f6515c3ec26f4e00d4e67a62dc8b5c2cdcf189f104576567b22", "status": "translated" }, "getting-started/index.md": { - "sourceHash": "sha256:1cff893340218b6b9399d31193263f541a0afd5205d2c520580069cf18b634b8", + "sourceHash": "sha256:a7a9719e3b24b4a414dfdbb77069ef5e6fefde7b83385e9ef67a5f0cb42a7c4c", "status": "translated" }, "getting-started/installation.md": { - "sourceHash": "sha256:ae80dbaf88ee37d9427ee9e0400e2ed7229530595e768027886ed6dc1fa4f87b", + "sourceHash": "sha256:89e25d98425d713ef85bc08cfe9780cffb5adf1974fc06d133233cdc76f507b2", "status": "translated" }, "getting-started/migrate-from-desktop.md": { - "sourceHash": "sha256:dc1fd7a811ed4408a7a453ec25361274f2bab258e2ea5329aa6d897c9afe04d4", + "sourceHash": "sha256:5f187bd69c968abea61375703f9c19b5e1c4674b6219878d6a18a07396f23bcf", "status": "translated" }, "getting-started/migrate-from-te2.md": { - "sourceHash": "sha256:942550423c2eb42cad3282142919fe92972d84b1f0c2cd12c4571f39422abc51", + "sourceHash": "sha256:e3b8f23bb9f7f7e7e0a911db6d0de1ad7f6479cdeed5e6093421e27ff6ce3c20", "status": "translated" }, "getting-started/migrate-from-vs.md": { - "sourceHash": "sha256:b7943609a6bab00f3299960d772ddf12a4161d46a011e5821a86e4c4560fd9a4", + "sourceHash": "sha256:2b87bf81182d2d0c645bfe354b06abf23343d2d61ff486918fd9b7055334043f", "status": "translated" }, "getting-started/optimizing-workflow-workspace-mode.md": { - "sourceHash": "sha256:b549dd9c660da15116394520e2c7db8a1167f1062a90964f8a078fac0969f7fa", + "sourceHash": "sha256:be881580d2cef038fc74e503591f8110998d40922a7c30bc15936ad7a36dc823", "status": "translated" }, "getting-started/parallel-development.md": { - "sourceHash": "sha256:771e22a14e4149f7d32da22a47ba73440b81a61564b1de976f16520c6e98fee0", + "sourceHash": "sha256:0101f8323927cee6cdd2a67c5b3f1384e1481e7648eada619b7ab3e0e0ba1b2b", "status": "translated" }, "getting-started/personalizing-te3.md": { - "sourceHash": "sha256:af5c6881e93c82a790f36c1cfd84dbf7456c320e067a207193b7d59a40e5773b", + "sourceHash": "sha256:6cb01ce0ac5ecd6bb4b231cf54937781e523805c024fe96a2c5f4492e39fd951", "status": "translated" }, "getting-started/Power-BI-Desktop-Integration.md": { - "sourceHash": "sha256:7c23b5811b153da076659cc097d65d04eb73794464942c3058059346eed3860f", + "sourceHash": "sha256:3e22d21db3ea34088cc114527f0a21951630fd0a37453ec99d5ca0d8f19fbf8f", "status": "translated" }, "getting-started/refresh-preview-query.md": { - "sourceHash": "sha256:1b95ce652f9fe220ce0baa2bdb2e0de95e1746729d9d6f4f1807717b04f6b804", + "sourceHash": "sha256:9b245c1a7ecdb7de7738329b2da2f3452e96665b7f7778316d78039cd4c1a3b1", + "status": "translated" + }, + "getting-started/sharing-macros-bpa-rules.md": { + "sourceHash": "sha256:96f6211eee67f507f4c31db2ebf9b3726cdf92ccfae790c64a8a7a40e16202e5", "status": "translated" }, "getting-started/toc.md": { - "sourceHash": "sha256:e2f87443d4ec79c44180a2e305fdf21ef9361b8f2f97c9b5f4f30816cf64727e", + "sourceHash": "sha256:6f0f1913443119bf78a723b94748f4baa411175f4da79da34ba60ebb3ffae4cc", "status": "translated" }, "getting-started/training-telearn.md": { - "sourceHash": "sha256:d58ebfdb0ba3721250865f314b7e234d07d60883edbd87ae9abb53dfbe6c54d2", + "sourceHash": "sha256:cba2b907335edf7e1310d6323c063305cbed3c66013758b932d91466394b4d85", "status": "translated" }, "getting-started/views/bpa-view-reference.md": { - "sourceHash": "sha256:b1b9af1605783a0d1a7215c3cbd463444d6e870ed8e31edc952c328e73f9996c", + "sourceHash": "sha256:15ca9b6b41ad2b4a120b6ef5ea17c4474e72fa68375e5539654e662057eb2383", "status": "translated" }, "getting-started/views/data-refresh-view-reference.md": { - "sourceHash": "sha256:2b0531afa0df2babacda9b8b0f942b626195ec5a6034aa9a158250d2168e7a7f", + "sourceHash": "sha256:5ae489e72f396630aec7b81960492180ce2359df98a403503cc7613fd2f22049", "status": "translated" }, "getting-started/views/diagram-view-reference.md": { - "sourceHash": "sha256:7d20597c914bebc07cfa6f3937a8183d152b8ba642e70e1cf8a6b041edd2a2f7", + "sourceHash": "sha256:00d1bab69592c55957a4dbde3661e24689af0bb471e4e982d0723c4ed8122127", "status": "translated" }, "getting-started/views/find-replace-reference.md": { - "sourceHash": "sha256:eea63df2e08bedcae9c7bc5f00fb21386cd95c9171ea8ad89f87f959c05a26ad", + "sourceHash": "sha256:a207ceafc5c8e3d3a218c80d405fafc3c71a9d9491964366607ee205e28a2b69", "status": "translated" }, "getting-started/views/macros-view-reference.md": { - "sourceHash": "sha256:88d73c55965ca306db4ad3e379fa987063c89c17c95ebed50e83d6240b9cb71a", + "sourceHash": "sha256:684b3ae341bc7955f0f304e04782f7b80505ca4bb2490a7c91e99e19439b743a", "status": "translated" }, "getting-started/views/messages-view-reference.md": { - "sourceHash": "sha256:a68d178caeb8eada8179a539ae061a3e5be49562edd30e5d95f154f749de8bff", + "sourceHash": "sha256:b4e921bba59577a9f8566439d26b22c2fa5e6dd7abc73ca5dee661d2d659c627", "status": "translated" }, "getting-started/views/properties-view-reference.md": { - "sourceHash": "sha256:f15e10357990e0871d0c6d003f3e422574ce05503a20e6ee1f8c585cabc45d53", + "sourceHash": "sha256:6915dedc081501314e88a8e8700a946ec5217564c2b12e8aa45b3b4a54ad2a81", "status": "translated" }, "getting-started/views/tom-explorer-view-reference.md": { - "sourceHash": "sha256:cff7e065f5ad8926552d64ce6170e782d999e24f3894e0b0a7e2f9858a801a7a", + "sourceHash": "sha256:0531754556357e77414ea097c8e89932c87f7e39ddcc33ef9cf3677653b50564", "status": "translated" }, "getting-started/views/user-interface-reference.md": { - "sourceHash": "sha256:2725adb9a676940d5e4b83c68c4989d2f40d503a83a216397840857874398563", + "sourceHash": "sha256:db5941a9366d706a595db7c01c82659fae0e0372be1a7e12c9d8061bfbde1e06", "status": "translated" }, "how-tos/Advanced-Filtering-of-the-Explorer-Tree.md": { - "sourceHash": "sha256:95d4c41f0fdc8f73ebbdde8c9829278f7e6cd01264c25c5b46763c225a0ec988", + "sourceHash": "sha256:935a9dc732e0fd11e5550a6c4472d35a42d3ee1a381586d693ac7a5830542903", "status": "translated" }, "how-tos/Advanced-Scripting.md": { - "sourceHash": "sha256:631d59ddf5d4bd7a39db9f62c2c11b1376e1933685da763c8b0992bdf982e370", + "sourceHash": "sha256:a7c6dd9a5fc927f309f47ffb69b335996b503abf3cde9271c94a5afd5e366f21", + "status": "translated" + }, + "how-tos/change-compatibility-mode.md": { + "sourceHash": "sha256:3fbf4c15fa1139dceed32d02cc4c59e4d46c17e8bca55704057158be01f44300", "status": "translated" }, "how-tos/connect-ssas.md": { - "sourceHash": "sha256:c2dfe9827a42110e7d25cfe9c3ed7c4a04ff7c2990d1ed10f64ab3c551037b16", + "sourceHash": "sha256:738df17f3a1ad8792884661e7b0ba408872170bc4180a239abfc66c2f5081ed5", "status": "translated" }, "how-tos/deploy-current-model.md": { - "sourceHash": "sha256:6ab8db3b6bcc1757ae8d0fdd51133ddc53334d3d7a78c45c7c4085a3f652027d", + "sourceHash": "sha256:834b504afe131332faeb2a705df8fb5f7df3f5137cfbc335d44b77a00b527930", "status": "translated" }, "how-tos/drag-drop.md": { - "sourceHash": "sha256:b3b4f89d0bc2b76a905e868d06d50917d81f8d4d64b3b1b5002dbfdf06fa6e72", + "sourceHash": "sha256:a7209f5254e6988a92bdca532d3a375cc82d134739e432a1970a6312814b8027", "status": "translated" }, "how-tos/duplicate-batchrename.md": { - "sourceHash": "sha256:0da1c04c8e0206882f716e3957cf86dae92500d4f6b6d51c7c7e44e5d33b3f54", + "sourceHash": "sha256:8fd7332b2b836c9e4358bc944b07c62c7fbea09ef3abd896653e05fed4c937cd", "status": "translated" }, "how-tos/edit-properties.md": { - "sourceHash": "sha256:e3edbd6a49beb9bb75a2d2076e51bea33227202ea910b070cedda099c17618f4", + "sourceHash": "sha256:36d9f419afff9ce4d1ba814f1a97ec3c825e569dc0698c82d6ccec72380cbabd", "status": "translated" }, "how-tos/folder-serialization.md": { - "sourceHash": "sha256:8b41e78bef09301284841690f12002ed1ca983c845d3b1d8b1b6cc85cb73c5b1", + "sourceHash": "sha256:6b4dae8fb419f7efb1d3348211d95f6febe409c9f6d860985f8f14a8beee052a", "status": "translated" }, "how-tos/formula-fixup-dependencies.md": { - "sourceHash": "sha256:cbb583c5453297aa76e267f86984587c0947dea569d937f291d54fc0921ddf23", + "sourceHash": "sha256:7b92d3f7a82312c06fe839e066df04411aae4a3d0aadf94aea44cc3b1934efb2", "status": "translated" }, "how-tos/import-export-translations.md": { - "sourceHash": "sha256:ee2d4bbed5fad42c661ef39838e0a62ea4a9459a7350a949b4c540569b77f6eb", + "sourceHash": "sha256:e360d9f6fb69f4ed660b150bc7cdbeaad188998f9addaca07a1ba95880158fbc", "status": "translated" }, "how-tos/importing-tables-from-excel.md": { - "sourceHash": "sha256:54be367cdb3652a1f329935b5f663df6076a420b1d1782b968fc3f467a580fe3", + "sourceHash": "sha256:6a68727673ad603d2091784ea68b0fd13ebc1eed7c5e279366841237e3319e9a", "status": "translated" }, "how-tos/Importing-Tables.md": { - "sourceHash": "sha256:17f8530c0bc0109b8e199fe56d36919fc24697fc437674e7728fadb353c878e5", + "sourceHash": "sha256:d50f21da67fec34065c172f1e57cc8479e6d98900fd1aefc05b2f9913213e3ab", + "status": "translated" + }, + "how-tos/includes/sample-metricview.md": { + "sourceHash": "sha256:fccd99fc0bee912d39f879757f5b18d263bbe88d130d534fe5663283a2351dad", "status": "translated" }, "how-tos/incremental-refresh2-h.md": { - "sourceHash": "sha256:b7cb1ce87b83b31c3cf44af327adf14324cbed8448fcf15223121c3bd083f819", + "sourceHash": "sha256:f784f8946b99d76865d5180b766c6d5c934b79ce89b6a15aeb99ed4e124c799d", "status": "translated" }, "how-tos/index.md": { - "sourceHash": "sha256:d6ca415819641e338d7d22d2eaabd75a2e83585fd226537776c6c00a22bb0f92", + "sourceHash": "sha256:602751d2958e61e1f0f0fbef21e254551b3e803070597384104c8689787809bf", "status": "translated" }, "how-tos/load-save.md": { - "sourceHash": "sha256:5999c48324037425d2cde3d8f82aa9afd0d419c2401c9605e31b5df0973bc2b4", + "sourceHash": "sha256:16972c8e9dc5861cafebda87d9c646fa1569dd9282d4f46049b34c700a9124a0", "status": "translated" }, "how-tos/Master-model-pattern.md": { - "sourceHash": "sha256:e6efeec6d7944d0aff773e9cc4219cd255af65bf23cd4b1aa39553d63ba033c5", + "sourceHash": "sha256:00f0f58661780f3001fc5ea01d866a6c7e05c95b1ffb10e2b4ebdec19fcb7eae", "status": "translated" }, "how-tos/metadata-backup.md": { - "sourceHash": "sha256:8c842b9fb0eb88fbcbc09a20036e1d21f6d6e48a6cf882141e2bc63cb759e329", + "sourceHash": "sha256:2e09520c1a8cc63a14d2bb548106dc094e24f105ea47b9adb2cbb67259f9ca4d", "status": "translated" }, "how-tos/perspectives-translations.md": { - "sourceHash": "sha256:6b640688ab87fee2e73e02776f4042efffa3f1bc007b0e607214c89a3386e16f", + "sourceHash": "sha256:d982fc9f33492df087251ffccfb498a4e482f5c49da5aed8139af5e591f9a1fd", "status": "translated" }, "how-tos/powerbi-xmla-pbix-workaround.md": { - "sourceHash": "sha256:032818b434b6b69fbe8db47914cee6c58a56d00cb279e47da3e99129c9f45315", + "sourceHash": "sha256:abb8737c6f6ea6d42eaa8303312d659b2ba8f4c4bf63eb0c17cc2de60be02c3a", "status": "translated" }, "how-tos/replace-tables.md": { - "sourceHash": "sha256:a71712e15de2cfb89a52c8022246743617bb75c97a025c6b0d2f02bb1569e80d", + "sourceHash": "sha256:bb2c09a2091a99c069bd2f206cefe471732284e7bd838b3a33047571ccacda73", "status": "translated" }, "how-tos/roles-rls.md": { - "sourceHash": "sha256:0f3e7a0ecf642bd55eeb6865d7c881563cf8e2f7708517172590def678060540", + "sourceHash": "sha256:fae9d351ead61fb6b1286401bb9f38c5e23d9cc427d1c1aab5bf351059afe56c", "status": "translated" }, "how-tos/script-reference-objects.md": { - "sourceHash": "sha256:486150d7fb6512e0038b7aa1d95480bc1dd4cda5116e9ca99e0ad75c420a57d1", + "sourceHash": "sha256:f4807e41f2785b359bc6b9875519aaebdca84da06415bea5a6cd09179a59e18e", + "status": "translated" + }, + "how-tos/scripting-add-clone-remove-objects.md": { + "sourceHash": "sha256:4d28cbac2e46174af4ed28927c660275192fb013621c5b2d37c0dd5563f95f07", + "status": "translated" + }, + "how-tos/scripting-check-object-types.md": { + "sourceHash": "sha256:e5fb737f603fb9234e8177ce7d888e6f73219b3823561bfea0d2db110e9788c0", + "status": "translated" + }, + "how-tos/scripting-custom-winforms-dialogs.md": { + "sourceHash": "sha256:9830c8cea603b15203226db2df1e217209cc5b78e67c14fbbe11f9cbc1f229e5", + "status": "translated" + }, + "how-tos/scripting-dynamic-linq-vs-csharp.md": { + "sourceHash": "sha256:e989800b165f6c5b86076bbdb28e749cd7e882f7be7ee0f72f9e3924634abbe9", + "status": "translated" + }, + "how-tos/scripting-filter-query-linq.md": { + "sourceHash": "sha256:a269be822be81370fd10a982730d6eb1f47bc3ed2cc6081a28fd7b4428c632fc", + "status": "translated" + }, + "how-tos/scripting-navigate-tom-hierarchy.md": { + "sourceHash": "sha256:f12fd751688eab6151b40579ac6e2840657d4f8693364facbef5bcea131e9f8f", + "status": "translated" + }, + "how-tos/scripting-perspectives-translations.md": { + "sourceHash": "sha256:0767acd259fb03acba4804f0ce3f4f31d6fd03fb5313b000bcd7b292835ded09", + "status": "translated" + }, + "how-tos/scripting-tom-interfaces.md": { + "sourceHash": "sha256:73b571f60ab78fc67b7523f78e157a21127856a6570e53a9a101f344d2117f3c", + "status": "translated" + }, + "how-tos/scripting-ui-helpers.md": { + "sourceHash": "sha256:23b920a830d6bef0afa9fa6041fa3453824b50057121e5f4a6c2e92470fd00e9", + "status": "translated" + }, + "how-tos/scripting-use-selected-object.md": { + "sourceHash": "sha256:430cfe77c38d7718c3908af2f7757e19b10fe2c1d5191c9113bc1c4c85fd286a", + "status": "translated" + }, + "how-tos/scripting-work-with-annotations.md": { + "sourceHash": "sha256:31f333c1fa02b5aa1b844b5e6b391f56f13b59fb4d3b6c0c4ef55d5a9a49673d", + "status": "translated" + }, + "how-tos/scripting-work-with-dependencies.md": { + "sourceHash": "sha256:7a2fc4475716bdb80bb7d1c2ce4264276ac3be2b8e474bb0b43659c549ca7ca9", + "status": "translated" + }, + "how-tos/scripting-work-with-expressions.md": { + "sourceHash": "sha256:a294b1cad950289516b26454aa616a985abb037bff7f767fb17bb4ef56c77a3d", "status": "translated" }, "how-tos/semantic-bridge-add-object.md": { - "sourceHash": "sha256:47cebddcb3457d0c355fd207c68de6fb980ad1e9b864ec39d80ca64aac861217", + "sourceHash": "sha256:fc45b9109e248917012968eaed3d31ab2731c749be09ea3a3604317771dd6761", "status": "translated" }, "how-tos/semantic-bridge-how-tos.md": { - "sourceHash": "sha256:ebc5e0ee2c2688b218ad90eb3ab2882183651deb6e797058bc34192e3850602f", + "sourceHash": "sha256:3e7254cf0f1574d7a0b75109d152b36e4911d1ff417a4691b1bcafc115fd175f", "status": "translated" }, "how-tos/semantic-bridge-import.md": { - "sourceHash": "sha256:f6f20eecc7756a8ef3739106b3ebc82ab85ba4282fee87701ecd8b78d415f397", + "sourceHash": "sha256:5e44456f341235b172a178e01ac6c0ecfee7d3b6a4e4d955ba240a2ee4d0cb47", "status": "translated" }, "how-tos/semantic-bridge-load-inspect.md": { - "sourceHash": "sha256:c4903b1f3dae22e24ef7401892ef4ec18d284493b19217a92095a6ec02e774d2", + "sourceHash": "sha256:5267a8486231514b4500cae234bf91aad00ecd2ccb5ae44389f46fb01b6a3de3", + "status": "translated" + }, + "how-tos/semantic-bridge-metric-view-handle-failures.md": { + "sourceHash": "sha256:67ad4305aa673c9d258a13b33b0cfb7e00d23a22ab410f4abf197d80676a7a3b", + "status": "translated" + }, + "how-tos/semantic-bridge-metric-view-import-from-file.md": { + "sourceHash": "sha256:93070e6a840581a2c023da6990574598fba958968ef7c0c7fab22b489ae793e4", "status": "translated" }, "how-tos/semantic-bridge-remove-object.md": { - "sourceHash": "sha256:0201f69d14698b005e06d60f6a721b3bc371d83bbc885944df38cc82e58aeb10", + "sourceHash": "sha256:4d3b3a064617e5b8792d448d6bcc6bfca71e449c7eb168e8958466096fae4fb9", "status": "translated" }, "how-tos/semantic-bridge-rename-objects.md": { - "sourceHash": "sha256:980236af341a7130899bb8bfa1655636edef998c95ba27d0552d504b1039c735", + "sourceHash": "sha256:2b741ad175e8be66503950d5aa5de558e2ce01b93371905faa8a8ded3bff914c", "status": "translated" }, "how-tos/semantic-bridge-serialize.md": { - "sourceHash": "sha256:2ca79bc033307934ac2ee7223639b8b71d7c7dc76fdaf5d13ec3d638d279bb40", + "sourceHash": "sha256:27ca90dfaf274d3d7933c2bb5839e2f6f28d213692e91b9e96d0b0199ac3174f", "status": "translated" }, "how-tos/semantic-bridge-validate-contextual-rules.md": { - "sourceHash": "sha256:2c8475f1c9b0d4169ff21e493fc47e4c03eb16fd06aa88b6fa9065ef2a03a2a9", + "sourceHash": "sha256:d29e2a536f0c2211f2f2a81896b281b994922d6ed8d82d6aa218fe2432c9b3e0", "status": "translated" }, "how-tos/semantic-bridge-validate-default.md": { - "sourceHash": "sha256:744cb9028b5d70ec6146649d98c6e5b2b909abfb6dd76addde90bfe2f9534c80", + "sourceHash": "sha256:c580f957c8083de4a78ffd1b6c7c97801c7c39821d1362205f014f0d075e11cf", "status": "translated" }, "how-tos/semantic-bridge-validate-simple-rules.md": { - "sourceHash": "sha256:871544d36d746d980df2cce373ca58961a3b21a612e28361e5ad5af1f37fda0d", + "sourceHash": "sha256:8022c0d65d92617604199afbf23383d9a1f8ab74184565435f0f57b0d6cbd46e", "status": "translated" }, "how-tos/toc.md": { - "sourceHash": "sha256:1b2f757b9db44d77156204b5fa006f78828eba9bc76fad02d97f54ece3cf3bf7", - "status": "translated" + "sourceHash": "", + "status": "untranslated" }, "how-tos/undo-redo.md": { - "sourceHash": "sha256:e03d98c7fe13f0ada9d6a887c417798a510ad82916e7b2c4e98c498a2d5ba69d", + "sourceHash": "sha256:743d09f92c48856bd0a069bc81d94c43038951d3b31baf2a6ac48495d705ca7c", "status": "translated" }, "how-tos/update-compatibility-level.md": { - "sourceHash": "sha256:6ff9fa9aa0a1979456abec53ec14addbf3c00a188bc8426adda43333e4ce1701", + "sourceHash": "sha256:db1e5f011e9012548630f4dc75358331f1a2b50b4652693feb145c2e685b9162", "status": "translated" }, "how-tos/xmla-as-connectivity.md": { - "sourceHash": "sha256:cf80a9d3d83420cd977af328fc21c63066ee28a7e12665b277d866f1e280ff40", + "sourceHash": "sha256:2c7134c5882b2901561935c89eea4f27f3d45534385e02b7d92d58416d269cda", "status": "translated" }, - "how-tos/includes/sample-metricview-deserialize.md": { - "sourceHash": "sha256:3f4bfcdeebb12a2cc7395441588f12fcb79ac7c0cdd6df4d0fc81193cf80419c", + "includes/feature-comparison.partial.md": { + "sourceHash": "sha256:46272433823ee39eb87e0f8592c46465ea0f0c8dd0bb9c12a67a409e81dc114e", "status": "translated" }, - "how-tos/includes/sample-metricview.md": { - "sourceHash": "sha256:41ed9d676177a7c23aec90bfbe9e703e7a18a1498009cc26b916805bca31ac41", + "index.md": { + "sourceHash": "sha256:1f4253085ee1a9650ad521ba52e0ec8371938577fb6b5369cd0f6b30649fea75", "status": "translated" }, - "how-tos/includes/sample-metricview.yaml": { - "sourceHash": "sha256:cd42d73830a347784d89cceab989764ccf168f965987ccb183dee2095b5cf464", + "kb/bpa-avoid-invalid-characters-descriptions.md": { + "sourceHash": "sha256:348a0b21b9c03d6e496e239a00cfdc81d39610af0fd5ad6de6fe1ff623f7ceff", "status": "translated" }, - "references/application-language.md": { - "sourceHash": "sha256:2b145c41ce53e4fb5013008471d79d4f7fe0602e4bedff220be0fcaa3028ee7c", + "kb/bpa-avoid-invalid-characters-names.md": { + "sourceHash": "sha256:8f013bf4faea84f9a24756e185ded303c9807c17b4ac5eb76df448b810e07eb0", "status": "translated" }, - "references/downloads.md": { - "sourceHash": "sha256:443087bae7d1bdebac65c03f355e1152f85d5d135d4f0783f6b710a6fd8941aa", + "kb/bpa-avoid-provider-partitions-structured.md": { + "sourceHash": "sha256:86ec65070d81d5063e4d173d7c8c0966853f9b6694bbd8e726b468e7cb8b01f5", "status": "translated" }, - "references/FAQ.md": { - "sourceHash": "sha256:821216efcbde4b1e629a83a1979c77a4cfd9f3b5c1f0ebcd273e3d828311d29b", + "kb/bpa-calculation-groups-no-items.md": { + "sourceHash": "sha256:744c11cb3577957cc223454875f5ea95726fce0a94970698756f47b0a46c86b6", "status": "translated" }, - "references/FormatDax.md": { - "sourceHash": "sha256:1b225bea6ccef4ae3a996a02443464307999ea958824b62b7445206245daf7e6", + "kb/bpa-data-column-source.md": { + "sourceHash": "sha256:a94e71eb040d34b57ad1f867e09460c23bf2d2e59ac848fdb92af82237851292", "status": "translated" }, - "references/index.md": { - "sourceHash": "sha256:2a1ed8e5bca82a8c8f180ba910dd7fc87ee891adef3d59234248f751545da10e", + "kb/bpa-date-table-exists.md": { + "sourceHash": "sha256:de3f26c0b6618516bdc57f3ea4575d35e22ff6dfe2adc856c77b6075b26842e1", "status": "translated" }, - "references/Keyboard-Shortcuts2.md": { - "sourceHash": "sha256:e0807f2e3f258f1c0d62555107041dfbaa17650d7150dca186ef237431af4ced", + "kb/bpa-do-not-summarize-numeric.md": { + "sourceHash": "sha256:244d8e116c98cab2126d5fc368471e4f432912e9e19181f58227d94b8322ddb6", "status": "translated" }, - "references/policies.md": { - "sourceHash": "sha256:9092b34841be3fdcf0021e529713a49d47346556b06073b3f81407ce15503c95", + "kb/bpa-expression-required.md": { + "sourceHash": "sha256:8a411171214efbf1a8fd9da1be301ee6ca0f09bcadf38ce54082acf21984346b", "status": "translated" }, - "references/preferences.md": { - "sourceHash": "sha256:6fe02d486971ff696c28b22e9e2a88662bb67d41cce4099342a1ba0f23c3089f", + "kb/bpa-format-string-columns.md": { + "sourceHash": "sha256:bf422113bd43cb69befbac033445b65b29a8569d9621a1c91eaf082a26047a9b", "status": "translated" }, - "references/release-history.md": { - "sourceHash": "sha256:30f7a485e3e60ebd809664c4b93f58c8598b0bc7d0fc4bc3715305179c0ed133", + "kb/bpa-format-string-measures.md": { + "sourceHash": "sha256:bc6cd3c9bb8d66074e6f7836e4f2dd33f650df390bef17394e5cf385e744fce3", "status": "translated" }, - "references/roadmap.md": { - "sourceHash": "sha256:dc7eb645cafbae20584b9422f22de6bba82c6c86aaaa41c3922f430b29d962d3", + "kb/bpa-hide-foreign-keys.md": { + "sourceHash": "sha256:9b67226f8b72ed7990a1c46dba893bcd36e775169fc562099bb3a8e17c47dace", "status": "translated" }, - "references/Roadmap2-h.md": { - "sourceHash": "sha256:269e6b5b5fc980f70aa642ad6882ed01909631c2afafe6daff399e6efdbea8f1", + "kb/bpa-many-to-many-single-direction.md": { + "sourceHash": "sha256:caf01f0d64fd87d6cd67e3247c059c9a02b366c4478ad2a36ebcbea14228c261", + "status": "translated" + }, + "kb/bpa-perspectives-no-objects.md": { + "sourceHash": "sha256:8c2e025fa682be0443b0d926d49eef193f807af7d1df74736dcbe7d867140506", + "status": "translated" + }, + "kb/bpa-powerbi-latest-compatibility.md": { + "sourceHash": "sha256:21ff822045d3a8bcd70bab6945d025c6ed8285c153ed739d7759ac552f056830", + "status": "translated" + }, + "kb/bpa-relationship-same-datatype.md": { + "sourceHash": "sha256:291f46cc5c6ef8980aba3c960edd1e0d6d42de202f390d2ada4d4ec71342521f", + "status": "translated" + }, + "kb/bpa-remove-auto-date-table.md": { + "sourceHash": "sha256:2328dbcb9c6571901c1f6565f73963c973556285e52765be67bf4d2efb3436e5", + "status": "translated" + }, + "kb/bpa-remove-unused-data-sources.md": { + "sourceHash": "sha256:58bd7d8482b7e17548d3b8f525c0f21c6648e2872815daadd8e5c1d84443f189", + "status": "translated" + }, + "kb/bpa-set-isavailableinmdx-false.md": { + "sourceHash": "sha256:9a5934a34ee1b4c911d855a78e87163a5427422d1ee59093b47d9c407f3bc50d", + "status": "translated" + }, + "kb/bpa-set-isavailableinmdx-true-necessary.md": { + "sourceHash": "sha256:9f1148b29e5e6ec221f24f0c4998e2da7d4a3955d23fe911dec5d63401e1094a", + "status": "translated" + }, + "kb/bpa-specify-application-name.md": { + "sourceHash": "sha256:3de1a94d6a0b66003b65d5b06669ed905f792c3151cc36117abc73c10f52abcc", + "status": "translated" + }, + "kb/bpa-translate-descriptions.md": { + "sourceHash": "sha256:8c73f97d93d7f0b5635b31d7e96bc5464f15f7cfc06507b8ca549edeae0cd374", + "status": "translated" + }, + "kb/bpa-translate-display-folders.md": { + "sourceHash": "sha256:10c7a4f9324e0de5c4c122ea7d10a2b6bb4e412a2f435802fc5e43d30dc65c53", + "status": "translated" + }, + "kb/bpa-translate-hierarchy-levels.md": { + "sourceHash": "sha256:27062bb6d7edfea8f0d78f3a789b985b18cc844690d29bc4127d55b66cac466d", + "status": "translated" + }, + "kb/bpa-translate-perspectives.md": { + "sourceHash": "sha256:8dbbdd6fd6f41cab313d51334c38cb91cdbb7830c560472a5abbdb39d40a7525", + "status": "translated" + }, + "kb/bpa-translate-visible-names.md": { + "sourceHash": "sha256:48c6cc3b4998a6ca24f517a88b714f5e92c29add04010e2c607faca34c45d9da", + "status": "translated" + }, + "kb/bpa-trim-object-names.md": { + "sourceHash": "sha256:f9c4103682dd64f14587c68948264b3049aa3fcdb63997d7ebd3645075c4aa5a", + "status": "translated" + }, + "kb/bpa-udf-use-compound-names.md": { + "sourceHash": "sha256:6a626e28ba35b857383cbaf6069708880026531ad2dba4faee0a09cb52bb102e", + "status": "translated" + }, + "kb/bpa-visible-objects-no-description.md": { + "sourceHash": "sha256:be6aff3d1618ad140b7be83ae59f97756572de2bc8b84eb5e035953f39642abb", + "status": "translated" + }, + "kb/DI001.md": { + "sourceHash": "sha256:0fdec75eb29be6ad908093cf6a95b9ab331bb568382772bf814fcc919a2dfc2a", + "status": "translated" + }, + "kb/DI002.md": { + "sourceHash": "sha256:068a81baf4c2c490ef06e51cfb9886723242dce676a9edea26e0948c0b00f702", + "status": "translated" + }, + "kb/DI003.md": { + "sourceHash": "sha256:b8e00a8116d08be012100d264b75fec171eedfc043a1bc52b1f2cf074c2dd0e1", + "status": "translated" + }, + "kb/DI004.md": { + "sourceHash": "sha256:c61d11bc3eb6a5e005f9c1b6f3fccdde21252abca2ffa9b4a181cfdd8ec7dba7", + "status": "translated" + }, + "kb/DI005.md": { + "sourceHash": "sha256:8e03cd78771896db6b9dfb81a57e0a45f42335d6b242e0d474699da70843de53", + "status": "translated" + }, + "kb/DI006.md": { + "sourceHash": "sha256:96086b12841c4fe8365a83fc91fb167c81d915b2c98e448489bdf55773167704", + "status": "translated" + }, + "kb/DI007.md": { + "sourceHash": "sha256:78dd569b532ba7d5cb0efe9c96e5623604c5c9dadd67949d65ad14449832c1b7", + "status": "translated" + }, + "kb/DI008.md": { + "sourceHash": "sha256:5521d0162fdacf2da404c49d5715d5124f4fa682f793dce4df3d7b952519a2bc", + "status": "translated" + }, + "kb/DI009.md": { + "sourceHash": "sha256:790d74322a9d163c6acb9bf5d2b0e45352b85b954d68f2c1bddb06c4600361b3", + "status": "translated" + }, + "kb/DI010.md": { + "sourceHash": "sha256:bf5c57cf42365ee7869cb9f4d24c5ca20f96f79f73007a587b9034d25bb321df", + "status": "translated" + }, + "kb/DI011.md": { + "sourceHash": "sha256:805dd2b2e2e754ef66ab07bba08fa0755c519e7432af9e83fbf4490a49f68924", + "status": "translated" + }, + "kb/DI012.md": { + "sourceHash": "sha256:b9934cee7d4ea8c47cb70de48454918e64326fb8f76bee17db773b0f32627462", + "status": "translated" + }, + "kb/DI013.md": { + "sourceHash": "sha256:19a2309ca26b9eedf30cde50cb70f3f70e8d8aa786ac98fc3e7bab74143d54e6", + "status": "translated" + }, + "kb/DI014.md": { + "sourceHash": "sha256:be9a88c83f9dfa328b63298e7adf2a809b2cd83ea03cb14886838ac891536f1c", + "status": "translated" + }, + "kb/DI015.md": { + "sourceHash": "sha256:c1c69cade3182935031f846cbe3f4165a4fd453ca77012e76f4f8cff1de2d3c3", + "status": "translated" + }, + "kb/DR001.md": { + "sourceHash": "sha256:ad922ebf457863f55e34000fd78fc007c0d69f9bee556d2f79b5cd27c03273fa", + "status": "translated" + }, + "kb/DR002.md": { + "sourceHash": "", + "status": "untranslated" + }, + "kb/DR003.md": { + "sourceHash": "sha256:b76298c84d8c1269703dba2770d51a6674dfa4f03979e5aa71a53a456575bb49", + "status": "translated" + }, + "kb/DR004.md": { + "sourceHash": "sha256:d370d93822297f2614b44a30c04d330e5feef34160b080eb9ad3e019c70c7f6b", + "status": "translated" + }, + "kb/DR005.md": { + "sourceHash": "sha256:5b1451993738cd1b1737d47e96f328e5899f14028c161fe7c92357d74bfd29da", + "status": "translated" + }, + "kb/DR006.md": { + "sourceHash": "sha256:a91536c340fc07b1293e3a2e8cd4e89828d9fbdd8bacf3b23a736cfb8314e329", + "status": "translated" + }, + "kb/DR007.md": { + "sourceHash": "sha256:a03a20219e9ed635de09831705034fe148912a694c50d8d36b87b4efa26ffbd9", + "status": "translated" + }, + "kb/DR008.md": { + "sourceHash": "sha256:980a8345025fe79ac9cb8c3f841b204702f60dadb9574d72b33ba229cf9a475b", + "status": "translated" + }, + "kb/DR009.md": { + "sourceHash": "sha256:8609e608aaebfce4391671248f28d02870da055a99c90e126d56c4b415ceabb9", + "status": "translated" + }, + "kb/DR010.md": { + "sourceHash": "sha256:76c8d124d90f77650b62cc3b03402177ef55f25898b032bc7b94180130778485", + "status": "translated" + }, + "kb/DR011.md": { + "sourceHash": "sha256:ddb675bac420ba94a6a2eef2f7ab9cb4c9981b23398cc2a984a5cd30197743d2", + "status": "translated" + }, + "kb/DR012.md": { + "sourceHash": "sha256:dc8c217235ef82b45ca02328f0e1d78ccc12114dddd5a44394126ec3cd638a61", + "status": "translated" + }, + "kb/DR013.md": { + "sourceHash": "sha256:3e81af96332221c3b1f36655a6ee3f8dc0b116c019cdb7a5c26c5971c9cfc51f", + "status": "translated" + }, + "kb/DR014.md": { + "sourceHash": "sha256:d435c5959eb47b00a7aab3b846013228ec6d2077a3dc245e50c1a5328c392dbc", + "status": "translated" + }, + "kb/index.md": { + "sourceHash": "sha256:8331987f38c8b52d8907a3f0d2c773d188e888cc823f2a5a21463d0a163f7a15", + "status": "translated" + }, + "kb/RW001.md": { + "sourceHash": "sha256:cc6cd9a2294f425380d825ff0a5ca882c3bd40a4d5207cf6cce0793f7c6af493", + "status": "translated" + }, + "kb/RW002.md": { + "sourceHash": "sha256:597595d6f8a1102114f51ae992bb519d21095f2a1003a8b1a90d3d96849916c2", + "status": "translated" + }, + "kb/RW003.md": { + "sourceHash": "sha256:289a445b62c13098e4ed6af7fa37901b1aec8bfb4d3b57e1e42eda8f1de9ad5e", + "status": "translated" + }, + "kb/toc.md": { + "sourceHash": "sha256:db0f8007f8bb5998ac188559ffcd9831d49a8c938f1b41434295625097a0a160", + "status": "translated" + }, + "references/application-language.md": { + "sourceHash": "sha256:3d8bb1e49bd7cf97c56210ab5c8322b7c419cbb0152966f195cabeabb87d1ded", "status": "translated" }, - "references/shortcuts3.md": { - "sourceHash": "sha256:c58efcb0bfd40d9a32809349f54a791c86ee5a501718c4aaca7b72733e74347e", + "references/downloads.md": { + "sourceHash": "sha256:0bdbdf1e0ab65e52a9bb7b697b55057605c5911322c4613269f250dd3609a632", "status": "translated" }, - "references/SQL-Server-2017-support-h.md": { - "sourceHash": "sha256:db00c8642b2526adb31b4dfe2c7e56cc0040c8c34fec031125f7064bfa41b63c", + "references/FAQ.md": { + "sourceHash": "sha256:bf29800bdcc5ace4dd5d3813fc1fb8c48b30a034039a0285bd62768866b924e9", "status": "translated" }, - "references/supported-files.md": { - "sourceHash": "sha256:79abc83bd95b9f176b4567e47e482ce1100ad12913143dead97f7d0b03ba3720", + "references/FormatDax.md": { + "sourceHash": "sha256:d6d07781fc5c8cbecb8187caa6123eb0a13f0f145abfa4b8c4cb1274c7e9dcf2", "status": "translated" }, - "references/TabularEditor.TOMWrapper-h.md": { - "sourceHash": "sha256:603e7d30f3b77493a42ed2c269400c4139617bd026588328a78dfb16c92f317c", + "references/index.md": { + "sourceHash": "sha256:a7562663ae2f94db4f055c883a66e6a28d911925ae9cb1e9aea2fe1de791d569", "status": "translated" }, - "references/toc.md": { - "sourceHash": "sha256:2a8242ed589d5a5c1726553305054d7aa2a004cbd758bd270534ea0f26500621", + "references/Keyboard-Shortcuts2.md": { + "sourceHash": "sha256:6dac63cca6fed07aeeaa4c9c97b9174104c299528f823785cb9d042fc567f93c", "status": "translated" }, - "references/user-options.md": { - "sourceHash": "sha256:3547b53ce1fe8d2e177517d1937921bd4b8320f09ed7434e037e0d4db6f70981", + "references/policies.md": { + "sourceHash": "sha256:876619a9cba13d65a05f11ed3cc9d0b62c7e0378124ccaa3e111b57cc25de1e7", "status": "translated" }, - "references/user-settings-files-te2.md": { - "sourceHash": "sha256:4a17aaa1b20a1c4d0f54affd7af348292b7f08a73504b0e3665f6e817f11f88f", + "references/preferences.md": { + "sourceHash": "sha256:c967483c9c594efaa8b0ec09ac4d4a1fa3e9f7f74bf89f33808133cd668c00d4", "status": "translated" }, - "references/whats-new.md": { - "sourceHash": "sha256:e3887e4264963f35ae76ae568968c9fb7249858520e60ffbbd8adf944e77491a", + "references/release-history.md": { + "sourceHash": "sha256:41847ab7494390d2a3af39d40b378cea8eb8a654e36f59dde79d022cd37a1af6", "status": "translated" }, "references/release-notes/3_0_1.md": { - "sourceHash": "sha256:4f90835a7971b4820009525b985c3e279039d71151e54d45753130777aa671ce", + "sourceHash": "sha256:9dd7d412075ffeb73fd903e6e560738125b944c1107d5279e06278b3a1c67ac6", "status": "translated" }, "references/release-notes/3_0_10.md": { - "sourceHash": "sha256:2b7777b0f54be87bc8bf9e080b4986f3e0d13e9431040fe5f80bc91980a286a4", + "sourceHash": "sha256:e4ed9e02f1d530888ea3291a800b799de6e5dce5717597aed5b02f96e6874819", "status": "translated" }, "references/release-notes/3_0_2.md": { - "sourceHash": "sha256:4c4fa9537b2f5691880e242791c3b091af34b0b9839ef2e7049adf4ed82a8801", + "sourceHash": "sha256:c259a508fc8aa8ab579248baa48edc26886cc11a7b46c1c300f5381f7f9a7576", "status": "translated" }, "references/release-notes/3_0_3.md": { - "sourceHash": "sha256:68d74ed0dd95601fccb517df398d555811ec85f50470b976df9b3b567a73a434", + "sourceHash": "sha256:e34eb6b6cc40a550c2b1e5555ab3dd5ae9aab4fe150191da8b217f7e1c44b7f8", "status": "translated" }, "references/release-notes/3_0_4.md": { - "sourceHash": "sha256:eae59197c4a996948b54885cc9f9c4775e38ad255f256f5f8b35219c7e52db79", + "sourceHash": "sha256:0745acfac1154de9e0b61300052c871454382fff3120fa89b0bf5b794522bcb1", "status": "translated" }, "references/release-notes/3_0_5.md": { - "sourceHash": "sha256:c39eb7e39bb3704d5691b8a4c0c7c3f9d5525c46586f29ffac646c2aa55f78f1", + "sourceHash": "sha256:0bc3323e03649c932b63cb6997eb73c33f7d8211601640a21630c7cb61851ffd", "status": "translated" }, "references/release-notes/3_0_6.md": { - "sourceHash": "sha256:6c43864671d333449ff843ba761a878b5bd9c3a4e56407ee193f25e07f7fc82f", + "sourceHash": "sha256:1e570cc335e2d65c8c4a137009c2aedaa3aeb09e0a45140121b6bc7cbe1d7468", "status": "translated" }, "references/release-notes/3_0_7.md": { - "sourceHash": "sha256:bd673da3dffc46611b124a292422bde1ecfa8360f0cc49732c029d610e6ad343", + "sourceHash": "sha256:5abefcdef1f490a553d1a625f1b3ae2c22e063ee14d751d483feeb1d347593bc", "status": "translated" }, "references/release-notes/3_0_8.md": { - "sourceHash": "sha256:df7eb26d1c7a747cbadc84887b83437c36b038f3578b32e07d656965a6fb8313", + "sourceHash": "sha256:cb1bfca1fe61a069fe1f3bf3f53ea69ba4209a25e434d94b0b1ff4fb63d64012", "status": "translated" }, "references/release-notes/3_0_9.md": { - "sourceHash": "sha256:8efdca40e1b9b66def91c1f2fa0813171e54537686a5cc4e1a17be4fc9130723", + "sourceHash": "sha256:645ce98903aff7368af55a9b23fdeff5964c5804e16d3dfbbebe34f2eeaed37d", "status": "translated" }, "references/release-notes/3_10_0.md": { - "sourceHash": "sha256:5049f3d981c44f82d9cecbc0e1c5ac0c8b5ab5137280e9957dfafdca0126b717", + "sourceHash": "sha256:280c2247e870b506a5134ad2da70ea21f53a5d7b2ad8e9f5319414e2f21d29b9", "status": "translated" }, "references/release-notes/3_10_1.md": { - "sourceHash": "sha256:875854de66aaec104359b884495bf6b51bca7325859d14fce9fe69057f6d7690", + "sourceHash": "sha256:8fcb4cee86daee9325723dc6cb6b9f3ffb3d9a41e67f0fc2c35510b8e0a78521", "status": "translated" }, "references/release-notes/3_11_0.md": { - "sourceHash": "sha256:021ecd692848551cfac260cbeb0b916c55df53273bbf2a197587ccce5b28d8db", + "sourceHash": "sha256:00487e4ef0ddf0ee9e93644ec740a1381bc984d716553cb60d2abc0140fc7b6d", "status": "translated" }, "references/release-notes/3_12_0.md": { - "sourceHash": "sha256:edb7df4534aed409999badff422f868c8c8014e8a13526c1cc378c44fc5e3837", + "sourceHash": "sha256:5ce41677cf81415a721c8db3c5c7e435c8b9c6fcf6ba61af7932cfcd2cd7f2b6", "status": "translated" }, "references/release-notes/3_12_1.md": { - "sourceHash": "sha256:f16279a68ee3df9a44a514232180cb1f3c1eb876c79c5c248d5c4fc0f3606893", + "sourceHash": "sha256:48c8104c99b0af6a26485ed1e19971ac9a06ca2fa143c55d1c2c5c74fe5252ca", "status": "translated" }, "references/release-notes/3_13_0.md": { - "sourceHash": "sha256:499f64520f241e0fe9d67cc959f734a4ae2aafa0e02dec11ec9328198a176159", + "sourceHash": "sha256:43b9bff69d3f4fd23c2f98102be764674c6ee63c5832e8dccbf6cadcb2d5cb0a", "status": "translated" }, "references/release-notes/3_14_0.md": { - "sourceHash": "sha256:31fd3fcc3de4989ef621536886603c7d14809ff8abb2b92ecee610cab80ea447", + "sourceHash": "sha256:f9819f664683671f5c6ad1f4b38f27c0d6390d88d748746ea2ca6f939ec57a3b", "status": "translated" }, "references/release-notes/3_15_0.md": { - "sourceHash": "sha256:f6e31f4a06cf0638a436461ca586375185fb6d83bb1f84769ca77714d6d85373", + "sourceHash": "sha256:892511af91a5becc0cddf3c17031eb2772f1c0362f2e144fa6137a9cfef864e1", "status": "translated" }, "references/release-notes/3_16_0.md": { - "sourceHash": "sha256:1b4ac68461f244e79d06ab74147edda6273a66d810cd7a51ee208692bf570d25", + "sourceHash": "sha256:c370d56ce763d64929dab9191dfec25d4d8e5cf4aac5ae242c965213497c07c9", "status": "translated" }, "references/release-notes/3_16_1.md": { - "sourceHash": "sha256:72b2fd9dfd0d056f91f0b57281b175432f4e1be79c2eddbd821fe69089461ad4", + "sourceHash": "sha256:8c963ee75f969d1f42991744d912d3ee9901209fe4729421791fff43cef34c16", "status": "translated" }, "references/release-notes/3_16_2.md": { - "sourceHash": "sha256:43b57904637bb57a6761d28eb7fe672e6c379f2dcd70d59c0a0e3f2d40690d7e", + "sourceHash": "sha256:2afcb4c569c945fcefcbb4ae5db451c3d3e6c386d6ce01520456af225cc397d7", "status": "translated" }, "references/release-notes/3_17_0.md": { - "sourceHash": "sha256:7a2234e15b112c51fa69cb1ee20242f34e58f555d2aac7ec85b3eaace801d276", + "sourceHash": "sha256:c8d747fa588ed2e9724ca5642094b3183a7848de106206c5acb7a6db003f03ad", "status": "translated" }, "references/release-notes/3_17_1.md": { - "sourceHash": "sha256:5506c04dceed3f9087ecc8ffccc73e0c846dc564a7bdc5dc8d3bb059521946cf", + "sourceHash": "sha256:43426ca1856ba7146d873c25c365fd7ca9dd6c2dac27172d4302aa08caf73a13", "status": "translated" }, "references/release-notes/3_18_0.md": { - "sourceHash": "sha256:794ca24e8ddacd1ea723639b9511a1550e9d5e9fbbc9e924f241c2871b9c2196", + "sourceHash": "sha256:ccef1d643cac9bbba5813f1f9101466c79a12f8adf5622f1b20780964bd39b36", "status": "translated" }, "references/release-notes/3_18_1.md": { - "sourceHash": "sha256:821a9da8c370a714da2ea8e89bfad676e8115c366019ef36f46edf0e6127fe7e", + "sourceHash": "sha256:864335c116512c8d22394bc0b36873a8c4855d1a61a18f15524bd16afabb5869", "status": "translated" }, "references/release-notes/3_18_2.md": { - "sourceHash": "sha256:cb3a96d6736f0a2394f226d3c1d7ffe1888781741ea19ed03b4dcff3fe8474a0", + "sourceHash": "sha256:498e7482728a3b72acb8b42b8eec7caf732df56677d3b44ac0fbf5a32562ef17", "status": "translated" }, "references/release-notes/3_19_0.md": { - "sourceHash": "sha256:c35acaddef5e1a0c26d8946c910cc60e73fd7387e68a4258c06befbbdb4633f2", + "sourceHash": "sha256:1a50d8f5bbee16ffcefb119bcdc3f2ff8f0ee153f7f9848f0a4acd7383f8ac16", "status": "translated" }, "references/release-notes/3_1_0.md": { - "sourceHash": "sha256:22b2ffee0b524fa1ca565e36f263b612fc243df5f213ff2a39b86d1a52bad6b8", + "sourceHash": "sha256:0585f4b1aad5d388d66147afa156d2e57e8f615ce5c3d3fb0830925d5393ee61", "status": "translated" }, "references/release-notes/3_1_1.md": { - "sourceHash": "sha256:fef2f46ee6c5fa38fd785bc0a3b0de38c79d2b654cd906c61577cae09b6511c3", + "sourceHash": "sha256:1d6e4b185d09f93a6688f14f954a766353fd3daa17ecedd6689735c2c01c5208", "status": "translated" }, "references/release-notes/3_1_2.md": { - "sourceHash": "sha256:8741312f51bdca558a909b8a5b6bb6466a9d9d9512960687ef611980b7fb13b3", + "sourceHash": "sha256:9f7ef93ca36c28f28b2a61b62b7cf68738da2a31a73abbc67ebacd4be6b05915", "status": "translated" }, "references/release-notes/3_1_3.md": { - "sourceHash": "sha256:4167126ca437e2f0bf9162170b8fd9b183b560e455767835b7593a2b91507e26", + "sourceHash": "sha256:63da26ecf5029706dd4a732a7494b15ff4df4bbf3d4e1e7743f61b46d979d2ef", "status": "translated" }, "references/release-notes/3_1_4.md": { - "sourceHash": "sha256:a7ee27790de5c9dfe8e3c1fc919c6f7c8174da7bfa9f50633dbc75a9ae5e0ebb", + "sourceHash": "sha256:690c2c05528da4f99adb4177a5e1ca448305c1093948d0cbb7948242961aa453", "status": "translated" }, "references/release-notes/3_1_5.md": { - "sourceHash": "sha256:50e2bcd0f0552965f413a0675665e4aff37913526fce4b762fe745453a927309", + "sourceHash": "sha256:c8984a8ab4c13021d98434ce71d4a54db9636dd3725c313de6795e2d6ad4fd88", "status": "translated" }, "references/release-notes/3_1_6.md": { - "sourceHash": "sha256:ee331894b3d1d983cebc386a7722c268df841b87982e7cb00b4c8238d91849f6", + "sourceHash": "sha256:1e7e09b6f4d7b733050ee43873ffe842c7e55faba945cd9d5afce8996739f183", "status": "translated" }, "references/release-notes/3_1_7.md": { - "sourceHash": "sha256:0c2dd7f51db662b57a39a2d62b47947aa0b139449a1872382288078d3588de4d", + "sourceHash": "sha256:cc33ce5dd956b6802c5184d84ead86749e676bddf9034b7228b0963ab1fa8279", "status": "translated" }, "references/release-notes/3_20_0.md": { - "sourceHash": "sha256:b7b01a2570adb84248f5daefa4fb67c904aa3e86c5a6f76981b39eaac307e120", + "sourceHash": "sha256:5a259bbe3b2d0cb22234ba3fb754c0fc17f21a30ad3eea8af4090108d1103488", "status": "translated" }, "references/release-notes/3_20_1.md": { - "sourceHash": "sha256:c2c6eea97accfbe2a647977c0c5bf465d9ee8b1acf04d9873b32371268550fa6", + "sourceHash": "sha256:1adab99bfc877197b1138f98ae2889337b269b54951dbf8984609192eeb9f5ab", "status": "translated" }, "references/release-notes/3_21_0.md": { - "sourceHash": "sha256:42faa7c3ce84c0f5c7d7784e97add9b7bedaf38f2f6b3221b2bc304d864d43b6", + "sourceHash": "sha256:e989c0564fe64acbba6215cf25eacb1e79c6da59f17756ad9ea74500a059dc8e", "status": "translated" }, "references/release-notes/3_22_0.md": { - "sourceHash": "sha256:bc2ca2fdef85e48d39ea13b915a49c718151242d913c81e0e6882a1e9bbf6327", + "sourceHash": "sha256:71625baf4598ce69ea8022cde2848d04b60229d8c60ad15bb9fb806540d62288", "status": "translated" }, "references/release-notes/3_22_1.md": { - "sourceHash": "sha256:ff9af1848652e8e0b7bd21630e2ac64b802c17454d37394ae9355efff2c23c67", + "sourceHash": "sha256:1e3e64b4b3955f35edd2a9c0bae68ca092be3729c7cf46ee376234ef1f8432f8", "status": "translated" }, "references/release-notes/3_23_0.md": { - "sourceHash": "sha256:4d58b8bce271a54de47e21a2adef2fe4eda998ef7ca6a8bfb5b51fd9aa150dee", + "sourceHash": "sha256:5dfe7273a83bb750fb67556bfaa0494f3474489e8584d8a4416f81db7638e4b6", "status": "translated" }, "references/release-notes/3_23_1.md": { - "sourceHash": "sha256:3ff1341b1e0137f5fe9207ae386a30817be1497345d8cfa5df1a4b4b8dd6107f", + "sourceHash": "sha256:a9693c900f2bd1ba870cbd36bbd3dae8c5b2cc07046953c0f1c62f8ab073b41a", "status": "translated" }, "references/release-notes/3_24_0.md": { - "sourceHash": "sha256:ce832ff6d1995bc3638f1965f1ecefde7c0e49932f2fc42507b450193b179935", + "sourceHash": "sha256:7e06a6582ce450f85e58773fa486f8dfa2b57ae5ff8ba5228e90a2df42b2b0a2", "status": "translated" }, "references/release-notes/3_24_1.md": { - "sourceHash": "sha256:80e1125a923675ad3e4504b46b37640376e194ff5ecbacf6a00330b20a8a6ec5", + "sourceHash": "sha256:2f6edfbfe6bac0cf0c6e0ca0c941208626b1ae2df232c8dc14046a2c6312fd9a", "status": "translated" }, "references/release-notes/3_24_2.md": { - "sourceHash": "sha256:893114bb290ec42da3d606631f01ed47ba501f9fcd4cf7d4bdcdd73af8ed9836", + "sourceHash": "sha256:002bd955620438ac8a76221d201bf17027262f15b5356fd994a340dae2953c46", "status": "translated" }, "references/release-notes/3_25_0.md": { - "sourceHash": "sha256:1ea99aa120d56dd1cce9309c1cef346bb8bf62619420099a278e13d2ce123622", + "sourceHash": "sha256:94073554f6e49579ce01eeb1ad53b54d66d8a2949d9fb2281123688b597594b1", "status": "translated" }, "references/release-notes/3_25_1.md": { - "sourceHash": "sha256:a87e83af303e7f21e202aff5230470e76aff37a0f4d047a451875ebd1c17aad4", + "sourceHash": "sha256:5327769633fab3d45273026fa50388e6cc572bc4e3ad4e1685ae544d777d96be", "status": "translated" }, "references/release-notes/3_25_2.md": { - "sourceHash": "sha256:112207b0ea9c112fff765c543e76ab4126a57aa808225f33b4540b5923b9e827", + "sourceHash": "sha256:ed0cfa9bb38b492a133d7742ffb1a57169616ea1f604ef3091d0dba292a5d9c5", "status": "translated" }, "references/release-notes/3_25_3.md": { - "sourceHash": "sha256:4012345c860df7cd946fb6841ffae2b784639ddcbd5a219b16155addca036efa", + "sourceHash": "sha256:e2aedc2e010c287c6a6480f950eb5abaa06c24faeec99b6f298ca9236e8ecc11", "status": "translated" }, "references/release-notes/3_25_5.md": { - "sourceHash": "sha256:7144981670ed4e6fe18d1fb3e28b158cd32912ba22b1bd20798bd20d32cad741", + "sourceHash": "sha256:cbceecff8d36b2bb9f4ba3a54c9957fd081f06ec869f2d37eaa14bd731854744", + "status": "translated" + }, + "references/release-notes/3_26_0.md": { + "sourceHash": "sha256:877aaef72b1b712c4ac6060a0812b8f6a37e85b5d7f7c907d73aaf0aec50b4c0", + "status": "translated" + }, + "references/release-notes/3_26_1.md": { + "sourceHash": "sha256:454c83599c5098dc644a438bdf15a80714a45f53fb63ac17d8514b1b197050c8", + "status": "translated" + }, + "references/release-notes/3_26_2.md": { + "sourceHash": "sha256:fa204a34a64c2f6a9e2764031e5c0c255b684edd6cbe18fd195a0d4fea2978bd", + "status": "translated" + }, + "references/release-notes/3_26_3.md": { + "sourceHash": "sha256:feb01ccd22d33c9adb705a0b7f6ef3523f4dfbdf1905410809d6b3f193515335", "status": "translated" }, "references/release-notes/3_2_0.md": { - "sourceHash": "sha256:6ecf64ba7d0f65cd64c9b32ab46e3850b1c7ec033b9476a6192d5769c5bb12f2", + "sourceHash": "sha256:f80a34035dde22a0619af4ce18d51f59866d2eff27418c893e676f9da877b10c", "status": "translated" }, "references/release-notes/3_2_1.md": { - "sourceHash": "sha256:ea8937b624cc1128a892a687bfd23fdc536638d8a81efef8a2048baf632cc7a5", + "sourceHash": "sha256:43e1be32e10f7a17a7d86df369eef21a78ebe5b2a52b53a1f83b6fd386b29049", "status": "translated" }, "references/release-notes/3_2_2.md": { - "sourceHash": "sha256:744d48cbe380075f3a3d442bebfff7444fa9023e2d3945a6e50898e4600841fe", + "sourceHash": "sha256:2da5802638de5f4ebb2f12cffb33be191351e460ce411293e865bf8310ea9747", "status": "translated" }, "references/release-notes/3_2_3.md": { - "sourceHash": "sha256:8c5389af01656e536faa8eabe7460e18c6aab4a62c543d80299e1cfdcc1afd50", + "sourceHash": "sha256:233ad017abfdf9b6cf5938bcd58d76dfac5aaa83c691e6b8613e8501ead44f6e", "status": "translated" }, "references/release-notes/3_3_0.md": { - "sourceHash": "sha256:f15db7654636a547034b22e1f5bba04be511349b93e1aef08fde7010f43eabaf", + "sourceHash": "sha256:6407130a08fc4aba3742cdc01f6fcdbe315036324a6ef4ef485750bbab90d75e", "status": "translated" }, "references/release-notes/3_3_1.md": { - "sourceHash": "sha256:d272ef003c110516559a348f5295cf82a4ee1bff2e009b882cb7bc7cb51092e0", + "sourceHash": "sha256:069220239a8a82d1f0d310a468b3af4959525d5b03e11298fb2049e0ebacc52a", "status": "translated" }, "references/release-notes/3_3_2.md": { - "sourceHash": "sha256:d08125afaed41782db0bb8fbf83f2e78c28e8c46db887d437cfb31545a3932f9", + "sourceHash": "sha256:e11afa6e505c7d0d7a2cc19b12232f05b12a402d1534d689a3987a692ad6eabd", "status": "translated" }, "references/release-notes/3_3_3.md": { - "sourceHash": "sha256:e5b8c6b56a4b96f45972422c4fbfa03fbb709c018d5f11590deb5c2b71d8fd83", + "sourceHash": "sha256:b25a3e45ea9245ff2348aef322932081aa9419469de741bd98ab45435fc01106", "status": "translated" }, "references/release-notes/3_3_4.md": { - "sourceHash": "sha256:84c5028c878ef1c45ce054680d1333e441f23771611bfb7ea382481a1ffad6fc", + "sourceHash": "sha256:dabac3bc52ea3ecb59ffc39e10a13d252a29530412a604c284cf33fa988e7ccb", "status": "translated" }, "references/release-notes/3_3_5.md": { - "sourceHash": "sha256:d5feae59a649bf8b811f5cf755c2916166de03e67770cd8e34b11553b38a87c0", + "sourceHash": "sha256:b88d6bc31375caca6ccbc5af1f95d1c64a64bac5d5861d540a5a111046ec2709", "status": "translated" }, "references/release-notes/3_3_6.md": { - "sourceHash": "sha256:65208a7bf0072e7a0d2c03b7bff4e915f51a9887a394bd439576b5146db3282c", + "sourceHash": "sha256:2eea5eec39317d1fc007cffd6971bf08411e04b79984bac9e9e7c5f4a9237329", "status": "translated" }, "references/release-notes/3_4_0.md": { - "sourceHash": "sha256:f20043008ef5673020ea9daa33bf6f8587005a191248e74852f7b45b12506442", + "sourceHash": "sha256:45921e73217c3fd5d69b614e10428aa428b0d466bdb49d83058cb79a49b846c7", "status": "translated" }, "references/release-notes/3_4_1.md": { - "sourceHash": "sha256:429344a948a9ffe9a834aa625598625247f93164f72bb04d712ad3346f3b61b0", + "sourceHash": "sha256:2c4222b3083566a37505c7ede112ee3076111acf89a9cacb7335241c5195a1be", "status": "translated" }, "references/release-notes/3_4_2.md": { - "sourceHash": "sha256:36fb93b69db715b9f5b5b398530c3f9b58026020af5876ac9959996d4676cadb", + "sourceHash": "sha256:8aca2f841be6c495daf791f1c8a812605c4215639dd6d61bf9a99a1450913eb3", "status": "translated" }, "references/release-notes/3_5_0.md": { - "sourceHash": "sha256:617775d38e30f4b632cee846088b385ad92586f59a159b2b47da954df2ceba36", + "sourceHash": "sha256:3e8574e571c37b8fdf644f42377a8f20e4494f57683aa2aeeb66f6cc5dbb3b3d", "status": "translated" }, "references/release-notes/3_5_1.md": { - "sourceHash": "sha256:886e863e3e341e4f6f2247545ca023731546bb974b7280b8414f40a9b1d9984b", + "sourceHash": "sha256:bbf6c0a5eaf0581ef564c23861341ba086a336a7a0c8556b18c82a3b97263de0", "status": "translated" }, "references/release-notes/3_6_0.md": { - "sourceHash": "sha256:596c121f04782773b41eb122d56f79a8110a9cec2bd7356577d392dbe1dbdcb2", + "sourceHash": "sha256:390ff39fcddb43462ce6371e434faeffaec2542bf1ce3871bf109c31afbebb8e", "status": "translated" }, "references/release-notes/3_7_0.md": { - "sourceHash": "sha256:855ac7bfe0b863764f62d2fe015af40dff189ba01e925b209937f91183327ed1", + "sourceHash": "sha256:1193e309b3e6b7c5ab312cdfac506a029c5d3fe1a46e55c44cfa73ac02b05559", "status": "translated" }, "references/release-notes/3_7_1.md": { - "sourceHash": "sha256:728972a441ab5076cd1c8d6fec671071f19709e59e8ba3461e75947efcf27b0b", + "sourceHash": "sha256:bb30b3f2c2edfb66b5b948a092b63522ffae1661d74837bec19ab8df42931536", "status": "translated" }, "references/release-notes/3_8_0.md": { - "sourceHash": "sha256:22f799307afd7023f568e1ab561d84a04ab17873724b175fc5297a38122db9df", + "sourceHash": "sha256:59c7b7c50555df06c3b4d8862429c9dac365f71abe0b2867433c4eb467ff227a", "status": "translated" }, "references/release-notes/3_9_0.md": { - "sourceHash": "sha256:1a84abd1786511b5866baca58ae4e73d03b64a76948a8ff19a7fb2a2cc23988b", + "sourceHash": "sha256:435580f98c2577516a67e1c12f4c451c0f3116bc864b4dc1f474b47650d048c1", "status": "translated" }, "references/release-notes/beta-16_6.md": { - "sourceHash": "sha256:6f69b0b3fecd9de0dd92b1e8ddb826554594f74ec46c71a4a0e3548871b40290", + "sourceHash": "sha256:ef23109d790ae2a02b9e8103f0832d951f09230342655ae7d67360e532b2b2a5", "status": "translated" }, "references/release-notes/beta-17_4.md": { - "sourceHash": "sha256:8172363576b8f546b2ebe6cd9ce517f99fdf56ce3ff29ea87eabd5bccfaeb3b3", + "sourceHash": "sha256:5fc5a6537d9c3b6e46e1798fa60fcc348258e23722c2fa7936f1950a6e94e9ac", "status": "translated" }, "references/release-notes/beta-18_1.md": { - "sourceHash": "sha256:9a598f4bc2cf7575f46ddb732bc0d96ba65659dd6dfb180488c5eb667e1ac53c", + "sourceHash": "sha256:948893b0e25d9c457f7757c8c2aed0be34f9fd7897e47dfbb9d48adf34281a9c", "status": "translated" }, "references/release-notes/beta-18_2.md": { - "sourceHash": "sha256:03a0435624e0bd41b2667a98fee65f90a4c80355f4a2237cd941ad85a138577e", + "sourceHash": "sha256:44023c249d6312fe7174132138a20bee776cbaac990f9f63be3609f405cf7ecd", "status": "translated" }, "references/release-notes/beta-18_3.md": { - "sourceHash": "sha256:7493081d9011736b7ac078eb92bcc615e41f8a76aa4d0af6f966ed5a9ea671f3", + "sourceHash": "sha256:a65ccb795d0cfe9ad39045068462b4c4fcb92940cca33dc06907e32c84994f90", "status": "translated" }, "references/release-notes/beta-18_4.md": { - "sourceHash": "sha256:1c13e6e1d9e4d852a20450200cdd205a3f8ab0c655a8ccfe81a52ae0e2bf6246", + "sourceHash": "sha256:f41a8fb3249f05fcee27d35961101bacc220c9914d9cc3c74f1d515ae6b6723d", "status": "translated" }, "references/release-notes/beta-18_5.md": { - "sourceHash": "sha256:6d943c183893bf768292e4a185899aa9f78839292bc0839eb685583ddec03358", - "status": "translated" - }, - "kb/bpa-avoid-invalid-characters-descriptions.md": { - "sourceHash": "sha256:e1ab3b636de9a49a6c62cc900b49c51e138d0ee3cc1588bd2c102b16056449f2", - "status": "translated" - }, - "kb/bpa-avoid-invalid-characters-names.md": { - "sourceHash": "sha256:dbb102b008123984fdb1e23f2a6c47e8e6741cc85a28ad6c20111e1915548198", - "status": "translated" - }, - "kb/bpa-avoid-provider-partitions-structured.md": { - "sourceHash": "sha256:448f378b6fc6d8d04266a43c75302dd7252d623fb811555c1cf0fb8240e6c6cb", - "status": "translated" - }, - "kb/bpa-calculation-groups-no-items.md": { - "sourceHash": "sha256:3437dabfe44b34fa1a43cf96ce9853060fe16f0e3e742906bb14dc13860628c5", - "status": "translated" - }, - "kb/bpa-data-column-source.md": { - "sourceHash": "sha256:b7f6ae4783b0623ae359df487021142386d62c94ae785950e33cf58aa9b4964a", - "status": "translated" - }, - "kb/bpa-date-table-exists.md": { - "sourceHash": "sha256:cdd48113cf8b398961e8180b357e538ce668dac9d7957a7cd57f9b5f79383347", - "status": "translated" - }, - "kb/bpa-do-not-summarize-numeric.md": { - "sourceHash": "sha256:6ba62d431be290fc77e7a8292ce582d2e0f0a427b70b3ff8139aba8467c5fd9a", - "status": "translated" - }, - "kb/bpa-expression-required.md": { - "sourceHash": "sha256:70979585bf4e27b51ab9fbc2ceb7fe01d0b380a6422f32e3dafb25831f505ccd", - "status": "translated" - }, - "kb/bpa-format-string-columns.md": { - "sourceHash": "sha256:e21436631db1326ec9bb6e5144c3596b6b4cf08fab5c2b50753baa3925c2c815", - "status": "translated" - }, - "kb/bpa-format-string-measures.md": { - "sourceHash": "sha256:3bc53cc91e1370a99162e252117bc534514140bd54d9f174d0b7905d97f63ac8", - "status": "translated" - }, - "kb/bpa-hide-foreign-keys.md": { - "sourceHash": "sha256:299852702c84c3e412fd28183dd3c3a1a14c7fed99391bfa4b9a55e2e95d83c6", - "status": "translated" - }, - "kb/bpa-many-to-many-single-direction.md": { - "sourceHash": "sha256:69900d8c9a83fa75dde15bb6015b975d1bafbfb55f5f9e81303705aa3ab792f8", - "status": "translated" - }, - "kb/bpa-perspectives-no-objects.md": { - "sourceHash": "sha256:fb6fdaf5edb9d02bfbfeff47bcab9ed92c613d4a73aafa23b3be594913b5fd4b", - "status": "translated" - }, - "kb/bpa-powerbi-latest-compatibility.md": { - "sourceHash": "sha256:9385968afdcce0b67a7332c7ccd7efb4c633b4b485581a2d244530269082c52a", - "status": "translated" - }, - "kb/bpa-relationship-same-datatype.md": { - "sourceHash": "sha256:a61a0744ac2497ace5ff92e59f31d563df61216a04ce64aeced2e30240d0ac4f", - "status": "translated" - }, - "kb/bpa-remove-auto-date-table.md": { - "sourceHash": "sha256:96c8da84cf9e550ab9b1d40aa8253796cc325ffc2386bd875e350f5fadd4db52", - "status": "translated" - }, - "kb/bpa-remove-unused-data-sources.md": { - "sourceHash": "sha256:c49846acf4d5326b6dcfaffd42ae33d38a110cf111c14cca3476ca89d9819c32", - "status": "translated" - }, - "kb/bpa-set-isavailableinmdx-false.md": { - "sourceHash": "sha256:f3d18c1a403e7cad3d49570f209c68ce1ab539301c0e0099f4aa0bbe1e8f5f8c", - "status": "translated" - }, - "kb/bpa-set-isavailableinmdx-true-necessary.md": { - "sourceHash": "sha256:b0bb9ccba1eaddf3abaa7def7c1fe17c9cbdfd7ea8e4f728d919f54b6b516f66", - "status": "translated" - }, - "kb/bpa-specify-application-name.md": { - "sourceHash": "sha256:e93bf3c1c57fa05f0522694980f7b511779ac127d6a6abf47571a4500d7320f9", - "status": "translated" - }, - "kb/bpa-translate-descriptions.md": { - "sourceHash": "sha256:241a9cbde9056914f8494f0d29e37d0f17266b9aaaf672c0c731610bc1130d94", - "status": "translated" - }, - "kb/bpa-translate-display-folders.md": { - "sourceHash": "sha256:aad20fc98449ad88378f6fcec1d4da25429fc0dd15710d8e0cddb4062944480d", - "status": "translated" - }, - "kb/bpa-translate-hierarchy-levels.md": { - "sourceHash": "sha256:71d10084d1e0f2c546fce80d73388ea5da05135f813b8512fdf60a5593381711", - "status": "translated" - }, - "kb/bpa-translate-perspectives.md": { - "sourceHash": "sha256:b1ba0f2c572b59dd564e9fb2ac02cf2fc05d7fc495b0f6ab5406ee8cf4027912", - "status": "translated" - }, - "kb/bpa-translate-visible-names.md": { - "sourceHash": "sha256:fe5dd4b69830746c9af69840c8f8975c1e279f4c5959f90f7388ac27b0552001", - "status": "translated" - }, - "kb/bpa-trim-object-names.md": { - "sourceHash": "sha256:387cd8339cd496acfed940f772b2772b55a923269f4c2e8e90e5bd7f48a8bb75", - "status": "translated" - }, - "kb/bpa-visible-objects-no-description.md": { - "sourceHash": "sha256:a0eaaa105b4326ffa49f4e6240b4cb6f84e0007bfa455065d64bfe721bcf5f9e", - "status": "translated" - }, - "kb/DI001.md": { - "sourceHash": "sha256:25783e67a976a42a7cabe303f2c5381f58d07b67de7bc5f89b84f438c86cc299", - "status": "translated" - }, - "kb/DI002.md": { - "sourceHash": "sha256:561ec551f444faf58f604028df338a3bdb83cc37ac6cc296db53d1d0a2d8aa9b", - "status": "translated" - }, - "kb/DI003.md": { - "sourceHash": "sha256:76cfa3c7a491d3b15f4dcbb8793f0ca59da12cb8aed5db6cee66b15f6943f027", - "status": "translated" - }, - "kb/DI004.md": { - "sourceHash": "sha256:1caf47bc6102b0081dc285ecbd9219dd710073c8f3abc8662af3f9d88cac7ed9", - "status": "translated" - }, - "kb/DI005.md": { - "sourceHash": "sha256:7febf977cd43487cb392144bd4223f352d1911c03cb2976f5a9d967f774dfd17", - "status": "translated" - }, - "kb/DI006.md": { - "sourceHash": "sha256:c78dba443fb20fb07079a810a4e2564660e9a0519068713000d203ad52fef6a8", - "status": "translated" - }, - "kb/DI007.md": { - "sourceHash": "sha256:8633dc8c141fadd780cfc42dd27c94d89556a53f03282a1cfade47249922a4b7", - "status": "translated" - }, - "kb/DI008.md": { - "sourceHash": "sha256:48f3168b416e177a426c6e933f7d5a48d8c18749f33693b1600f9882a4f25cbb", - "status": "translated" - }, - "kb/DI009.md": { - "sourceHash": "sha256:fb4313a94b1c454af18a68b8fb2e002f471e0a2d0d97bf100eb05363edff7a7a", - "status": "translated" - }, - "kb/DI010.md": { - "sourceHash": "sha256:43867cfd04ef7ee82d8ee6a8cfb3080bb0e4a703ff4bc7e7d34c8a09728f6e4b", - "status": "translated" - }, - "kb/DI011.md": { - "sourceHash": "sha256:c87f24040121b3efea63e5fee9fce16acf9f9075b76b9b2e4da412eb04720e35", - "status": "translated" - }, - "kb/DI012.md": { - "sourceHash": "sha256:33dd64c2880b75abea78fe99e999c79478066262665081c9269861c8aeacce10", - "status": "translated" - }, - "kb/DI013.md": { - "sourceHash": "sha256:b8ba1b12d831664e73e208b8778daea7cca6b39982b40aeac7f0c7cb67698fd1", + "sourceHash": "sha256:f2edb22650b9e8fc6e21507d01e9ad0326f3fb88d2be6d9981ad513c365f63fb", "status": "translated" }, - "kb/DI014.md": { - "sourceHash": "sha256:2d337e7363fb934ca706e037e388710c2e05aefaea1c64f58b430ecec12cec99", - "status": "translated" - }, - "kb/DI015.md": { - "sourceHash": "sha256:08da38c3345155cb4d2826a3974c8aac8f7e5607baf2408bceefd7c3aa20f5eb", - "status": "translated" - }, - "kb/DR001.md": { - "sourceHash": "sha256:95d71ac50e5393d178a2810260b4d14ab400bd2472cedd7b34f4ab76eb4a95d0", + "references/roadmap.md": { + "sourceHash": "sha256:b0995af14ffeba3c104b4bcc0d65bfedea42ef4e3ddeeed9e8d2d1f594efc0ef", "status": "translated" }, - "kb/DR002.md": { - "sourceHash": "sha256:8f34b0a352f00b138fabe27f6f0c94d486a3ce71e7940eea7596fc66dfa91de7", + "references/Roadmap2-h.md": { + "sourceHash": "sha256:9c6d16935c781d75cd4eb81451ca7614291f880442892daf52d994b7106ccb69", "status": "translated" }, - "kb/DR003.md": { - "sourceHash": "sha256:0c8e4590399adb2a53a133b4495750caa773cd5c79d3de9b75d9c97917c09ff5", + "references/shortcuts3.md": { + "sourceHash": "sha256:4b911e79caf257e42f4a55171c8f98aa6fddfd20b855946a6855a250f3df8bf4", "status": "translated" }, - "kb/DR004.md": { - "sourceHash": "sha256:bb39fa203c9290c3be5226702a613cfde1af0b151f3f4caca990a199f6aa875d", + "references/SQL-Server-2017-support-h.md": { + "sourceHash": "sha256:05b6c42b0be102be9030d3b5b34f801cfdfcfb0c2be058f2b940c96a1d2d9dae", "status": "translated" }, - "kb/DR005.md": { - "sourceHash": "sha256:05894b5b1a0ab1c9f641ced87626eaa332f3a0c575af29ed6faa3a59bfd0a40d", + "references/supported-files.md": { + "sourceHash": "sha256:477feb6c5f662dcd2bd2728ae1309d80336e0603cf2a9bae8064adc2cf721200", "status": "translated" }, - "kb/DR006.md": { - "sourceHash": "sha256:1ba9f39490bc64310a41acd5cef7a9c3b939fa5d22c28ca3bd211b28b7cb0cc8", + "references/TabularEditor.TOMWrapper-h.md": { + "sourceHash": "sha256:d1ed449a9a96c0031fe8b280f1c808858196806f71363d8fa9447e0db6e7d71a", "status": "translated" }, - "kb/DR007.md": { - "sourceHash": "sha256:53f849a4055bb6875e47677d8f85296ebf8877a9dc0933abd126b141ac13fea3", - "status": "translated" + "references/toc.md": { + "sourceHash": "", + "status": "untranslated" }, - "kb/DR008.md": { - "sourceHash": "sha256:37f6d0770424f1182e0dbeeea9045ce087d9a42eb8492160b130351d49f6dfae", + "references/user-options.md": { + "sourceHash": "sha256:ef957dffafe614addc2110c8fd05b9c9021acc49fde1b975eddb3b431c349e1d", "status": "translated" }, - "kb/DR009.md": { - "sourceHash": "sha256:b0bb1af2ba8ad830e16441b9e4e1f9bfee463f3d4df83c5a663b67167f617368", + "references/user-settings-files-te2.md": { + "sourceHash": "sha256:0a2da6cad41a5e1983662c6d621ab9f78f671aa59f2389f31004b7847deef1f9", "status": "translated" }, - "kb/DR010.md": { - "sourceHash": "sha256:0d27c6fd8af6bafb4f91cb941cdca10fbcd3a624d0066daab88634354a3c5b4d", + "references/whats-new.md": { + "sourceHash": "sha256:1d8d160e5a2a13c2b73bd97fa4b42f9c7f0b6ee04dbce8501039f39eef107b4c", "status": "translated" }, - "kb/DR011.md": { - "sourceHash": "sha256:d2a50f75d0a4feb91e1ee4c7879e4839aa5d8193b0ad509da5fd892b655a6430", + "security/gdpr-delete.md": { + "sourceHash": "sha256:25dafde7519c658a7212697ee19bee0033405761a2dfda883549908d63e7ea1f", "status": "translated" }, - "kb/DR012.md": { - "sourceHash": "sha256:8394a610677b999f04ef2be435bb2b53b838fa924fcc8c6b195720fd20cc63cb", + "security/index.md": { + "sourceHash": "sha256:14f520a1bc03f8d8f7e417d4dea1e7034f2e4c45addb8b9cfacb97e6585efa65", "status": "translated" }, - "kb/DR013.md": { - "sourceHash": "sha256:57fb582e349dc96388134c30775e3956f18f1370030163b93c7d5f6b2ed74264", + "security/privacy-policy.md": { + "sourceHash": "sha256:3b3d626bc3e999fb30369deca46398b5799282797bf93325e4566fab677fa59b", "status": "translated" }, - "kb/DR014.md": { - "sourceHash": "sha256:204c5bed823d9cf87efdf87e45959ca9b172f4e066f8fd59f36417f1342ee8bf", - "status": "translated" + "security/security-privacy.md": { + "sourceHash": "", + "status": "untranslated" }, - "kb/index.md": { - "sourceHash": "sha256:86639aab87287a6fa22a24e4bd4e73b4c2101050db8fc40fec71f74578f6c6c2", + "security/terms.md": { + "sourceHash": "sha256:90fd51f0cff45e9e16b2c200fc4dc3ccf769b97a3c6afcd09b7a688cfc5b87f0", "status": "translated" }, - "kb/RW001.md": { - "sourceHash": "sha256:f1eb1722568740f11d8758df85670050ca3dcca28fa288911bdff6eeecd06c44", + "security/third-party-notices.md": { + "sourceHash": "sha256:f21ab9783b89a6295a1343c4cd986b90acbf7940eb0f49d2b3ef33aa05d2282f", "status": "translated" }, - "kb/RW002.md": { - "sourceHash": "sha256:b7a7ad43cd3d1034dc3bf9f7507601265960becd05ea316fd1bd8802d01b117b", + "security/toc.md": { + "sourceHash": "sha256:d9a103f54e335e2e91434c926317f8f4dda1ea8c20b45ae25b6700c958db3e83", "status": "translated" }, - "kb/RW003.md": { - "sourceHash": "sha256:e41f03c10279ba88c2bfb1b9d3a5eafeb8d9fa536f459809b57f0291407d25eb", + "toc.yml": { + "sourceHash": "sha256:11d5df2671e45efed1baf56cd10c6634fc48f5f34b8f40d5b83456258a929108", "status": "translated" }, - "kb/toc.md": { - "sourceHash": "sha256:64a6d46f855afa5a748dc2f2393641abdc38473e142817a9e0e043a924e01c1c", + "todo/as-cicd.md": { + "sourceHash": "sha256:b9c4e2179d5be3177a2119ec147214c8f0e5f19bc82993fce18a1712717ad839", "status": "translated" }, - "security/gdpr-delete.md": { - "sourceHash": "sha256:e21e2f61114f7765cf5dab166ac9204244d3b6d056567ceb947410171480436c", + "todo/Maintaining-Calculations-using-Scripting.md": { + "sourceHash": "sha256:f01409f53e2dd517b8504f021d56725efc821bff3e94d5cf25c223f6ee9ed977", "status": "translated" }, - "security/index.md": { - "sourceHash": "sha256:956d0f7d76b544f0b5f70f7de43436d701221ca2c842efa8bef4ebb0e91179b3", + "todo/powerbi-cicd.md": { + "sourceHash": "sha256:a564d8b5f8312289b739fa332d0dfae6a3b07822e454da25c6f10c16b1e4c016", "status": "translated" }, - "security/privacy-policy.md": { - "sourceHash": "sha256:da89cecb73fc1b4de82c27c50ed76e6ca8f9365c1018f329a2502d855c5d9b1b", + "todo/te2-advanced.md": { + "sourceHash": "sha256:02b6a9bfe2f57d4a8ca9cca47d635343f4dee8b1759807de26faa9ffd51157f2", "status": "translated" }, - "security/security-privacy.md": { - "sourceHash": "sha256:449a306a6aa7e7376460bfa3c802d42fa6e53781fac39a287a64bb2cea0e349c", + "troubleshooting/azure-openai-connection-errors.md": { + "sourceHash": "sha256:e0212bb587e9af4f18ba63af831a01f9e84b893a8306685475e6a55abbef8650", "status": "translated" }, - "security/te3-eula.md": { - "sourceHash": "sha256:52fed84c515c55a2415b99bfbece46051c6d12cd409b324fbd52fab222214435", + "troubleshooting/calendar-blank-value.md": { + "sourceHash": "sha256:0306783580e503f6745682cf15906a60703d87b56ce743eb0f4e04c6b2ed4ca9", "status": "translated" }, - "security/third-party-notices.md": { - "sourceHash": "sha256:38d3a67fc048930c6da47d2f6af09fbf463d002dced7a385e587389aab42f2f2", + "troubleshooting/composite-model-measure-formatting.md": { + "sourceHash": "sha256:752fe4e77aa95d8710d48287f8d650ab12b71097e3dd81e49f50f72dd62175fb", "status": "translated" }, - "security/toc.md": { - "sourceHash": "sha256:cd3387b30602644747d9a3268b58f11db953524611b1a0c565af57d99cc954ff", + "troubleshooting/databricks-column-comments-length.md": { + "sourceHash": "sha256:5fb9b67014b00e5af4fe732edd4c5b9c99d00adbe3e251f3213ab4d17f2751d5", "status": "translated" }, - "troubleshooting/calendar-blank-value.md": { - "sourceHash": "sha256:34be544d6ed74231467eb6250280305eec9ac7df68119d382026e4d6a3966169", + "troubleshooting/databricks-refresh-empty-catalog.md": { + "sourceHash": "sha256:b6c29f4798ad8be4abac566ab051314ee0f1e0b477f21160fdbc6c44702195d8", "status": "translated" }, "troubleshooting/direct-lake-entity-updates-reverting.md": { - "sourceHash": "sha256:02969e9dec89cad15a480bfe0ace86e8f3e8ef943b6e0a20ab233034846029c8", + "sourceHash": "sha256:aab510b71ae2d9128c2c1fc565e2e3a3bee5cf2e568fc57aa33a8710b1f650cc", "status": "translated" }, "troubleshooting/index.md": { - "sourceHash": "sha256:30d2e5d94e50a74d5ff8522ce6a738d1378034bc97e094cc7f319ce19148023b", + "sourceHash": "sha256:201e48fac8aefa81c9dbcf119fc81fc8783ecc822ec5bcf4dd6fc464b8c15393", "status": "translated" }, "troubleshooting/licensing-activation.md": { - "sourceHash": "sha256:d64bc93160e955bf39b7f7c316200882e301e466d0c40548e3f975bc258096fd", + "sourceHash": "sha256:55b69d4c3ecc2cf40666029a8ec010e1305a09be496c86b72367bd4bf769ecfd", "status": "translated" }, "troubleshooting/locale-not-supported.md": { - "sourceHash": "sha256:381d1e4b89e12bd52d76bf83d583c5df01918b3b1e0facdc2b9895c312db7290", + "sourceHash": "sha256:3913bccc1fa6aafb9d2d6ab07f4018ea91efa1c1cc34874fc7267ab9b7034091", "status": "translated" }, "troubleshooting/proxy-settings.md": { - "sourceHash": "sha256:312d26f827c50d0daaf4bc95aeb42b6e263330046db563374d5dc44c3d8524ab", + "sourceHash": "sha256:9de7a8f714d448d4dd5184645833cd2a0ca298d2743cae52595ef2ebb83044c8", "status": "translated" }, "troubleshooting/toc.md": { - "sourceHash": "sha256:123158de09eea87e20e872f5d2eb233c6cbed3f6a585170c2c304edcc4483577", - "status": "translated" + "sourceHash": "", + "status": "untranslated" }, "tutorials/calendars.md": { - "sourceHash": "sha256:cc9cfd5bd837c7e00deb3e1753e72ffa4a7e38441465f2debc08abfdc66fc138", + "sourceHash": "sha256:42d5fbcac1800e22943c402afc8cc0ab45ecae62d802ffafb492d37a3e4096b2", "status": "translated" }, "tutorials/connecting-to-azure-databricks.md": { - "sourceHash": "sha256:05f30942709cf7b0b25b56e593bc29c36564c8b85525eb6240b9c6dcae674156", + "sourceHash": "sha256:269182b9bd66273e3dfa41139b91525312377a98230583820217b5afe3971c99", "status": "translated" }, "tutorials/creating-macros.md": { - "sourceHash": "sha256:aaaf277d68310b19f1cb98127df696d654b1729b083a23211f5050435cddde01", + "sourceHash": "sha256:c6c5951e9b76a66c146878235441b06c929595c6d11e86e2a23f8bc00eb5e9d4", "status": "translated" }, - "tutorials/detail-rows-expression.md": { - "sourceHash": "sha256:525e717f1598862cc24d406a29ff960782a20bd05c80bf87acec6d4e2b26ca3a", + "tutorials/data-security/data-security-about.md": { + "sourceHash": "sha256:cb222b35457c3f4617e95b55a8415187ca28631d10f99b6d3472f61d6da93614", "status": "translated" }, - "tutorials/direct-lake-guidance.md": { - "sourceHash": "sha256:d2f12ce024b19491468051cd810308da7147965aa84fecee4cc255b6a929705d", + "tutorials/data-security/data-security-setup-ols.md": { + "sourceHash": "sha256:c2f2266fc6f918d57da5fc9347a5c21aee8b769c020eabca32b0fc4408605cf1", "status": "translated" }, - "tutorials/importing-tables.md": { - "sourceHash": "sha256:6dee33a9de491f235d0520976222c5fa84aecea75e9c20094fdef12d8bf7abf5", + "tutorials/data-security/data-security-setup-rls.md": { + "sourceHash": "sha256:0315eac2798ccef13b32c9b9566dc914fc754cbbd2d31c130c7a8eca249e0571", "status": "translated" }, - "tutorials/index.md": { - "sourceHash": "sha256:9c632bdce66bdb47d8a087fa61be80247151cdf61601ceb834481f8ef49da3ec", + "tutorials/data-security/data-security-testing.md": { + "sourceHash": "sha256:aa6878e48f87256250f79f03275616644eea55578b54f7e5cfc965b5ecd20449", "status": "translated" }, - "tutorials/new-as-model.md": { - "sourceHash": "sha256:3a3cf1f4b09fa5c93afeef984c89b548eb7c289e47e0b7f64cd61dce72ab88e2", + "tutorials/detail-rows-expression.md": { + "sourceHash": "sha256:649495b03f0cf86488f215704d4353ac0998be3de9eac2cb653a69b0a60b74ce", "status": "translated" }, - "tutorials/new-pbi-model.md": { - "sourceHash": "sha256:8461303b7161afcaf58f8513c85ae2a48f5e3d0c19c4bd45c2fef4149e3de363", + "tutorials/direct-lake-guidance.md": { + "sourceHash": "sha256:12780771ea12151faacfa1f4d911d88b512b898ce535f8634deb6a0fc60ce3e0", "status": "translated" }, - "tutorials/powerbi-xmla.md": { - "sourceHash": "sha256:4ec8d37ee8644e56a17ac7a3662a38de1498b8fdca1ab8a737109fa971ee0881", + "tutorials/importing-tables.md": { + "sourceHash": "sha256:1e9205918c6b3e8b7b93dd6c47efe358059e49876878db2233a25a8476257d1c", "status": "translated" }, - "tutorials/toc.md": { - "sourceHash": "sha256:e79cc11f5e05d84a3f615483f2822ce848db0c6f537da131957927b6569c643a", + "tutorials/incremental-refresh/incremental-refresh-about.md": { + "sourceHash": "sha256:e36870b0ec41dce6e7d95b2b95aed9d996707289be50590585645c26de0cc6a2", "status": "translated" }, - "tutorials/udfs.md": { - "sourceHash": "sha256:dcc0d3f956f4e241a9a6653e852f3823f57fcad790918099135eecc649efa3cf", + "tutorials/incremental-refresh/incremental-refresh-modify.md": { + "sourceHash": "sha256:b984b2ea73f6fe7e10f9d0971da6377b14b7dbd8adde86a7b99acf2b33f5e7e8", "status": "translated" }, - "tutorials/user-defined-aggregations.md": { - "sourceHash": "sha256:8e353aa782edadd1734d8e8c4c4e30b41b3edd150956cb0289c771002181005a", + "tutorials/incremental-refresh/incremental-refresh-schema.md": { + "sourceHash": "sha256:8d2ce1926f887ac402e3189d5f3264bd3f9ba3e5febbb8495945d2696b676cfd", "status": "translated" }, - "tutorials/workspace-mode.md": { - "sourceHash": "sha256:f90519dd1b92514690fdd3530c190719ceb7a6526ed5911a534f09800ff867ff", + "tutorials/incremental-refresh/incremental-refresh-setup.md": { + "sourceHash": "sha256:d2b72afa3364f56fe87a23b4f5cf68e462a1be587863c735ff6a4f3c4ecd14f9", "status": "translated" }, - "tutorials/data-security/data-security-about.md": { - "sourceHash": "sha256:67f06a996eb51a92f12af6334fb67e35bd0dbe90b7087f1d26a37ddfd4909381", + "tutorials/incremental-refresh/incremental-refresh-workspace-mode.md": { + "sourceHash": "sha256:f758272b3776eae20df95e68bca8be9f51091b177dc95b2192ae2a6b25503042", "status": "translated" }, - "tutorials/data-security/data-security-setup-ols.md": { - "sourceHash": "sha256:ba55db45323585ce76de9ac9702c90a0d6e0836161a3eabcecdb779351f653f7", + "tutorials/index.md": { + "sourceHash": "sha256:08b69c87240fadba66c946ff66b645ca69f32f49a7057d69509af1b6fce4f840", "status": "translated" }, - "tutorials/data-security/data-security-setup-rls.md": { - "sourceHash": "sha256:37aa92c73edc07e2f9d01afd3de6d85045b6730da6ddbbcc452f5d14b43e9460", + "tutorials/new-as-model.md": { + "sourceHash": "sha256:c69cc02c8746abaf67e28bd3d691a8ef39b85da924337f60500e9ef20b87697b", "status": "translated" }, - "tutorials/data-security/data-security-testing.md": { - "sourceHash": "sha256:bc93809b7fafe060954311b3a1d97aecce69e906f20fca244220384fcc1f1147", + "tutorials/new-pbi-model.md": { + "sourceHash": "sha256:c1bdce1cc3623b25d976edf1e6830a16765e4684e8cc48801987242b42083bed", "status": "translated" }, - "tutorials/incremental-refresh/incremental-refresh-about.md": { - "sourceHash": "sha256:80c779772e0e1f5638302bee4ae27ffcec86249050f34ca30ccdb8031537e894", + "tutorials/powerbi-xmla.md": { + "sourceHash": "sha256:f445f13880be03d2923a5b2ecfc85ce22148e6da2ac72661997e8548b5bcc124", "status": "translated" }, - "tutorials/incremental-refresh/incremental-refresh-modify.md": { - "sourceHash": "sha256:71a9811c9adec7c0ea3e188bc4d604eec123f11854c98611ff1e98d4582f6305", + "tutorials/toc.md": { + "sourceHash": "sha256:8a2515bffeac53dd28f78da28c6c321a4c7687437cb4952039b1f230347996b0", "status": "translated" }, - "tutorials/incremental-refresh/incremental-refresh-schema.md": { - "sourceHash": "sha256:aa35afdc0710e4e8ac5373c1a16ee44843848ecf0669e61e49769217a0b04d6d", + "tutorials/udfs.md": { + "sourceHash": "sha256:309fa14887cbea21f7148e07c67382301d071e3257d5e2356a6ac49bcdb113ef", "status": "translated" }, - "tutorials/incremental-refresh/incremental-refresh-setup.md": { - "sourceHash": "sha256:6899e4a1b7393cd795b3fec5bcd60339648769fc91e279f8e04102a97d7c9f3d", + "tutorials/user-defined-aggregations.md": { + "sourceHash": "sha256:27fbed8599f40b55b8277e0e40e5fba309feb664a92be7ad76ff662ac52ab922", "status": "translated" }, - "tutorials/incremental-refresh/incremental-refresh-workspace-mode.md": { - "sourceHash": "sha256:98f31b4429d3a98a7541041c417d062257da648ffd393b7e44bb7c5429660953", + "tutorials/workspace-mode.md": { + "sourceHash": "sha256:e35e6f82ed64cb2d6a73fe987b64d631ebd2416fba3bb1055e56bb91c89d4eed", "status": "translated" }, "whats-new/3-11-0.html": { @@ -1485,29 +1689,111 @@ "whats-new/index.html": { "sourceHash": "sha256:5b9c85b1dffecec8f9912e589adb5fb38de25f01803f8a3618053f6994580251", "status": "translated" + } + }, + "summary": { + "translated": 416, + "outdated": 0, + "untranslated": 6, + "copied": 0, + "pinned": 0, + "total": 422, + "completionPercent": 98.6, + "pendingJobs": 7, + "failures": 0 + }, + "pendingJobs": { + "features/Best-Practice-Analyzer.md": { + "mode": "raw", + "idContent": "es/features/Best-Practice-Analyzer.md", + "sourceHash": "sha256:253dba4e0485c6b8052ae8da292cece72490d32b7fea72112aada89dbd2b8264", + "reason": "forced", + "contentType": "text/markdown", + "chars": 9077, + "jobId": 7955658, + "targetLanguage": "es-ES", + "serviceType": "premium", + "environment": "sandbox", + "submittedAt": "2026-09-07T07:26:25Z" }, - "index.md": { - "sourceHash": "sha256:81a81e409a97ca4d17b0fc503180b5ef6926ce0ba00e62d9b257f58613dc5697", - "status": "translated" + "features/toc.md": { + "mode": "raw", + "idContent": "es/features/toc.md", + "sourceHash": "sha256:9de7b2b89d41288caf042ff23187fa805f19ec472f91d34541b555b5f20d6de7", + "reason": "forced", + "contentType": "text/markdown", + "chars": 2109, + "jobId": 7955659, + "targetLanguage": "es-ES", + "serviceType": "premium", + "environment": "sandbox", + "submittedAt": "2026-09-07T07:26:25Z" }, - "toc.yml": { - "sourceHash": "sha256:364efc09fddb9e225cd3fa156dce1c88b52b39f816af4ea9d49622a2df522a65", - "status": "translated" + "kb/bpa-set-isavailableinmdx-false.md": { + "mode": "raw", + "idContent": "es/kb/bpa-set-isavailableinmdx-false.md", + "sourceHash": "sha256:9a5934a34ee1b4c911d855a78e87163a5427422d1ee59093b47d9c407f3bc50d", + "reason": "forced", + "contentType": "text/markdown", + "chars": 3650, + "jobId": 7955660, + "targetLanguage": "es-ES", + "serviceType": "premium", + "environment": "sandbox", + "submittedAt": "2026-09-07T07:26:25Z" }, "404.html": { - "sourceHash": "sha256:a630d6e508ff79709689c70f0dba4244fc700783402a0a6c75f4018f61cec3b5", - "status": "translated" + "mode": "raw", + "idContent": "es/404.html", + "sourceHash": "sha256:8e5748cfcc9ca6d991e8e65c36fb2b411cfec8c78e4f7c7be154ca6200c2f80e", + "reason": "forced", + "contentType": "text/html", + "chars": 8234, + "jobId": 7955661, + "targetLanguage": "es-ES", + "serviceType": "premium", + "environment": "sandbox", + "submittedAt": "2026-09-07T07:26:25Z" + }, + "getting-started/app/index.html": { + "mode": "raw", + "idContent": "es/getting-started/app/index.html", + "sourceHash": "sha256:6a2a51cf6bd96a68cdbf553d4d8d5622899b22acca9623068a5dd06b16d00b78", + "reason": "forced", + "contentType": "text/html", + "chars": 27787, + "jobId": 7955662, + "targetLanguage": "es-ES", + "serviceType": "premium", + "environment": "sandbox", + "submittedAt": "2026-09-07T07:26:25Z" + }, + "toc.yml": { + "mode": "yaml-names", + "units": 9, + "idContent": "es/toc.yml", + "sourceHash": "sha256:11d5df2671e45efed1baf56cd10c6634fc48f5f34b8f40d5b83456258a929108", + "reason": "forced", + "contentType": "application/json", + "chars": 194, + "jobId": 7955663, + "targetLanguage": "es-ES", + "serviceType": "premium", + "environment": "sandbox", + "submittedAt": "2026-09-07T07:26:25Z" }, "_ui-strings.json": { - "sourceHash": "sha256:b97210e8c2ad0c87c8f5ed95f8f2cc31c85725c96907e91c5ba950df78d14963", - "status": "translated" + "mode": "json-placeholders", + "idContent": "es/_ui-strings.json", + "sourceHash": "sha256:01a51cba4185663722f9194cdaf9c8c61d9d65dac841e841120c8661b7485fbf", + "reason": "forced", + "contentType": "application/json", + "chars": 1935, + "jobId": 7955664, + "targetLanguage": "es-ES", + "serviceType": "premium", + "environment": "sandbox", + "submittedAt": "2026-09-07T07:26:25Z" } - }, - "summary": { - "translated": 375, - "outdated": 0, - "untranslated": 0, - "total": 375, - "completionPercent": 100.0 } -} \ No newline at end of file +} diff --git a/localizedContent/es/content/how-tos/includes/sample-metricview-deserialize.md b/localizedContent/es/content/how-tos/includes/sample-metricview-deserialize.md deleted file mode 100644 index ff3bdf749..000000000 --- a/localizedContent/es/content/how-tos/includes/sample-metricview-deserialize.md +++ /dev/null @@ -1,43 +0,0 @@ -## Deserializar Metric View para estos ejemplos de código - -Esta guía paso a paso usa una Metric View de ejemplo de comercio electrónico que representa datos de ventas, con tres tablas de dimensiones (producto, cliente y fecha) unidas a una tabla de hechos (pedidos). -Ejecuta primero el siguiente fragmento si quieres seguir el código en el resto de esta guía - -```csharp -SemanticBridge.MetricView.Deserialize(""" - version: 0.1 - source: sales.fact.orders - joins: - - name: product - source: sales.dim.product - on: source.product_id = product.product_id - - name: customer - source: sales.dim.customer - on: source.customer_id = customer.customer_id - - name: date - source: sales.dim.date - on: source.order_date = date.date_key - dimensions: - - name: product_name - expr: product.product_name - - name: product_category - expr: product.category - - name: customer_segment - expr: customer.segment - - name: order_date - expr: date.full_date - - name: order_year - expr: date.year - - name: order_month - expr: date.month_name - measures: - - name: total_revenue - expr: SUM(revenue) - - name: order_count - expr: COUNT(order_id) - - name: avg_order_value - expr: AVG(revenue) - - name: unique_customers - expr: COUNT(DISTINCT customer_id) - """); -``` diff --git a/localizedContent/es/content/security/te3-eula.md b/localizedContent/es/content/security/te3-eula.md deleted file mode 100644 index f99d52bfc..000000000 --- a/localizedContent/es/content/security/te3-eula.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -uid: te3-eula -title: Términos de licencia estándar -author: Søren Toft Joensen -updated: 2021-07-10 -applies_to: - products: - - product: Tabular Editor 2 - none: true - - product: Tabular Editor 3 - editions: - - edition: Desktop - full: true - - edition: Business - full: true - - edition: Enterprise - full: true ---- - -# Términos de licencia estándar de Tabular Editor 3 - -La versión más reciente de nuestros términos de licencia siempre está disponible en https://tabulareditor.com/license-terms diff --git a/localizedContent/zh/.translation-status.json b/localizedContent/zh/.translation-status.json index 51d4be674..ca7908e37 100644 --- a/localizedContent/zh/.translation-status.json +++ b/localizedContent/zh/.translation-status.json @@ -2,1376 +2,1580 @@ "language": "zh", "sourceBaseline": "content", "files": { + "404.html": { + "sourceHash": "sha256:8e5748cfcc9ca6d991e8e65c36fb2b411cfec8c78e4f7c7be154ca6200c2f80e", + "status": "translated" + }, + "_ui-strings.json": { + "sourceHash": "sha256:01a51cba4185663722f9194cdaf9c8c61d9d65dac841e841120c8661b7485fbf", + "status": "translated" + }, "features/advanced-refresh.md": { - "sourceHash": "sha256:a3f181fa26c3fb2a96949d37a42a9b2729ea01279baa4ce78cab95fca8047ff7", + "sourceHash": "sha256:4c8a5deb7eaece9c11a76f650eab3352e5a16e51cc445eccd356ec7ddb451c5c", + "status": "translated" + }, + "features/ai-assistant.md": { + "sourceHash": "sha256:8267b87af307df2b39e440252675d40ed0f2265952db810cac3899c60f622680", "status": "translated" }, "features/Best-Practice-Analyzer.md": { - "sourceHash": "sha256:b95e98bcacdc9bec745a801592ec906a4bb1490a44f997c700f4f6244436fe78", + "sourceHash": "sha256:253dba4e0485c6b8052ae8da292cece72490d32b7fea72112aada89dbd2b8264", "status": "translated" }, "features/built-in-bpa-rules.md": { - "sourceHash": "sha256:233d6d9f44bae808b345197b2c7b5691cb0b4af32ddb66468ef5debb4c1653fa", + "sourceHash": "sha256:bdffc9c4bc7c941d725928c7b068e04907b5f111d273daacd59d38e57faf0a30", "status": "translated" }, "features/code-actions.md": { - "sourceHash": "sha256:2965d0ab377e3989e5adefdf317b44318049f00f8c51c35df2a7632dee475fbf", + "sourceHash": "sha256:82baf7662cf3be05e7a4b399cd9cf3cf6b7ab76eb6eea7233b020be46862b47b", "status": "translated" }, "features/Command-line-Options.md": { - "sourceHash": "sha256:6b2a4865311b0da759029cb643f838fa0243438a352cb8e855deb53ae8804146", + "sourceHash": "sha256:8f0e81ec57e6bc1e49ea2967760f68bc18e29f170de2a89bd83fc54b953b6741", "status": "translated" }, "features/creating-macros.md": { - "sourceHash": "sha256:031b3f5aca8eec28c78609ef421d48595cdc2df96f073d7a52ab16bebdf44326", + "sourceHash": "sha256:b1a3ff506c962feae7461cf83f78b86ab387d573bc53060d7f12f5c22963778c", "status": "translated" }, "features/csharp-scripts.md": { - "sourceHash": "sha256:fe043bad73d6f8e483b6413ca58f6a62f5ded53074863ab11e5cde5fc0975ac3", + "sourceHash": "sha256:e7ea43de890f061f9c5c23ba7d5b446c13003a59722836cb94c2912ce2bba1db", "status": "translated" }, - "features/Custom-Actions-hidden.md": { - "sourceHash": "sha256:0100c53a221855d5b1a8fa5c2a810fd9f56eda751fedb1eccad1b76d0cb8189c", + "features/CSharpScripts/Advanced/script-add-databricks-metadata-descriptions.md": { + "sourceHash": "sha256:3e3502fa81133433b74714be43bbc8eb911a90f8848ff98d0f1828f56036f9f9", "status": "translated" }, - "features/dax-debugger.md": { - "sourceHash": "sha256:20fdc750de8ab9f6d153f82b51c392201b53c89da26b3ee6a4508f87ac3a8403", + "features/CSharpScripts/Advanced/script-convert-dlol-to-import.md": { + "sourceHash": "sha256:35795fbeb43ca8cd0f1da2d5f94fdc58825d5951526475c99bd52f1cacf77ccc", "status": "translated" }, - "features/dax-editor.md": { - "sourceHash": "sha256:1f2c0c195b157d031bc3ced3a532165fb97a69de0c1636749b0775f487dcd236", + "features/CSharpScripts/Advanced/script-convert-dlsql-to-dlol.md": { + "sourceHash": "sha256:f977fe473183a92168b7d227519993669391f41e3aa535e167b89b9239fe6124", "status": "translated" }, - "features/dax-optimizer-integration.md": { - "sourceHash": "sha256:445aebdd1cfe2ebba6ad4d31786808f963aaa394d2971079a9dbf1bdf31dcde3", + "features/CSharpScripts/Advanced/script-convert-import-to-dlol.md": { + "sourceHash": "sha256:2740a932e1e7cb4f2a2eb7eb9129d2847db63209cbb2baaf6b20e3b45b4d71c4", "status": "translated" }, - "features/dax-package-manager.md": { - "sourceHash": "sha256:a8f12f885d8ffa3af88d600aa9f9f584f081bc527407cb9c3332a7780c7d1903", + "features/CSharpScripts/Advanced/script-count-things.md": { + "sourceHash": "sha256:67e92ecd96ccbe2ec1373a8d3280182a126d832821001b70e21c990a4742deb2", "status": "translated" }, - "features/dax-query.md": { - "sourceHash": "sha256:8c45adcffa202f972c37ddd6b1278da37dbe3ecdc0b62f85abf27a723f7f8f0c", + "features/CSharpScripts/Advanced/script-create-and-replace-M-parameter.md": { + "sourceHash": "sha256:0ce06f6f7719671d46c05fabbbc25a25721c9069615c6484b6dbe2bf058b8505", "status": "translated" }, - "features/dax-scripts.md": { - "sourceHash": "sha256:e88d01ebe77b65464d9b7abd6688a27c3fee2e3f8539d8ebd51c2e5b813e105d", + "features/CSharpScripts/Advanced/script-create-databricks-relationships.md": { + "sourceHash": "sha256:7100c6e93a2961e64c4962a5e22d0bca278b2f5c97b1c4509fbc20be236b7b5d", "status": "translated" }, - "features/deployment.md": { - "sourceHash": "sha256:9e60fe917808f281dd83193a448915452f828350dca386fe8c2b2bfef15c0365", + "features/CSharpScripts/Advanced/script-create-date-table.md": { + "sourceHash": "sha256:a95991c83be2e3b7d7fbfa7c8b1eff5f59467487b66aaed2fe7a43e510f002ca", "status": "translated" }, - "features/hierarchical-display.md": { - "sourceHash": "sha256:2f5c506f7b35ecfea164cccfee74ce6de9c8dd317c01fff96fd04e132512c0dc", + "features/CSharpScripts/Advanced/script-databricks-semantic-model-set-up.md": { + "sourceHash": "sha256:d18ee6c4ecb762d70373d3632b828a41a5dd76b283a0f742065aed5bc9ce0233", "status": "translated" }, - "features/import-tables.partial.md": { - "sourceHash": "sha256:b82db90bbf187352379ec8762cbd205610e72490e130136debac3b75ec70ce42", + "features/CSharpScripts/Advanced/script-find-replace-selected-measures.md": { + "sourceHash": "sha256:01f8139d9460fda1438695c2ff55c02a0671fc22ec442b111dd44236cd0f22c1", "status": "translated" }, - "features/index.md": { - "sourceHash": "sha256:50f03bfd484f4801fd99569881b836a2c93d4d8b179ab731f588cb3c0c9b2a35", + "features/CSharpScripts/Advanced/script-format-power-query.md": { + "sourceHash": "sha256:a18bc6f534e163a0cc87d75f47b02f13c3f6d47d4dd988ce53f55bd52002126b", "status": "translated" }, - "features/metadata-translation-editor.md": { - "sourceHash": "sha256:4262cf9f4d08a6f141fe470c9ac670d33edd49f9c4755bcc17fc8bbb26f31a41", + "features/CSharpScripts/Advanced/script-implement-incremental-refresh.md": { + "sourceHash": "sha256:ebeaff67ce308880b8ba5be4b9cd198c30a5f48af87df7b477d48ed313bb1907", "status": "translated" }, - "features/perspective-editor.md": { - "sourceHash": "sha256:c52ab63a25c110c9302b50b563d52d81d7bbe7a4b7fc999908cae7cff5a1b77f", + "features/CSharpScripts/Advanced/script-implement-user-defined-aggregations.md": { + "sourceHash": "sha256:e4a1668f2fe37b9dfedf705f1ba2052a3f696c10e3422420ee24e7f9a983aa8c", "status": "translated" }, - "features/pivot-grid.md": { - "sourceHash": "sha256:b6af168f855063133df7efcafa67927ff16748fe3cdd59c4fb0b9786d93be066", + "features/CSharpScripts/Advanced/script-output-things.md": { + "sourceHash": "sha256:b2e5c8db2ba620c4e8ccb72e729b96c474b926b2cf8e5124736be1fcfc4291f4", "status": "translated" }, - "features/refresh-overrides.md": { - "sourceHash": "sha256:d2363ece74c675093076e1d3bb692fb2841cb9c705e52a458aec64df266d9f36", + "features/CSharpScripts/Advanced/script-remove-measures-with-error.md": { + "sourceHash": "sha256:d0a4c46a77e8af7bb00b109e8875d4b549bc0c03f22ec28d2f1bd2d95ce4f20d", "status": "translated" }, - "features/save-to-folder.md": { - "sourceHash": "sha256:ef91c2f9f796fef7e91358ead257c2b3cf32c57092114cb95c4f16ca4552a503", + "features/CSharpScripts/Beginner/script-count-rows.md": { + "sourceHash": "sha256:691212c34c7c4dc0b39809eedd600fa594cdcb8d8e0d116fa9e6804a07fd1088", "status": "translated" }, - "features/save-with-supporting-files.md": { - "sourceHash": "sha256:8b436e18eaae2061c8521ac117fa08e8ed83c43a218b7631c1409caaedf1974d", + "features/CSharpScripts/Beginner/script-create-field-parameter.md": { + "sourceHash": "sha256:68a3acca96d3e372683c73129f700024e3229daa1752be3805653accc56fd91d", "status": "translated" }, - "features/script-helper-methods.md": { - "sourceHash": "sha256:52ece0ac2ca74b3aceefd177057e773977da7b27b44977e432c3ed207677b6c6", + "features/CSharpScripts/Beginner/script-create-m-parameter.md": { + "sourceHash": "sha256:9e2f4adee2211c2ef4a90fb017b289dbe5bdd935e290172030e5b6f32f1bf2bb", "status": "translated" }, - "features/semantic-bridge-metric-view-object-model.md": { - "sourceHash": "sha256:d27290a24b2917dcaa396d9d0aa9c1d2680b00dfba2e976d15007749b3831d08", + "features/CSharpScripts/Beginner/script-create-measure-table.md": { + "sourceHash": "sha256:0a641a1fa1d5b7b7ab73799b15d9434407b3fecc1d721ed1d43a58a4ff1d30e2", "status": "translated" }, - "features/semantic-bridge-metric-view-validation.md": { - "sourceHash": "sha256:81d096d6308ae4b99b860082843c6e89e753a6374efd9787264bbd158329a96d", + "features/CSharpScripts/Beginner/script-create-sum-measures-from-columns.md": { + "sourceHash": "sha256:50bd3f2a8ef34e438493f91da92be743f7cd4296a694d3243460abeb47f28c89", "status": "translated" }, - "features/semantic-bridge.md": { - "sourceHash": "sha256:a8da16703cd2c553fbdbf78e8337d1a2a9499b964f48373e4817012153c33548", + "features/CSharpScripts/Beginner/script-create-table-groups.md": { + "sourceHash": "sha256:74dd9a2bda903d85972caedcf9e230ca4843fa87fa490bd82122498db8a261c4", "status": "translated" }, - "features/table-groups.md": { - "sourceHash": "sha256:6a2a5c2d24ce7f1f8171211e61ddb8f2774619d945fb24bcf33fc83db6c0f8e8", + "features/CSharpScripts/Beginner/script-display-unique-column-values.md": { + "sourceHash": "sha256:9305105469bd45fb0ce7edbb66fdc9c7ec9be16d377bca79c8265ca16451aeeb", "status": "translated" }, - "features/tmdl.md": { - "sourceHash": "sha256:4b1e71e59f7377cb6694e22848a9831907d8bce20459dbb5814a4badcde68463", + "features/CSharpScripts/Beginner/script-edit-hidden-partitions.md": { + "sourceHash": "sha256:fe1dfb281e64a7367180a7705a5559be43d8c9368e1c5e7acccab093ee1430d4", "status": "translated" }, - "features/toc.md": { - "sourceHash": "sha256:124251b5b1d7b83b2daba79f72ec18bf2961bd43075635ebda15094992f326ea", + "features/CSharpScripts/Beginner/script-format-numeric-measures.md": { + "sourceHash": "sha256:e2c47703f04f15d22d23fd66c8abda9a27e5ff15ffbf6c9e73a9dbc54ba3952d", "status": "translated" }, - "features/Useful-script-snippets.md": { - "sourceHash": "sha256:1a220bb878ce665e3ba52328c2929758bf399c56613d9a072b2d5894710d4be2", + "features/CSharpScripts/Beginner/script-show-data-source-dependencies.md": { + "sourceHash": "sha256:6bd1324dda48a5363b2d1e9d9e893f61f2e3364913c64848180c5069442a1600", "status": "translated" }, - "features/using-bpa-sample-rules-expressions.md": { - "sourceHash": "sha256:fbf4aca9a2718fc53171edac32f27bbacd194e1b3644c4f19a62c996435697a2", + "features/CSharpScripts/csharp-script-library-advanced.md": { + "sourceHash": "sha256:1bffc5de50a63a02a1499b6c690f8f021a456f69bcea94de8d30589792f76e9f", "status": "translated" }, - "features/using-bpa.md": { - "sourceHash": "sha256:a750489f209d1bb3d314368e15c88913e3fad6c08402139c29aa77d172755684", + "features/CSharpScripts/csharp-script-library-beginner.md": { + "sourceHash": "sha256:8297f36b1b4788ff484bc7fbda33135465e258039c2c4fb91a10d7dd8d8dceb7", "status": "translated" }, - "features/Workspace-Database.md": { - "sourceHash": "sha256:037cf90e068ae6f0297fad2560012d9c08072abee11af6833c07e4d9bd4f4f7f", + "features/CSharpScripts/csharp-script-library.md": { + "sourceHash": "sha256:46b1ba3cb8415ab08589e29d961cbd45c89f8a94e545c381ef189be5e0641b55", "status": "translated" }, - "features/workspace-mode.partial.md": { - "sourceHash": "sha256:14bcfb19450618ce483f54692c50573694a0f9f1432923610e4bfb3b42d56155", + "features/CSharpScripts/Template/csharp-script-Template.md": { + "sourceHash": "sha256:9759ddcf839ac9befcbc5e895af14e4c555a327e1866fb48f373933d5e86758e", "status": "translated" }, - "features/CSharpScripts/csharp-script-library-advanced.md": { - "sourceHash": "sha256:d9896cd8b9c754d2c382efcb22525c1e4af263988d656cff7b95d250115fb299", + "features/Custom-Actions-hidden.md": { + "sourceHash": "sha256:a803f1066adffe1f7f0a0f8fc0c71c70b268737b756491c39ec54c70d49f204c", "status": "translated" }, - "features/CSharpScripts/csharp-script-library-beginner.md": { - "sourceHash": "sha256:728395ae6dc4b6686c418b09ed9d090c41ca9066a4c734e82469075c34fc4881", + "features/dax-debugger.md": { + "sourceHash": "sha256:97d080142ccf2afca94b197cf7f0153dcbac57b9e0c0559c947b2da455c5814d", "status": "translated" }, - "features/CSharpScripts/csharp-script-library.md": { - "sourceHash": "sha256:4a83b9ee945a704d09879d38757a8e8838acdce54c519a9191d3748c80f2c6dc", + "features/dax-editor.md": { + "sourceHash": "sha256:60ef7116000aa877e6e88ae05e6e517f32081b183a45972e1864515dbef58c6b", "status": "translated" }, - "features/Semantic-Model/direct-lake-sql-model.md": { - "sourceHash": "sha256:24c924ac00b363280270397b20e38bfcb58ec284ffcbafd332acdef9cdf70517", + "features/dax-optimizer-integration.md": { + "sourceHash": "sha256:7f360b8d39e0a763558717f52a112f4e908cd7e30b99c94580b0f478277858f1", "status": "translated" }, - "features/Semantic-Model/direct-query-over-as.md": { - "sourceHash": "sha256:85af1a152e4cf86a04fdd91bcf518bea567e30e55688e99f2201d524eaad22a9", + "features/dax-package-manager.md": { + "sourceHash": "sha256:1b6e0543d34a7b9f09bd8da1f53bafccaa4edbb4d8639dd057c4675820f3242f", "status": "translated" }, - "features/Semantic-Model/semantic-model-types.md": { - "sourceHash": "sha256:64a1be153fec80ec6235186a2ec316d53fe9af11dfc9d1cbee637c0ed6fd41dd", + "features/dax-query.md": { + "sourceHash": "sha256:f4307107c9930ea338c695ff713f38b64d0c5ff2e831dba1682f4131604f17e7", "status": "translated" }, - "features/views/bpa-view.md": { - "sourceHash": "sha256:c7ec9d9df36d5f768d0d6384f7b1baa7ddd134a5222d34fa32fbec4f9eb44395", + "features/dax-scripts.md": { + "sourceHash": "sha256:0de5ac712cbc629c2f24101dc01d425ab7121175a44c2004157164fa0e6208ec", "status": "translated" }, - "features/views/data-refresh-view.md": { - "sourceHash": "sha256:b248eb0c684a13d7f3d787b0ddd6a1637f4556eed8da40138dab30ccc308003e", + "features/deployment.md": { + "sourceHash": "sha256:dd753224144a686b1e621ea6bfdd3ea03601bd032fae1be49da58a0b1b6c24ee", "status": "translated" }, - "features/views/diagram-view.md": { - "sourceHash": "sha256:980b80557f3d1bf12969c4d974f53b1e76e211c152d544447fcb9b77dd32294e", + "features/hierarchical-display.md": { + "sourceHash": "sha256:6a726e430a507cfa17e106e498360d1518df8da7e656883c3ff845202d936cf5", "status": "translated" }, - "features/views/find-replace.md": { - "sourceHash": "sha256:f42cbffa4ba076ede971a7f74eb1a04c51a671f709167e80d123a27708f348b6", + "features/import-tables.partial.md": { + "sourceHash": "sha256:ca53e315bb2c4d8a1c45350141d2fcff7eb386c03d66950a8bff4d2761d1fd55", "status": "translated" }, - "features/views/macros-view.md": { - "sourceHash": "sha256:520dd7be90d9a286f15c145d0c3f151213fc79a4c9f8b25af4de9b766f20b012", + "features/index.md": { + "sourceHash": "sha256:5bac9edafd8e6fe76387a56ec818c4ca1665313ad6f2300212d522e57a77ee73", "status": "translated" }, - "features/views/messages-view.md": { - "sourceHash": "sha256:f4d90ae99db74ddc3742853ad8ca848480cafe1c392565525394db5b24003a6a", + "features/metadata-translation-editor.md": { + "sourceHash": "sha256:7dd826b950714972b3429347f09fed22bcdbaf3f149e4f47dc6382ecf26e69e5", "status": "translated" }, - "features/views/properties-view.md": { - "sourceHash": "sha256:f001df97118de15a898b6d160c3ad5df453589bdace10f50391eb8223802f30e", + "features/perspective-editor.md": { + "sourceHash": "sha256:d7bd4b9834bb8fac3a1072e4c84d43efe36d64e7443b50f0778f0f76f189476f", "status": "translated" }, - "features/views/tom-explorer-view.md": { - "sourceHash": "sha256:3d2dbacf216f262ac4cc0380a1545b11c2bd9ae3d953e4a4afb870431c777d96", + "features/pivot-grid.md": { + "sourceHash": "sha256:13469e84b4715d3989eb9f9220222807cad8eaa30bb5dc6eb1837da1e13d82ee", "status": "translated" }, - "features/views/user-interface.md": { - "sourceHash": "sha256:986ae970de12c4745c8c55268cc2278f50ed0f0f612237b8701645df0a3571f8", + "features/refresh-overrides.md": { + "sourceHash": "sha256:5cf34fc846be329c7ecdf10fb5927e1e3b9f5a3de4bafde5f63f30894e17fe8e", "status": "translated" }, - "features/CSharpScripts/Advanced/script-add-databricks-metadata-descriptions.md": { - "sourceHash": "sha256:1cdf755e7b2b922a65e1e5458e87ad870bf94d7fa559cdd811f0c4cbd8a671f8", + "features/save-to-folder.md": { + "sourceHash": "sha256:b4301ebe814e9c08764c9e634e51bc5a46047cd2534c61a540fb9ff0a5d89f0d", "status": "translated" }, - "features/CSharpScripts/Advanced/script-convert-dlsql-to-dlol.md": { - "sourceHash": "sha256:a9ccc97caf8639e63c4b196449dbebb7efade5aa9e0991f3f460f6b6a46f7415", + "features/save-with-supporting-files.md": { + "sourceHash": "sha256:b61b721a1d9f2cf0652155795e046291d1e8d5c80fb865f528ec03e2ada0d98d", "status": "translated" }, - "features/CSharpScripts/Advanced/script-convert-import-to-dlol.md": { - "sourceHash": "sha256:f6e6afd6430cc5eaf734a29eee11ba1bdc300efcd2f3e9a8566ab933cad8a0d7", + "features/script-helper-methods.md": { + "sourceHash": "sha256:5cb24ef463fa580075848ea2facacb0b906a1e08ff6cb1c62f7e41ae3824fbf6", "status": "translated" }, - "features/CSharpScripts/Advanced/script-count-things.md": { - "sourceHash": "sha256:a57cc71eca07c7ef5625dcd0de4ef25dbd135d124376ee97b5c163f135e094ac", + "features/semantic-bridge-metric-view-fields-and-dimensions.md": { + "sourceHash": "sha256:58bd40eea2c9da6f1cf4b1b24ca50f04ed021a171289ba554edccd4874fbafa4", "status": "translated" }, - "features/CSharpScripts/Advanced/script-create-and-replace-M-parameter.md": { - "sourceHash": "sha256:98e78822d061113b577caa032c5cec2ecdc01dc3cc54415d895d6635b5372a40", + "features/semantic-bridge-metric-view-object-model.md": { + "sourceHash": "sha256:6028084095ef69f4cd3c5f0e0770809dafca75ba3636e9f02335c7984e06c826", "status": "translated" }, - "features/CSharpScripts/Advanced/script-create-databricks-relationships.md": { - "sourceHash": "sha256:2c057aa107ad51f212de201495d4b957a9caedb5b4020295677215937c1bb989", + "features/semantic-bridge-metric-view-tabular-translation.md": { + "sourceHash": "sha256:74be78dd2533ebab27bb4d9dff59a263f12f19b90e8bc746d6206feb4d7f08b5", "status": "translated" }, - "features/CSharpScripts/Advanced/script-create-date-table.md": { - "sourceHash": "sha256:d3a6814c2600eb4104b75aa09736fe94c707a5b9314affe7228ffad221a7c58d", + "features/semantic-bridge-metric-view-validation.md": { + "sourceHash": "sha256:3b323ce1afb0fe35d9dc2067e7d8b0365eff7b7df5f6363c797a38f0267f51b1", "status": "translated" }, - "features/CSharpScripts/Advanced/script-databricks-semantic-model-set-up.md": { - "sourceHash": "sha256:a88f9235ec3544b569919b71dfd67918aadda89f3594bd4a930dc2cd0cd6c3b1", + "features/semantic-bridge.md": { + "sourceHash": "sha256:148612d364d9d289779fdf6bb5119e69a5a06f1162e11a909c5456284fbfcdbb", "status": "translated" }, - "features/CSharpScripts/Advanced/script-find-replace-selected-measures.md": { - "sourceHash": "sha256:8810d2b2c4b290a475cf2c25547d55e11581052adecd15722d33722f1dee8d7f", + "features/Semantic-Model/direct-lake-sql-model.md": { + "sourceHash": "sha256:5b7665db988503e68a3f05a8cb10f7883663bfda426d0c1856c6b1e50021e4c1", "status": "translated" }, - "features/CSharpScripts/Advanced/script-format-power-query.md": { - "sourceHash": "sha256:e0d227f4dee9e9555941f4f1692f9149db96601ef93cfe170fac0aec1ef15a7d", + "features/Semantic-Model/direct-query-over-as.md": { + "sourceHash": "sha256:280fbda0a57da7ba6b11294d3809f7b52c4f25cdf7ca5fa07e85264e5cd1e74d", "status": "translated" }, - "features/CSharpScripts/Advanced/script-implement-incremental-refresh.md": { - "sourceHash": "sha256:d073d4bf9223b7c04a68b06f0430d5116eef349187beeb4ae624f0b28a40ba67", + "features/Semantic-Model/semantic-model-types.md": { + "sourceHash": "sha256:d314cae6177dff5be507edbe44a1132f70dad1f9683cb60ed4aee5aa05d24578", "status": "translated" }, - "features/CSharpScripts/Advanced/script-implement-user-defined-aggregations.md": { - "sourceHash": "sha256:e6d7233c69c13b465e0a8c444a458d13115711ce3e093651a0177bd074eac83b", + "features/table-groups.md": { + "sourceHash": "sha256:75438952d25956a239efcb3acfdbd4d91fb124b02959bfad8065b83cfc8eb78a", "status": "translated" }, - "features/CSharpScripts/Advanced/script-output-things.md": { - "sourceHash": "sha256:55aa64a311c8d33ea0012e86cca9c7d9e39b492bf1f9012f442234966871c568", + "features/te-cli/includes/te-cli-preview-notice.md": { + "sourceHash": "sha256:3def00decf392f4d017bd0d3132c2e690fbaae2d09b381b0e3588fded89b7fb8", "status": "translated" }, - "features/CSharpScripts/Advanced/script-remove-measures-with-error.md": { - "sourceHash": "sha256:98a4e7ce55c0cbe7bc418827593433cf4cd4c24d02b266114b80cb2b14ef29bc", + "features/te-cli/te-cli-auth.md": { + "sourceHash": "sha256:64acf273c54477802fb09fe452573ec74bb01b3c1409a457db20cb6a53ec4557", "status": "translated" }, - "features/CSharpScripts/Beginner/script-count-rows.md": { - "sourceHash": "sha256:880b4cecde42c22e2ac5449590145195c30dd0b53499dc57eb5bfce960575ab3", + "features/te-cli/te-cli-automation.md": { + "sourceHash": "sha256:0cbf17e32e1455bacd1898f40190ec310ba4ed4855de3892e10c2d5010cceb9b", "status": "translated" }, - "features/CSharpScripts/Beginner/script-create-field-parameter.md": { - "sourceHash": "sha256:b900174834019481edb57daee36c1cd06ab33e5e04ded0ad7989bd211c034df3", + "features/te-cli/te-cli-cicd.md": { + "sourceHash": "sha256:f3cb9d43acbfbd218059a68e2424221db17065ff2924e1d3f9c8dd77f3fba631", "status": "translated" }, - "features/CSharpScripts/Beginner/script-create-m-parameter.md": { - "sourceHash": "sha256:495d56bf23e3a8b96b109e692530b55539dd59f8d6861ffa17a0aadebd2ef00e", + "features/te-cli/te-cli-commands.md": { + "sourceHash": "sha256:8477ea050acdfe20c798929180a94e35a433dd73dab7214d57a3486d773f18fc", "status": "translated" }, - "features/CSharpScripts/Beginner/script-create-measure-table.md": { - "sourceHash": "sha256:287bd8089fa3be09b2d5cc7830934549c219b6957cd71b76e36c75400168b8af", + "features/te-cli/te-cli-config.md": { + "sourceHash": "sha256:cc5e0827d7ebc71a3aa4ade3edf245d425ef227b1a796d04f1cb28267760b9a8", "status": "translated" }, - "features/CSharpScripts/Beginner/script-create-sum-measures-from-columns.md": { - "sourceHash": "sha256:9aeacf4afb6eb0ea78b0783bb0f17203b478f3b504e17080f9e01d40973d068b", + "features/te-cli/te-cli-install.md": { + "sourceHash": "sha256:e3b97de653c2b76bff2553ba5618b8031699a28303b1bd1ad5de33887ac579ab", "status": "translated" }, - "features/CSharpScripts/Beginner/script-create-table-groups.md": { - "sourceHash": "sha256:297ec32a4bd6104b89273ae6ef1708b653f81bea40c8c5fac8ef8a8e73e59cb9", + "features/te-cli/te-cli-interactive.md": { + "sourceHash": "sha256:797076275a2c1c0f4cff8042250aed209107f1b50ecfd9279d6695983e5881b1", "status": "translated" }, - "features/CSharpScripts/Beginner/script-display-unique-column-values.md": { - "sourceHash": "sha256:18b6074f21c2f083eb8bc5486d060ba6f9655db11f4f5e7e087460eeae839128", + "features/te-cli/te-cli-limitations.md": { + "sourceHash": "sha256:86caaf7b4ef53a92af84f2cab900f231555ad280139385e86e15eb7aa277ff4a", "status": "translated" }, - "features/CSharpScripts/Beginner/script-edit-hidden-partitions.md": { - "sourceHash": "sha256:12372b7806e13174ec41bb1dfe207f63a039e7e416b50a7a441325dc1a788e22", + "features/te-cli/te-cli-migrate.md": { + "sourceHash": "sha256:2dbe1b5ad09e9d096a6816db117ddbb07b7611c427689362cd72753546a283e7", "status": "translated" }, - "features/CSharpScripts/Beginner/script-format-numeric-measures.md": { - "sourceHash": "sha256:efcac83bb47fb34182536fd7626e1e9159186aed8addc1218a089bcf8f995836", + "features/te-cli/te-cli-skill.md": { + "sourceHash": "sha256:4b6eba64e68df9170c9ebf0e046932dfd63a256258881874f081cafe47606fa5", "status": "translated" }, - "features/CSharpScripts/Beginner/script-show-data-source-dependencies.md": { - "sourceHash": "sha256:4754d28371a514ff7e5ce78cd6d87f0a4a9a4907336e0fca1f0af4a05774d6c9", + "features/te-cli/te-cli.md": { + "sourceHash": "sha256:7996d6b15f1739097767af168689007e4f7b03bff5b730bb65e50fb42bd2f3c4", "status": "translated" }, - "features/CSharpScripts/Template/csharp-script-Template.md": { - "sourceHash": "sha256:97b462002593a46b2335518b6e0773f101ec09a5ae39ef2800a4dd584940d835", + "features/tmdl.md": { + "sourceHash": "sha256:f05bb3b99f5266a4f692fb9b628b151114647378f6ef55d9058688cfb59afb85", + "status": "translated" + }, + "features/toc.md": { + "sourceHash": "", + "status": "untranslated" + }, + "features/Useful-script-snippets.md": { + "sourceHash": "sha256:72e52b00b78c38dd76279535adbae7a80a1d2b755ecadd80c33b967d7998b988", + "status": "translated" + }, + "features/using-bpa-sample-rules-expressions.md": { + "sourceHash": "sha256:8c516b0268e11a5686c8d9e171cf7b3fe433594f88ca5b6e18c2708224c19bef", + "status": "translated" + }, + "features/using-bpa.md": { + "sourceHash": "sha256:2bae3a2476d435a2fece243326c3b5061cc80dde7f28fc53d38b4d3ebbe256ce", + "status": "translated" + }, + "features/views/bpa-view.md": { + "sourceHash": "sha256:dcccdbd044bbd074e52b5abf8e72d8ec7b714d81617f0261b49ab8a776de1670", + "status": "translated" + }, + "features/views/data-refresh-view.md": { + "sourceHash": "sha256:e7e5f52207d8c255922157fda644998d286de3dbc1a053af89e9b2566456eb48", + "status": "translated" + }, + "features/views/diagram-view.md": { + "sourceHash": "sha256:f548e5788e92d4961a62520bc56d4ea0ace76833d1f17fdbad7d74ff4cb25ae7", + "status": "translated" + }, + "features/views/find-replace.md": { + "sourceHash": "sha256:32a2e329d26e3712c1115ee0cb1965780387888d05c5af11b7bb2b056c8f3d42", + "status": "translated" + }, + "features/views/macros-view.md": { + "sourceHash": "sha256:6f8046bc8d5bcf581c147a3263fefeb4f6dbc160cccd29a0a8b09db2949f055e", + "status": "translated" + }, + "features/views/messages-view.md": { + "sourceHash": "sha256:c444a771025eab4067d298298fa8391fd1e0157c38582320b097f9d2320d4657", + "status": "translated" + }, + "features/views/properties-view.md": { + "sourceHash": "sha256:58dbde868a400d90bf5cfc1c77996b76c61c38b53de7900bd585dd91d3635e7f", + "status": "translated" + }, + "features/views/tom-explorer-view.md": { + "sourceHash": "sha256:052acb70972248b2677de696ddc8e2988ce7641af381d36d440eddea17dba98d", + "status": "translated" + }, + "features/views/user-interface.md": { + "sourceHash": "sha256:abb08b4fcdd7abc175ac495e5dc0e11168c55a249f31eb315a8093a8a260e50f", + "status": "translated" + }, + "features/Workspace-Database.md": { + "sourceHash": "sha256:356ced6221e49a7fc0552fd195c48bd3af5503e9599fe71f473d320010a27c0f", + "status": "translated" + }, + "features/workspace-mode.partial.md": { + "sourceHash": "sha256:f18c36e62b6d46dfca6ec6daba5b5e6964f9020a23b6d8683b995a6715bcf27b", + "status": "translated" + }, + "getting-started/app/index.html": { + "sourceHash": "sha256:6a2a51cf6bd96a68cdbf553d4d8d5622899b22acca9623068a5dd06b16d00b78", "status": "translated" }, "getting-started/azure-marketplace.md": { - "sourceHash": "sha256:7e5c57b8926ef7ca70b7da036ae439e63bf3559959742e13394bd7ecf6d17166", + "sourceHash": "sha256:3b2b5db9e85d9639eed51fbb91c18dead4a7feebc4e4d324804aaf41011243de", "status": "translated" }, "getting-started/boosting-productivity-te3.md": { - "sourceHash": "sha256:1dd6814ad7f714f01e4b7ca3e3c6565cd1c6deaff949c02fb6c1d2d493e95e92", + "sourceHash": "sha256:6cbd04a7b267e22d41c812a2efedda6cfc840a8c9e7146b0144cae6a1b077a61", "status": "translated" }, "getting-started/bpa.md": { - "sourceHash": "sha256:1942bcbb9b7d7f130e4f9869e1d541a12efc14d706b082ae013a016f779c9d3d", + "sourceHash": "sha256:2fe5b3652da37a39f1e8f413a6fe4b6d4a406227a941c730743ef29e579e221d", "status": "translated" }, "getting-started/creating-and-testing-dax.md": { - "sourceHash": "sha256:0650899dbd3142d41cb52eeb857e1829a3482874388865d999b577fa9fb7b704", + "sourceHash": "sha256:a44ead38de3eff665cc936036392d074088113bd7ab15be2209e587518290df1", "status": "translated" }, "getting-started/cs-scripts-and-macros.md": { - "sourceHash": "sha256:a32b793efb495fb0a23a981df218b49b34e58d0d6c2c19f2d037b8ae229887ce", + "sourceHash": "sha256:4f8a1fbaa0acc5a25fb1574c379e24f9ffaf280e74ce1bbc755f8922f6feae36", "status": "translated" }, "getting-started/dax-script-introduction.md": { - "sourceHash": "sha256:4393ece02e4cc62ad20d3ee63c1cf86866c6b70cbf5fd5124806da2adace7336", + "sourceHash": "sha256:1c500ed5798529eb123c3f097b3052dd48a97e2bb4a6fb8e2ff8a3c0660048ae", "status": "translated" }, "getting-started/desktop-limitations.md": { - "sourceHash": "sha256:77d974f5d7444f6d09d4dceb67c21946e514884fd10b2b2777d43b3693dac3cc", + "sourceHash": "sha256:4cb45a0e90a6407c47b35fda020c92600243839644ba8899d7981803abd52221", "status": "translated" }, "getting-started/editions.md": { - "sourceHash": "sha256:16752e36d2acf132ad4ce88ebcd226c065b77b11fc0ccca64612fe095709e2c4", + "sourceHash": "sha256:ada26d8ba83b53d2dd528def9455c52c3755406f44161eeb4b804ac87b2ef5af", "status": "translated" }, "getting-started/general-introduction.md": { - "sourceHash": "sha256:61337e48a6edff56d82bc3cbe0d2618d8139c93d434c1342ef312b7b8ba80d78", + "sourceHash": "sha256:4751981c02c68d5ba523befe0b4fdca2c6d9dd316ffa82839653c41c7550def0", "status": "translated" }, "getting-started/Getting-Started-te2.md": { - "sourceHash": "sha256:33dfe416723db01f219fafff7262251d16f9fda0d75570bac748a42d67930012", + "sourceHash": "sha256:07a52d7e6a05b99cee21912889a9848d74163b7b4273f46b74bad4bfe0fa9b00", "status": "translated" }, "getting-started/getting-started.md": { - "sourceHash": "sha256:28ea3283bb5bef0b4fd62eea31b3d4a5bb0d65abbe7d498451e2952d915756cd", + "sourceHash": "sha256:7468ddd7943d4547083cb4f1388d5a6d3613f6a459f4148942f04393b29aabb1", + "status": "translated" + }, + "getting-started/github-flow.md": { + "sourceHash": "sha256:1d6e200523cd43563e31dbbea87d34006012ba667d50d308cd3c336e91720db8", "status": "translated" }, "getting-started/importing-tables-data-modeling.md": { - "sourceHash": "sha256:539c1165edc406679fcb0d9dedcaa96ebf598346c49df92018cf74f52da894c6", + "sourceHash": "sha256:fd8e6ecb5d903f6515c3ec26f4e00d4e67a62dc8b5c2cdcf189f104576567b22", "status": "translated" }, "getting-started/index.md": { - "sourceHash": "sha256:1cff893340218b6b9399d31193263f541a0afd5205d2c520580069cf18b634b8", + "sourceHash": "sha256:a7a9719e3b24b4a414dfdbb77069ef5e6fefde7b83385e9ef67a5f0cb42a7c4c", "status": "translated" }, "getting-started/installation.md": { - "sourceHash": "sha256:ae80dbaf88ee37d9427ee9e0400e2ed7229530595e768027886ed6dc1fa4f87b", + "sourceHash": "sha256:89e25d98425d713ef85bc08cfe9780cffb5adf1974fc06d133233cdc76f507b2", "status": "translated" }, "getting-started/migrate-from-desktop.md": { - "sourceHash": "sha256:dc1fd7a811ed4408a7a453ec25361274f2bab258e2ea5329aa6d897c9afe04d4", + "sourceHash": "sha256:5f187bd69c968abea61375703f9c19b5e1c4674b6219878d6a18a07396f23bcf", "status": "translated" }, "getting-started/migrate-from-te2.md": { - "sourceHash": "sha256:942550423c2eb42cad3282142919fe92972d84b1f0c2cd12c4571f39422abc51", + "sourceHash": "sha256:e3b8f23bb9f7f7e7e0a911db6d0de1ad7f6479cdeed5e6093421e27ff6ce3c20", "status": "translated" }, "getting-started/migrate-from-vs.md": { - "sourceHash": "sha256:b7943609a6bab00f3299960d772ddf12a4161d46a011e5821a86e4c4560fd9a4", + "sourceHash": "sha256:2b87bf81182d2d0c645bfe354b06abf23343d2d61ff486918fd9b7055334043f", "status": "translated" }, "getting-started/optimizing-workflow-workspace-mode.md": { - "sourceHash": "sha256:b549dd9c660da15116394520e2c7db8a1167f1062a90964f8a078fac0969f7fa", + "sourceHash": "sha256:be881580d2cef038fc74e503591f8110998d40922a7c30bc15936ad7a36dc823", "status": "translated" }, "getting-started/parallel-development.md": { - "sourceHash": "sha256:771e22a14e4149f7d32da22a47ba73440b81a61564b1de976f16520c6e98fee0", + "sourceHash": "sha256:0101f8323927cee6cdd2a67c5b3f1384e1481e7648eada619b7ab3e0e0ba1b2b", "status": "translated" }, "getting-started/personalizing-te3.md": { - "sourceHash": "sha256:af5c6881e93c82a790f36c1cfd84dbf7456c320e067a207193b7d59a40e5773b", + "sourceHash": "sha256:6cb01ce0ac5ecd6bb4b231cf54937781e523805c024fe96a2c5f4492e39fd951", "status": "translated" }, "getting-started/Power-BI-Desktop-Integration.md": { - "sourceHash": "sha256:7c23b5811b153da076659cc097d65d04eb73794464942c3058059346eed3860f", + "sourceHash": "sha256:3e22d21db3ea34088cc114527f0a21951630fd0a37453ec99d5ca0d8f19fbf8f", "status": "translated" }, "getting-started/refresh-preview-query.md": { - "sourceHash": "sha256:1b95ce652f9fe220ce0baa2bdb2e0de95e1746729d9d6f4f1807717b04f6b804", + "sourceHash": "sha256:9b245c1a7ecdb7de7738329b2da2f3452e96665b7f7778316d78039cd4c1a3b1", + "status": "translated" + }, + "getting-started/sharing-macros-bpa-rules.md": { + "sourceHash": "sha256:96f6211eee67f507f4c31db2ebf9b3726cdf92ccfae790c64a8a7a40e16202e5", "status": "translated" }, "getting-started/toc.md": { - "sourceHash": "sha256:e2f87443d4ec79c44180a2e305fdf21ef9361b8f2f97c9b5f4f30816cf64727e", + "sourceHash": "sha256:6f0f1913443119bf78a723b94748f4baa411175f4da79da34ba60ebb3ffae4cc", "status": "translated" }, "getting-started/training-telearn.md": { - "sourceHash": "sha256:d58ebfdb0ba3721250865f314b7e234d07d60883edbd87ae9abb53dfbe6c54d2", + "sourceHash": "sha256:cba2b907335edf7e1310d6323c063305cbed3c66013758b932d91466394b4d85", "status": "translated" }, "getting-started/views/bpa-view-reference.md": { - "sourceHash": "sha256:b1b9af1605783a0d1a7215c3cbd463444d6e870ed8e31edc952c328e73f9996c", + "sourceHash": "sha256:15ca9b6b41ad2b4a120b6ef5ea17c4474e72fa68375e5539654e662057eb2383", "status": "translated" }, "getting-started/views/data-refresh-view-reference.md": { - "sourceHash": "sha256:2b0531afa0df2babacda9b8b0f942b626195ec5a6034aa9a158250d2168e7a7f", + "sourceHash": "sha256:5ae489e72f396630aec7b81960492180ce2359df98a403503cc7613fd2f22049", "status": "translated" }, "getting-started/views/diagram-view-reference.md": { - "sourceHash": "sha256:7d20597c914bebc07cfa6f3937a8183d152b8ba642e70e1cf8a6b041edd2a2f7", + "sourceHash": "sha256:00d1bab69592c55957a4dbde3661e24689af0bb471e4e982d0723c4ed8122127", "status": "translated" }, "getting-started/views/find-replace-reference.md": { - "sourceHash": "sha256:eea63df2e08bedcae9c7bc5f00fb21386cd95c9171ea8ad89f87f959c05a26ad", + "sourceHash": "sha256:a207ceafc5c8e3d3a218c80d405fafc3c71a9d9491964366607ee205e28a2b69", "status": "translated" }, "getting-started/views/macros-view-reference.md": { - "sourceHash": "sha256:88d73c55965ca306db4ad3e379fa987063c89c17c95ebed50e83d6240b9cb71a", + "sourceHash": "sha256:684b3ae341bc7955f0f304e04782f7b80505ca4bb2490a7c91e99e19439b743a", "status": "translated" }, "getting-started/views/messages-view-reference.md": { - "sourceHash": "sha256:a68d178caeb8eada8179a539ae061a3e5be49562edd30e5d95f154f749de8bff", + "sourceHash": "sha256:b4e921bba59577a9f8566439d26b22c2fa5e6dd7abc73ca5dee661d2d659c627", "status": "translated" }, "getting-started/views/properties-view-reference.md": { - "sourceHash": "sha256:f15e10357990e0871d0c6d003f3e422574ce05503a20e6ee1f8c585cabc45d53", + "sourceHash": "sha256:6915dedc081501314e88a8e8700a946ec5217564c2b12e8aa45b3b4a54ad2a81", "status": "translated" }, "getting-started/views/tom-explorer-view-reference.md": { - "sourceHash": "sha256:cff7e065f5ad8926552d64ce6170e782d999e24f3894e0b0a7e2f9858a801a7a", + "sourceHash": "sha256:0531754556357e77414ea097c8e89932c87f7e39ddcc33ef9cf3677653b50564", "status": "translated" }, "getting-started/views/user-interface-reference.md": { - "sourceHash": "sha256:2725adb9a676940d5e4b83c68c4989d2f40d503a83a216397840857874398563", + "sourceHash": "sha256:db5941a9366d706a595db7c01c82659fae0e0372be1a7e12c9d8061bfbde1e06", "status": "translated" }, "how-tos/Advanced-Filtering-of-the-Explorer-Tree.md": { - "sourceHash": "sha256:95d4c41f0fdc8f73ebbdde8c9829278f7e6cd01264c25c5b46763c225a0ec988", + "sourceHash": "sha256:935a9dc732e0fd11e5550a6c4472d35a42d3ee1a381586d693ac7a5830542903", "status": "translated" }, "how-tos/Advanced-Scripting.md": { - "sourceHash": "sha256:631d59ddf5d4bd7a39db9f62c2c11b1376e1933685da763c8b0992bdf982e370", + "sourceHash": "sha256:a7c6dd9a5fc927f309f47ffb69b335996b503abf3cde9271c94a5afd5e366f21", + "status": "translated" + }, + "how-tos/change-compatibility-mode.md": { + "sourceHash": "sha256:3fbf4c15fa1139dceed32d02cc4c59e4d46c17e8bca55704057158be01f44300", "status": "translated" }, "how-tos/connect-ssas.md": { - "sourceHash": "sha256:c2dfe9827a42110e7d25cfe9c3ed7c4a04ff7c2990d1ed10f64ab3c551037b16", + "sourceHash": "sha256:738df17f3a1ad8792884661e7b0ba408872170bc4180a239abfc66c2f5081ed5", "status": "translated" }, "how-tos/deploy-current-model.md": { - "sourceHash": "sha256:6ab8db3b6bcc1757ae8d0fdd51133ddc53334d3d7a78c45c7c4085a3f652027d", + "sourceHash": "sha256:834b504afe131332faeb2a705df8fb5f7df3f5137cfbc335d44b77a00b527930", "status": "translated" }, "how-tos/drag-drop.md": { - "sourceHash": "sha256:b3b4f89d0bc2b76a905e868d06d50917d81f8d4d64b3b1b5002dbfdf06fa6e72", + "sourceHash": "sha256:a7209f5254e6988a92bdca532d3a375cc82d134739e432a1970a6312814b8027", "status": "translated" }, "how-tos/duplicate-batchrename.md": { - "sourceHash": "sha256:0da1c04c8e0206882f716e3957cf86dae92500d4f6b6d51c7c7e44e5d33b3f54", + "sourceHash": "sha256:8fd7332b2b836c9e4358bc944b07c62c7fbea09ef3abd896653e05fed4c937cd", "status": "translated" }, "how-tos/edit-properties.md": { - "sourceHash": "sha256:e3edbd6a49beb9bb75a2d2076e51bea33227202ea910b070cedda099c17618f4", + "sourceHash": "sha256:36d9f419afff9ce4d1ba814f1a97ec3c825e569dc0698c82d6ccec72380cbabd", "status": "translated" }, "how-tos/folder-serialization.md": { - "sourceHash": "sha256:8b41e78bef09301284841690f12002ed1ca983c845d3b1d8b1b6cc85cb73c5b1", + "sourceHash": "sha256:6b4dae8fb419f7efb1d3348211d95f6febe409c9f6d860985f8f14a8beee052a", "status": "translated" }, "how-tos/formula-fixup-dependencies.md": { - "sourceHash": "sha256:cbb583c5453297aa76e267f86984587c0947dea569d937f291d54fc0921ddf23", + "sourceHash": "sha256:7b92d3f7a82312c06fe839e066df04411aae4a3d0aadf94aea44cc3b1934efb2", "status": "translated" }, "how-tos/import-export-translations.md": { - "sourceHash": "sha256:ee2d4bbed5fad42c661ef39838e0a62ea4a9459a7350a949b4c540569b77f6eb", + "sourceHash": "sha256:e360d9f6fb69f4ed660b150bc7cdbeaad188998f9addaca07a1ba95880158fbc", "status": "translated" }, "how-tos/importing-tables-from-excel.md": { - "sourceHash": "sha256:54be367cdb3652a1f329935b5f663df6076a420b1d1782b968fc3f467a580fe3", + "sourceHash": "sha256:6a68727673ad603d2091784ea68b0fd13ebc1eed7c5e279366841237e3319e9a", "status": "translated" }, "how-tos/Importing-Tables.md": { - "sourceHash": "sha256:17f8530c0bc0109b8e199fe56d36919fc24697fc437674e7728fadb353c878e5", + "sourceHash": "sha256:d50f21da67fec34065c172f1e57cc8479e6d98900fd1aefc05b2f9913213e3ab", + "status": "translated" + }, + "how-tos/includes/sample-metricview.md": { + "sourceHash": "sha256:fccd99fc0bee912d39f879757f5b18d263bbe88d130d534fe5663283a2351dad", "status": "translated" }, "how-tos/incremental-refresh2-h.md": { - "sourceHash": "sha256:b7cb1ce87b83b31c3cf44af327adf14324cbed8448fcf15223121c3bd083f819", + "sourceHash": "sha256:f784f8946b99d76865d5180b766c6d5c934b79ce89b6a15aeb99ed4e124c799d", "status": "translated" }, "how-tos/index.md": { - "sourceHash": "sha256:d6ca415819641e338d7d22d2eaabd75a2e83585fd226537776c6c00a22bb0f92", + "sourceHash": "sha256:602751d2958e61e1f0f0fbef21e254551b3e803070597384104c8689787809bf", "status": "translated" }, "how-tos/load-save.md": { - "sourceHash": "sha256:5999c48324037425d2cde3d8f82aa9afd0d419c2401c9605e31b5df0973bc2b4", + "sourceHash": "sha256:16972c8e9dc5861cafebda87d9c646fa1569dd9282d4f46049b34c700a9124a0", "status": "translated" }, "how-tos/Master-model-pattern.md": { - "sourceHash": "sha256:e6efeec6d7944d0aff773e9cc4219cd255af65bf23cd4b1aa39553d63ba033c5", + "sourceHash": "sha256:00f0f58661780f3001fc5ea01d866a6c7e05c95b1ffb10e2b4ebdec19fcb7eae", "status": "translated" }, "how-tos/metadata-backup.md": { - "sourceHash": "sha256:8c842b9fb0eb88fbcbc09a20036e1d21f6d6e48a6cf882141e2bc63cb759e329", + "sourceHash": "sha256:2e09520c1a8cc63a14d2bb548106dc094e24f105ea47b9adb2cbb67259f9ca4d", "status": "translated" }, "how-tos/perspectives-translations.md": { - "sourceHash": "sha256:6b640688ab87fee2e73e02776f4042efffa3f1bc007b0e607214c89a3386e16f", + "sourceHash": "sha256:d982fc9f33492df087251ffccfb498a4e482f5c49da5aed8139af5e591f9a1fd", "status": "translated" }, "how-tos/powerbi-xmla-pbix-workaround.md": { - "sourceHash": "sha256:032818b434b6b69fbe8db47914cee6c58a56d00cb279e47da3e99129c9f45315", + "sourceHash": "sha256:abb8737c6f6ea6d42eaa8303312d659b2ba8f4c4bf63eb0c17cc2de60be02c3a", "status": "translated" }, "how-tos/replace-tables.md": { - "sourceHash": "sha256:a71712e15de2cfb89a52c8022246743617bb75c97a025c6b0d2f02bb1569e80d", + "sourceHash": "sha256:bb2c09a2091a99c069bd2f206cefe471732284e7bd838b3a33047571ccacda73", "status": "translated" }, "how-tos/roles-rls.md": { - "sourceHash": "sha256:0f3e7a0ecf642bd55eeb6865d7c881563cf8e2f7708517172590def678060540", + "sourceHash": "sha256:fae9d351ead61fb6b1286401bb9f38c5e23d9cc427d1c1aab5bf351059afe56c", "status": "translated" }, "how-tos/script-reference-objects.md": { - "sourceHash": "sha256:486150d7fb6512e0038b7aa1d95480bc1dd4cda5116e9ca99e0ad75c420a57d1", + "sourceHash": "sha256:f4807e41f2785b359bc6b9875519aaebdca84da06415bea5a6cd09179a59e18e", + "status": "translated" + }, + "how-tos/scripting-add-clone-remove-objects.md": { + "sourceHash": "sha256:4d28cbac2e46174af4ed28927c660275192fb013621c5b2d37c0dd5563f95f07", + "status": "translated" + }, + "how-tos/scripting-check-object-types.md": { + "sourceHash": "sha256:e5fb737f603fb9234e8177ce7d888e6f73219b3823561bfea0d2db110e9788c0", + "status": "translated" + }, + "how-tos/scripting-custom-winforms-dialogs.md": { + "sourceHash": "sha256:9830c8cea603b15203226db2df1e217209cc5b78e67c14fbbe11f9cbc1f229e5", + "status": "translated" + }, + "how-tos/scripting-dynamic-linq-vs-csharp.md": { + "sourceHash": "sha256:e989800b165f6c5b86076bbdb28e749cd7e882f7be7ee0f72f9e3924634abbe9", + "status": "translated" + }, + "how-tos/scripting-filter-query-linq.md": { + "sourceHash": "sha256:a269be822be81370fd10a982730d6eb1f47bc3ed2cc6081a28fd7b4428c632fc", + "status": "translated" + }, + "how-tos/scripting-navigate-tom-hierarchy.md": { + "sourceHash": "sha256:f12fd751688eab6151b40579ac6e2840657d4f8693364facbef5bcea131e9f8f", + "status": "translated" + }, + "how-tos/scripting-perspectives-translations.md": { + "sourceHash": "sha256:0767acd259fb03acba4804f0ce3f4f31d6fd03fb5313b000bcd7b292835ded09", + "status": "translated" + }, + "how-tos/scripting-tom-interfaces.md": { + "sourceHash": "sha256:73b571f60ab78fc67b7523f78e157a21127856a6570e53a9a101f344d2117f3c", + "status": "translated" + }, + "how-tos/scripting-ui-helpers.md": { + "sourceHash": "sha256:23b920a830d6bef0afa9fa6041fa3453824b50057121e5f4a6c2e92470fd00e9", + "status": "translated" + }, + "how-tos/scripting-use-selected-object.md": { + "sourceHash": "sha256:430cfe77c38d7718c3908af2f7757e19b10fe2c1d5191c9113bc1c4c85fd286a", + "status": "translated" + }, + "how-tos/scripting-work-with-annotations.md": { + "sourceHash": "sha256:31f333c1fa02b5aa1b844b5e6b391f56f13b59fb4d3b6c0c4ef55d5a9a49673d", + "status": "translated" + }, + "how-tos/scripting-work-with-dependencies.md": { + "sourceHash": "sha256:7a2fc4475716bdb80bb7d1c2ce4264276ac3be2b8e474bb0b43659c549ca7ca9", + "status": "translated" + }, + "how-tos/scripting-work-with-expressions.md": { + "sourceHash": "sha256:a294b1cad950289516b26454aa616a985abb037bff7f767fb17bb4ef56c77a3d", "status": "translated" }, "how-tos/semantic-bridge-add-object.md": { - "sourceHash": "sha256:47cebddcb3457d0c355fd207c68de6fb980ad1e9b864ec39d80ca64aac861217", + "sourceHash": "sha256:fc45b9109e248917012968eaed3d31ab2731c749be09ea3a3604317771dd6761", "status": "translated" }, "how-tos/semantic-bridge-how-tos.md": { - "sourceHash": "sha256:ebc5e0ee2c2688b218ad90eb3ab2882183651deb6e797058bc34192e3850602f", + "sourceHash": "sha256:3e7254cf0f1574d7a0b75109d152b36e4911d1ff417a4691b1bcafc115fd175f", "status": "translated" }, "how-tos/semantic-bridge-import.md": { - "sourceHash": "sha256:f6f20eecc7756a8ef3739106b3ebc82ab85ba4282fee87701ecd8b78d415f397", + "sourceHash": "sha256:5e44456f341235b172a178e01ac6c0ecfee7d3b6a4e4d955ba240a2ee4d0cb47", "status": "translated" }, "how-tos/semantic-bridge-load-inspect.md": { - "sourceHash": "sha256:c4903b1f3dae22e24ef7401892ef4ec18d284493b19217a92095a6ec02e774d2", + "sourceHash": "sha256:5267a8486231514b4500cae234bf91aad00ecd2ccb5ae44389f46fb01b6a3de3", + "status": "translated" + }, + "how-tos/semantic-bridge-metric-view-handle-failures.md": { + "sourceHash": "sha256:67ad4305aa673c9d258a13b33b0cfb7e00d23a22ab410f4abf197d80676a7a3b", + "status": "translated" + }, + "how-tos/semantic-bridge-metric-view-import-from-file.md": { + "sourceHash": "sha256:93070e6a840581a2c023da6990574598fba958968ef7c0c7fab22b489ae793e4", "status": "translated" }, "how-tos/semantic-bridge-remove-object.md": { - "sourceHash": "sha256:0201f69d14698b005e06d60f6a721b3bc371d83bbc885944df38cc82e58aeb10", + "sourceHash": "sha256:4d3b3a064617e5b8792d448d6bcc6bfca71e449c7eb168e8958466096fae4fb9", "status": "translated" }, "how-tos/semantic-bridge-rename-objects.md": { - "sourceHash": "sha256:980236af341a7130899bb8bfa1655636edef998c95ba27d0552d504b1039c735", + "sourceHash": "sha256:2b741ad175e8be66503950d5aa5de558e2ce01b93371905faa8a8ded3bff914c", "status": "translated" }, "how-tos/semantic-bridge-serialize.md": { - "sourceHash": "sha256:2ca79bc033307934ac2ee7223639b8b71d7c7dc76fdaf5d13ec3d638d279bb40", + "sourceHash": "sha256:27ca90dfaf274d3d7933c2bb5839e2f6f28d213692e91b9e96d0b0199ac3174f", "status": "translated" }, "how-tos/semantic-bridge-validate-contextual-rules.md": { - "sourceHash": "sha256:2c8475f1c9b0d4169ff21e493fc47e4c03eb16fd06aa88b6fa9065ef2a03a2a9", + "sourceHash": "sha256:d29e2a536f0c2211f2f2a81896b281b994922d6ed8d82d6aa218fe2432c9b3e0", "status": "translated" }, "how-tos/semantic-bridge-validate-default.md": { - "sourceHash": "sha256:744cb9028b5d70ec6146649d98c6e5b2b909abfb6dd76addde90bfe2f9534c80", + "sourceHash": "sha256:c580f957c8083de4a78ffd1b6c7c97801c7c39821d1362205f014f0d075e11cf", "status": "translated" }, "how-tos/semantic-bridge-validate-simple-rules.md": { - "sourceHash": "sha256:871544d36d746d980df2cce373ca58961a3b21a612e28361e5ad5af1f37fda0d", + "sourceHash": "sha256:8022c0d65d92617604199afbf23383d9a1f8ab74184565435f0f57b0d6cbd46e", "status": "translated" }, "how-tos/toc.md": { - "sourceHash": "sha256:1b2f757b9db44d77156204b5fa006f78828eba9bc76fad02d97f54ece3cf3bf7", - "status": "translated" + "sourceHash": "", + "status": "untranslated" }, "how-tos/undo-redo.md": { - "sourceHash": "sha256:e03d98c7fe13f0ada9d6a887c417798a510ad82916e7b2c4e98c498a2d5ba69d", + "sourceHash": "sha256:743d09f92c48856bd0a069bc81d94c43038951d3b31baf2a6ac48495d705ca7c", "status": "translated" }, "how-tos/update-compatibility-level.md": { - "sourceHash": "sha256:6ff9fa9aa0a1979456abec53ec14addbf3c00a188bc8426adda43333e4ce1701", + "sourceHash": "sha256:db1e5f011e9012548630f4dc75358331f1a2b50b4652693feb145c2e685b9162", "status": "translated" }, "how-tos/xmla-as-connectivity.md": { - "sourceHash": "sha256:cf80a9d3d83420cd977af328fc21c63066ee28a7e12665b277d866f1e280ff40", + "sourceHash": "sha256:2c7134c5882b2901561935c89eea4f27f3d45534385e02b7d92d58416d269cda", "status": "translated" }, - "how-tos/includes/sample-metricview-deserialize.md": { - "sourceHash": "sha256:3f4bfcdeebb12a2cc7395441588f12fcb79ac7c0cdd6df4d0fc81193cf80419c", + "includes/feature-comparison.partial.md": { + "sourceHash": "sha256:46272433823ee39eb87e0f8592c46465ea0f0c8dd0bb9c12a67a409e81dc114e", "status": "translated" }, - "how-tos/includes/sample-metricview.md": { - "sourceHash": "sha256:41ed9d676177a7c23aec90bfbe9e703e7a18a1498009cc26b916805bca31ac41", + "index.md": { + "sourceHash": "sha256:1f4253085ee1a9650ad521ba52e0ec8371938577fb6b5369cd0f6b30649fea75", "status": "translated" }, - "how-tos/includes/sample-metricview.yaml": { - "sourceHash": "sha256:cd42d73830a347784d89cceab989764ccf168f965987ccb183dee2095b5cf464", + "kb/bpa-avoid-invalid-characters-descriptions.md": { + "sourceHash": "sha256:348a0b21b9c03d6e496e239a00cfdc81d39610af0fd5ad6de6fe1ff623f7ceff", "status": "translated" }, - "references/application-language.md": { - "sourceHash": "sha256:2b145c41ce53e4fb5013008471d79d4f7fe0602e4bedff220be0fcaa3028ee7c", + "kb/bpa-avoid-invalid-characters-names.md": { + "sourceHash": "sha256:8f013bf4faea84f9a24756e185ded303c9807c17b4ac5eb76df448b810e07eb0", "status": "translated" }, - "references/downloads.md": { - "sourceHash": "sha256:443087bae7d1bdebac65c03f355e1152f85d5d135d4f0783f6b710a6fd8941aa", + "kb/bpa-avoid-provider-partitions-structured.md": { + "sourceHash": "sha256:86ec65070d81d5063e4d173d7c8c0966853f9b6694bbd8e726b468e7cb8b01f5", "status": "translated" }, - "references/FAQ.md": { - "sourceHash": "sha256:821216efcbde4b1e629a83a1979c77a4cfd9f3b5c1f0ebcd273e3d828311d29b", + "kb/bpa-calculation-groups-no-items.md": { + "sourceHash": "sha256:744c11cb3577957cc223454875f5ea95726fce0a94970698756f47b0a46c86b6", "status": "translated" }, - "references/FormatDax.md": { - "sourceHash": "sha256:1b225bea6ccef4ae3a996a02443464307999ea958824b62b7445206245daf7e6", + "kb/bpa-data-column-source.md": { + "sourceHash": "sha256:a94e71eb040d34b57ad1f867e09460c23bf2d2e59ac848fdb92af82237851292", "status": "translated" }, - "references/index.md": { - "sourceHash": "sha256:2a1ed8e5bca82a8c8f180ba910dd7fc87ee891adef3d59234248f751545da10e", + "kb/bpa-date-table-exists.md": { + "sourceHash": "sha256:de3f26c0b6618516bdc57f3ea4575d35e22ff6dfe2adc856c77b6075b26842e1", "status": "translated" }, - "references/Keyboard-Shortcuts2.md": { - "sourceHash": "sha256:e0807f2e3f258f1c0d62555107041dfbaa17650d7150dca186ef237431af4ced", + "kb/bpa-do-not-summarize-numeric.md": { + "sourceHash": "sha256:244d8e116c98cab2126d5fc368471e4f432912e9e19181f58227d94b8322ddb6", "status": "translated" }, - "references/policies.md": { - "sourceHash": "sha256:9092b34841be3fdcf0021e529713a49d47346556b06073b3f81407ce15503c95", + "kb/bpa-expression-required.md": { + "sourceHash": "sha256:8a411171214efbf1a8fd9da1be301ee6ca0f09bcadf38ce54082acf21984346b", "status": "translated" }, - "references/preferences.md": { - "sourceHash": "sha256:6fe02d486971ff696c28b22e9e2a88662bb67d41cce4099342a1ba0f23c3089f", + "kb/bpa-format-string-columns.md": { + "sourceHash": "sha256:bf422113bd43cb69befbac033445b65b29a8569d9621a1c91eaf082a26047a9b", "status": "translated" }, - "references/release-history.md": { - "sourceHash": "sha256:30f7a485e3e60ebd809664c4b93f58c8598b0bc7d0fc4bc3715305179c0ed133", + "kb/bpa-format-string-measures.md": { + "sourceHash": "sha256:bc6cd3c9bb8d66074e6f7836e4f2dd33f650df390bef17394e5cf385e744fce3", "status": "translated" }, - "references/roadmap.md": { - "sourceHash": "sha256:dc7eb645cafbae20584b9422f22de6bba82c6c86aaaa41c3922f430b29d962d3", + "kb/bpa-hide-foreign-keys.md": { + "sourceHash": "sha256:9b67226f8b72ed7990a1c46dba893bcd36e775169fc562099bb3a8e17c47dace", + "status": "translated" + }, + "kb/bpa-many-to-many-single-direction.md": { + "sourceHash": "sha256:caf01f0d64fd87d6cd67e3247c059c9a02b366c4478ad2a36ebcbea14228c261", + "status": "translated" + }, + "kb/bpa-perspectives-no-objects.md": { + "sourceHash": "sha256:8c2e025fa682be0443b0d926d49eef193f807af7d1df74736dcbe7d867140506", + "status": "translated" + }, + "kb/bpa-powerbi-latest-compatibility.md": { + "sourceHash": "sha256:21ff822045d3a8bcd70bab6945d025c6ed8285c153ed739d7759ac552f056830", + "status": "translated" + }, + "kb/bpa-relationship-same-datatype.md": { + "sourceHash": "sha256:291f46cc5c6ef8980aba3c960edd1e0d6d42de202f390d2ada4d4ec71342521f", + "status": "translated" + }, + "kb/bpa-remove-auto-date-table.md": { + "sourceHash": "sha256:2328dbcb9c6571901c1f6565f73963c973556285e52765be67bf4d2efb3436e5", + "status": "translated" + }, + "kb/bpa-remove-unused-data-sources.md": { + "sourceHash": "sha256:58bd7d8482b7e17548d3b8f525c0f21c6648e2872815daadd8e5c1d84443f189", + "status": "translated" + }, + "kb/bpa-set-isavailableinmdx-false.md": { + "sourceHash": "sha256:9a5934a34ee1b4c911d855a78e87163a5427422d1ee59093b47d9c407f3bc50d", + "status": "translated" + }, + "kb/bpa-set-isavailableinmdx-true-necessary.md": { + "sourceHash": "sha256:9f1148b29e5e6ec221f24f0c4998e2da7d4a3955d23fe911dec5d63401e1094a", + "status": "translated" + }, + "kb/bpa-specify-application-name.md": { + "sourceHash": "sha256:3de1a94d6a0b66003b65d5b06669ed905f792c3151cc36117abc73c10f52abcc", + "status": "translated" + }, + "kb/bpa-translate-descriptions.md": { + "sourceHash": "sha256:8c73f97d93d7f0b5635b31d7e96bc5464f15f7cfc06507b8ca549edeae0cd374", + "status": "translated" + }, + "kb/bpa-translate-display-folders.md": { + "sourceHash": "sha256:10c7a4f9324e0de5c4c122ea7d10a2b6bb4e412a2f435802fc5e43d30dc65c53", + "status": "translated" + }, + "kb/bpa-translate-hierarchy-levels.md": { + "sourceHash": "sha256:27062bb6d7edfea8f0d78f3a789b985b18cc844690d29bc4127d55b66cac466d", + "status": "translated" + }, + "kb/bpa-translate-perspectives.md": { + "sourceHash": "sha256:8dbbdd6fd6f41cab313d51334c38cb91cdbb7830c560472a5abbdb39d40a7525", + "status": "translated" + }, + "kb/bpa-translate-visible-names.md": { + "sourceHash": "sha256:48c6cc3b4998a6ca24f517a88b714f5e92c29add04010e2c607faca34c45d9da", + "status": "translated" + }, + "kb/bpa-trim-object-names.md": { + "sourceHash": "sha256:f9c4103682dd64f14587c68948264b3049aa3fcdb63997d7ebd3645075c4aa5a", + "status": "translated" + }, + "kb/bpa-udf-use-compound-names.md": { + "sourceHash": "sha256:6a626e28ba35b857383cbaf6069708880026531ad2dba4faee0a09cb52bb102e", + "status": "translated" + }, + "kb/bpa-visible-objects-no-description.md": { + "sourceHash": "sha256:be6aff3d1618ad140b7be83ae59f97756572de2bc8b84eb5e035953f39642abb", + "status": "translated" + }, + "kb/DI001.md": { + "sourceHash": "sha256:0fdec75eb29be6ad908093cf6a95b9ab331bb568382772bf814fcc919a2dfc2a", + "status": "translated" + }, + "kb/DI002.md": { + "sourceHash": "sha256:068a81baf4c2c490ef06e51cfb9886723242dce676a9edea26e0948c0b00f702", + "status": "translated" + }, + "kb/DI003.md": { + "sourceHash": "sha256:b8e00a8116d08be012100d264b75fec171eedfc043a1bc52b1f2cf074c2dd0e1", + "status": "translated" + }, + "kb/DI004.md": { + "sourceHash": "sha256:c61d11bc3eb6a5e005f9c1b6f3fccdde21252abca2ffa9b4a181cfdd8ec7dba7", + "status": "translated" + }, + "kb/DI005.md": { + "sourceHash": "sha256:8e03cd78771896db6b9dfb81a57e0a45f42335d6b242e0d474699da70843de53", + "status": "translated" + }, + "kb/DI006.md": { + "sourceHash": "sha256:96086b12841c4fe8365a83fc91fb167c81d915b2c98e448489bdf55773167704", + "status": "translated" + }, + "kb/DI007.md": { + "sourceHash": "sha256:78dd569b532ba7d5cb0efe9c96e5623604c5c9dadd67949d65ad14449832c1b7", + "status": "translated" + }, + "kb/DI008.md": { + "sourceHash": "sha256:5521d0162fdacf2da404c49d5715d5124f4fa682f793dce4df3d7b952519a2bc", + "status": "translated" + }, + "kb/DI009.md": { + "sourceHash": "sha256:790d74322a9d163c6acb9bf5d2b0e45352b85b954d68f2c1bddb06c4600361b3", + "status": "translated" + }, + "kb/DI010.md": { + "sourceHash": "sha256:bf5c57cf42365ee7869cb9f4d24c5ca20f96f79f73007a587b9034d25bb321df", + "status": "translated" + }, + "kb/DI011.md": { + "sourceHash": "sha256:805dd2b2e2e754ef66ab07bba08fa0755c519e7432af9e83fbf4490a49f68924", + "status": "translated" + }, + "kb/DI012.md": { + "sourceHash": "sha256:b9934cee7d4ea8c47cb70de48454918e64326fb8f76bee17db773b0f32627462", + "status": "translated" + }, + "kb/DI013.md": { + "sourceHash": "sha256:19a2309ca26b9eedf30cde50cb70f3f70e8d8aa786ac98fc3e7bab74143d54e6", + "status": "translated" + }, + "kb/DI014.md": { + "sourceHash": "sha256:be9a88c83f9dfa328b63298e7adf2a809b2cd83ea03cb14886838ac891536f1c", + "status": "translated" + }, + "kb/DI015.md": { + "sourceHash": "sha256:c1c69cade3182935031f846cbe3f4165a4fd453ca77012e76f4f8cff1de2d3c3", + "status": "translated" + }, + "kb/DR001.md": { + "sourceHash": "sha256:ad922ebf457863f55e34000fd78fc007c0d69f9bee556d2f79b5cd27c03273fa", + "status": "translated" + }, + "kb/DR002.md": { + "sourceHash": "sha256:0ad80ebc8cbf3ec27662dffc0210986cbabe8f92810bdb7a33717de8fab16037", + "status": "translated" + }, + "kb/DR003.md": { + "sourceHash": "sha256:b76298c84d8c1269703dba2770d51a6674dfa4f03979e5aa71a53a456575bb49", + "status": "translated" + }, + "kb/DR004.md": { + "sourceHash": "sha256:d370d93822297f2614b44a30c04d330e5feef34160b080eb9ad3e019c70c7f6b", + "status": "translated" + }, + "kb/DR005.md": { + "sourceHash": "sha256:5b1451993738cd1b1737d47e96f328e5899f14028c161fe7c92357d74bfd29da", + "status": "translated" + }, + "kb/DR006.md": { + "sourceHash": "sha256:a91536c340fc07b1293e3a2e8cd4e89828d9fbdd8bacf3b23a736cfb8314e329", + "status": "translated" + }, + "kb/DR007.md": { + "sourceHash": "sha256:a03a20219e9ed635de09831705034fe148912a694c50d8d36b87b4efa26ffbd9", + "status": "translated" + }, + "kb/DR008.md": { + "sourceHash": "sha256:980a8345025fe79ac9cb8c3f841b204702f60dadb9574d72b33ba229cf9a475b", + "status": "translated" + }, + "kb/DR009.md": { + "sourceHash": "sha256:8609e608aaebfce4391671248f28d02870da055a99c90e126d56c4b415ceabb9", + "status": "translated" + }, + "kb/DR010.md": { + "sourceHash": "sha256:76c8d124d90f77650b62cc3b03402177ef55f25898b032bc7b94180130778485", + "status": "translated" + }, + "kb/DR011.md": { + "sourceHash": "sha256:ddb675bac420ba94a6a2eef2f7ab9cb4c9981b23398cc2a984a5cd30197743d2", + "status": "translated" + }, + "kb/DR012.md": { + "sourceHash": "sha256:dc8c217235ef82b45ca02328f0e1d78ccc12114dddd5a44394126ec3cd638a61", + "status": "translated" + }, + "kb/DR013.md": { + "sourceHash": "sha256:3e81af96332221c3b1f36655a6ee3f8dc0b116c019cdb7a5c26c5971c9cfc51f", + "status": "translated" + }, + "kb/DR014.md": { + "sourceHash": "sha256:d435c5959eb47b00a7aab3b846013228ec6d2077a3dc245e50c1a5328c392dbc", + "status": "translated" + }, + "kb/index.md": { + "sourceHash": "sha256:8331987f38c8b52d8907a3f0d2c773d188e888cc823f2a5a21463d0a163f7a15", + "status": "translated" + }, + "kb/RW001.md": { + "sourceHash": "sha256:cc6cd9a2294f425380d825ff0a5ca882c3bd40a4d5207cf6cce0793f7c6af493", + "status": "translated" + }, + "kb/RW002.md": { + "sourceHash": "sha256:597595d6f8a1102114f51ae992bb519d21095f2a1003a8b1a90d3d96849916c2", + "status": "translated" + }, + "kb/RW003.md": { + "sourceHash": "sha256:289a445b62c13098e4ed6af7fa37901b1aec8bfb4d3b57e1e42eda8f1de9ad5e", + "status": "translated" + }, + "kb/toc.md": { + "sourceHash": "sha256:db0f8007f8bb5998ac188559ffcd9831d49a8c938f1b41434295625097a0a160", "status": "translated" }, - "references/Roadmap2-h.md": { - "sourceHash": "sha256:269e6b5b5fc980f70aa642ad6882ed01909631c2afafe6daff399e6efdbea8f1", + "references/application-language.md": { + "sourceHash": "sha256:3d8bb1e49bd7cf97c56210ab5c8322b7c419cbb0152966f195cabeabb87d1ded", "status": "translated" }, - "references/shortcuts3.md": { - "sourceHash": "sha256:c58efcb0bfd40d9a32809349f54a791c86ee5a501718c4aaca7b72733e74347e", + "references/downloads.md": { + "sourceHash": "sha256:0bdbdf1e0ab65e52a9bb7b697b55057605c5911322c4613269f250dd3609a632", "status": "translated" }, - "references/SQL-Server-2017-support-h.md": { - "sourceHash": "sha256:db00c8642b2526adb31b4dfe2c7e56cc0040c8c34fec031125f7064bfa41b63c", + "references/FAQ.md": { + "sourceHash": "sha256:bf29800bdcc5ace4dd5d3813fc1fb8c48b30a034039a0285bd62768866b924e9", "status": "translated" }, - "references/supported-files.md": { - "sourceHash": "sha256:79abc83bd95b9f176b4567e47e482ce1100ad12913143dead97f7d0b03ba3720", + "references/FormatDax.md": { + "sourceHash": "sha256:d6d07781fc5c8cbecb8187caa6123eb0a13f0f145abfa4b8c4cb1274c7e9dcf2", "status": "translated" }, - "references/TabularEditor.TOMWrapper-h.md": { - "sourceHash": "sha256:603e7d30f3b77493a42ed2c269400c4139617bd026588328a78dfb16c92f317c", + "references/index.md": { + "sourceHash": "sha256:a7562663ae2f94db4f055c883a66e6a28d911925ae9cb1e9aea2fe1de791d569", "status": "translated" }, - "references/toc.md": { - "sourceHash": "sha256:2a8242ed589d5a5c1726553305054d7aa2a004cbd758bd270534ea0f26500621", + "references/Keyboard-Shortcuts2.md": { + "sourceHash": "sha256:6dac63cca6fed07aeeaa4c9c97b9174104c299528f823785cb9d042fc567f93c", "status": "translated" }, - "references/user-options.md": { - "sourceHash": "sha256:3547b53ce1fe8d2e177517d1937921bd4b8320f09ed7434e037e0d4db6f70981", + "references/policies.md": { + "sourceHash": "sha256:876619a9cba13d65a05f11ed3cc9d0b62c7e0378124ccaa3e111b57cc25de1e7", "status": "translated" }, - "references/user-settings-files-te2.md": { - "sourceHash": "sha256:4a17aaa1b20a1c4d0f54affd7af348292b7f08a73504b0e3665f6e817f11f88f", + "references/preferences.md": { + "sourceHash": "sha256:c967483c9c594efaa8b0ec09ac4d4a1fa3e9f7f74bf89f33808133cd668c00d4", "status": "translated" }, - "references/whats-new.md": { - "sourceHash": "sha256:e3887e4264963f35ae76ae568968c9fb7249858520e60ffbbd8adf944e77491a", + "references/release-history.md": { + "sourceHash": "sha256:41847ab7494390d2a3af39d40b378cea8eb8a654e36f59dde79d022cd37a1af6", "status": "translated" }, "references/release-notes/3_0_1.md": { - "sourceHash": "sha256:4f90835a7971b4820009525b985c3e279039d71151e54d45753130777aa671ce", + "sourceHash": "sha256:9dd7d412075ffeb73fd903e6e560738125b944c1107d5279e06278b3a1c67ac6", "status": "translated" }, "references/release-notes/3_0_10.md": { - "sourceHash": "sha256:2b7777b0f54be87bc8bf9e080b4986f3e0d13e9431040fe5f80bc91980a286a4", + "sourceHash": "sha256:e4ed9e02f1d530888ea3291a800b799de6e5dce5717597aed5b02f96e6874819", "status": "translated" }, "references/release-notes/3_0_2.md": { - "sourceHash": "sha256:4c4fa9537b2f5691880e242791c3b091af34b0b9839ef2e7049adf4ed82a8801", + "sourceHash": "sha256:c259a508fc8aa8ab579248baa48edc26886cc11a7b46c1c300f5381f7f9a7576", "status": "translated" }, "references/release-notes/3_0_3.md": { - "sourceHash": "sha256:68d74ed0dd95601fccb517df398d555811ec85f50470b976df9b3b567a73a434", + "sourceHash": "sha256:e34eb6b6cc40a550c2b1e5555ab3dd5ae9aab4fe150191da8b217f7e1c44b7f8", "status": "translated" }, "references/release-notes/3_0_4.md": { - "sourceHash": "sha256:eae59197c4a996948b54885cc9f9c4775e38ad255f256f5f8b35219c7e52db79", + "sourceHash": "sha256:0745acfac1154de9e0b61300052c871454382fff3120fa89b0bf5b794522bcb1", "status": "translated" }, "references/release-notes/3_0_5.md": { - "sourceHash": "sha256:c39eb7e39bb3704d5691b8a4c0c7c3f9d5525c46586f29ffac646c2aa55f78f1", + "sourceHash": "sha256:0bc3323e03649c932b63cb6997eb73c33f7d8211601640a21630c7cb61851ffd", "status": "translated" }, "references/release-notes/3_0_6.md": { - "sourceHash": "sha256:6c43864671d333449ff843ba761a878b5bd9c3a4e56407ee193f25e07f7fc82f", + "sourceHash": "sha256:1e570cc335e2d65c8c4a137009c2aedaa3aeb09e0a45140121b6bc7cbe1d7468", "status": "translated" }, "references/release-notes/3_0_7.md": { - "sourceHash": "sha256:bd673da3dffc46611b124a292422bde1ecfa8360f0cc49732c029d610e6ad343", + "sourceHash": "sha256:5abefcdef1f490a553d1a625f1b3ae2c22e063ee14d751d483feeb1d347593bc", "status": "translated" }, "references/release-notes/3_0_8.md": { - "sourceHash": "sha256:df7eb26d1c7a747cbadc84887b83437c36b038f3578b32e07d656965a6fb8313", + "sourceHash": "sha256:cb1bfca1fe61a069fe1f3bf3f53ea69ba4209a25e434d94b0b1ff4fb63d64012", "status": "translated" }, "references/release-notes/3_0_9.md": { - "sourceHash": "sha256:8efdca40e1b9b66def91c1f2fa0813171e54537686a5cc4e1a17be4fc9130723", + "sourceHash": "sha256:645ce98903aff7368af55a9b23fdeff5964c5804e16d3dfbbebe34f2eeaed37d", "status": "translated" }, "references/release-notes/3_10_0.md": { - "sourceHash": "sha256:5049f3d981c44f82d9cecbc0e1c5ac0c8b5ab5137280e9957dfafdca0126b717", + "sourceHash": "sha256:280c2247e870b506a5134ad2da70ea21f53a5d7b2ad8e9f5319414e2f21d29b9", "status": "translated" }, "references/release-notes/3_10_1.md": { - "sourceHash": "sha256:875854de66aaec104359b884495bf6b51bca7325859d14fce9fe69057f6d7690", + "sourceHash": "sha256:8fcb4cee86daee9325723dc6cb6b9f3ffb3d9a41e67f0fc2c35510b8e0a78521", "status": "translated" }, "references/release-notes/3_11_0.md": { - "sourceHash": "sha256:021ecd692848551cfac260cbeb0b916c55df53273bbf2a197587ccce5b28d8db", + "sourceHash": "sha256:00487e4ef0ddf0ee9e93644ec740a1381bc984d716553cb60d2abc0140fc7b6d", "status": "translated" }, "references/release-notes/3_12_0.md": { - "sourceHash": "sha256:edb7df4534aed409999badff422f868c8c8014e8a13526c1cc378c44fc5e3837", + "sourceHash": "sha256:5ce41677cf81415a721c8db3c5c7e435c8b9c6fcf6ba61af7932cfcd2cd7f2b6", "status": "translated" }, "references/release-notes/3_12_1.md": { - "sourceHash": "sha256:f16279a68ee3df9a44a514232180cb1f3c1eb876c79c5c248d5c4fc0f3606893", + "sourceHash": "sha256:48c8104c99b0af6a26485ed1e19971ac9a06ca2fa143c55d1c2c5c74fe5252ca", "status": "translated" }, "references/release-notes/3_13_0.md": { - "sourceHash": "sha256:499f64520f241e0fe9d67cc959f734a4ae2aafa0e02dec11ec9328198a176159", + "sourceHash": "sha256:43b9bff69d3f4fd23c2f98102be764674c6ee63c5832e8dccbf6cadcb2d5cb0a", "status": "translated" }, "references/release-notes/3_14_0.md": { - "sourceHash": "sha256:31fd3fcc3de4989ef621536886603c7d14809ff8abb2b92ecee610cab80ea447", + "sourceHash": "sha256:f9819f664683671f5c6ad1f4b38f27c0d6390d88d748746ea2ca6f939ec57a3b", "status": "translated" }, "references/release-notes/3_15_0.md": { - "sourceHash": "sha256:f6e31f4a06cf0638a436461ca586375185fb6d83bb1f84769ca77714d6d85373", + "sourceHash": "sha256:892511af91a5becc0cddf3c17031eb2772f1c0362f2e144fa6137a9cfef864e1", "status": "translated" }, "references/release-notes/3_16_0.md": { - "sourceHash": "sha256:1b4ac68461f244e79d06ab74147edda6273a66d810cd7a51ee208692bf570d25", + "sourceHash": "sha256:c370d56ce763d64929dab9191dfec25d4d8e5cf4aac5ae242c965213497c07c9", "status": "translated" }, "references/release-notes/3_16_1.md": { - "sourceHash": "sha256:72b2fd9dfd0d056f91f0b57281b175432f4e1be79c2eddbd821fe69089461ad4", + "sourceHash": "sha256:8c963ee75f969d1f42991744d912d3ee9901209fe4729421791fff43cef34c16", "status": "translated" }, "references/release-notes/3_16_2.md": { - "sourceHash": "sha256:43b57904637bb57a6761d28eb7fe672e6c379f2dcd70d59c0a0e3f2d40690d7e", + "sourceHash": "sha256:2afcb4c569c945fcefcbb4ae5db451c3d3e6c386d6ce01520456af225cc397d7", "status": "translated" }, "references/release-notes/3_17_0.md": { - "sourceHash": "sha256:7a2234e15b112c51fa69cb1ee20242f34e58f555d2aac7ec85b3eaace801d276", + "sourceHash": "sha256:c8d747fa588ed2e9724ca5642094b3183a7848de106206c5acb7a6db003f03ad", "status": "translated" }, "references/release-notes/3_17_1.md": { - "sourceHash": "sha256:5506c04dceed3f9087ecc8ffccc73e0c846dc564a7bdc5dc8d3bb059521946cf", + "sourceHash": "sha256:43426ca1856ba7146d873c25c365fd7ca9dd6c2dac27172d4302aa08caf73a13", "status": "translated" }, "references/release-notes/3_18_0.md": { - "sourceHash": "sha256:794ca24e8ddacd1ea723639b9511a1550e9d5e9fbbc9e924f241c2871b9c2196", + "sourceHash": "sha256:ccef1d643cac9bbba5813f1f9101466c79a12f8adf5622f1b20780964bd39b36", "status": "translated" }, "references/release-notes/3_18_1.md": { - "sourceHash": "sha256:821a9da8c370a714da2ea8e89bfad676e8115c366019ef36f46edf0e6127fe7e", + "sourceHash": "sha256:864335c116512c8d22394bc0b36873a8c4855d1a61a18f15524bd16afabb5869", "status": "translated" }, "references/release-notes/3_18_2.md": { - "sourceHash": "sha256:cb3a96d6736f0a2394f226d3c1d7ffe1888781741ea19ed03b4dcff3fe8474a0", + "sourceHash": "sha256:498e7482728a3b72acb8b42b8eec7caf732df56677d3b44ac0fbf5a32562ef17", "status": "translated" }, "references/release-notes/3_19_0.md": { - "sourceHash": "sha256:c35acaddef5e1a0c26d8946c910cc60e73fd7387e68a4258c06befbbdb4633f2", + "sourceHash": "sha256:1a50d8f5bbee16ffcefb119bcdc3f2ff8f0ee153f7f9848f0a4acd7383f8ac16", "status": "translated" }, "references/release-notes/3_1_0.md": { - "sourceHash": "sha256:22b2ffee0b524fa1ca565e36f263b612fc243df5f213ff2a39b86d1a52bad6b8", + "sourceHash": "sha256:0585f4b1aad5d388d66147afa156d2e57e8f615ce5c3d3fb0830925d5393ee61", "status": "translated" }, "references/release-notes/3_1_1.md": { - "sourceHash": "sha256:fef2f46ee6c5fa38fd785bc0a3b0de38c79d2b654cd906c61577cae09b6511c3", + "sourceHash": "sha256:1d6e4b185d09f93a6688f14f954a766353fd3daa17ecedd6689735c2c01c5208", "status": "translated" }, "references/release-notes/3_1_2.md": { - "sourceHash": "sha256:8741312f51bdca558a909b8a5b6bb6466a9d9d9512960687ef611980b7fb13b3", + "sourceHash": "sha256:9f7ef93ca36c28f28b2a61b62b7cf68738da2a31a73abbc67ebacd4be6b05915", "status": "translated" }, "references/release-notes/3_1_3.md": { - "sourceHash": "sha256:4167126ca437e2f0bf9162170b8fd9b183b560e455767835b7593a2b91507e26", + "sourceHash": "sha256:63da26ecf5029706dd4a732a7494b15ff4df4bbf3d4e1e7743f61b46d979d2ef", "status": "translated" }, "references/release-notes/3_1_4.md": { - "sourceHash": "sha256:a7ee27790de5c9dfe8e3c1fc919c6f7c8174da7bfa9f50633dbc75a9ae5e0ebb", + "sourceHash": "sha256:690c2c05528da4f99adb4177a5e1ca448305c1093948d0cbb7948242961aa453", "status": "translated" }, "references/release-notes/3_1_5.md": { - "sourceHash": "sha256:50e2bcd0f0552965f413a0675665e4aff37913526fce4b762fe745453a927309", + "sourceHash": "sha256:c8984a8ab4c13021d98434ce71d4a54db9636dd3725c313de6795e2d6ad4fd88", "status": "translated" }, "references/release-notes/3_1_6.md": { - "sourceHash": "sha256:ee331894b3d1d983cebc386a7722c268df841b87982e7cb00b4c8238d91849f6", + "sourceHash": "sha256:1e7e09b6f4d7b733050ee43873ffe842c7e55faba945cd9d5afce8996739f183", "status": "translated" }, "references/release-notes/3_1_7.md": { - "sourceHash": "sha256:0c2dd7f51db662b57a39a2d62b47947aa0b139449a1872382288078d3588de4d", + "sourceHash": "sha256:cc33ce5dd956b6802c5184d84ead86749e676bddf9034b7228b0963ab1fa8279", "status": "translated" }, "references/release-notes/3_20_0.md": { - "sourceHash": "sha256:b7b01a2570adb84248f5daefa4fb67c904aa3e86c5a6f76981b39eaac307e120", + "sourceHash": "sha256:5a259bbe3b2d0cb22234ba3fb754c0fc17f21a30ad3eea8af4090108d1103488", "status": "translated" }, "references/release-notes/3_20_1.md": { - "sourceHash": "sha256:c2c6eea97accfbe2a647977c0c5bf465d9ee8b1acf04d9873b32371268550fa6", + "sourceHash": "sha256:1adab99bfc877197b1138f98ae2889337b269b54951dbf8984609192eeb9f5ab", "status": "translated" }, "references/release-notes/3_21_0.md": { - "sourceHash": "sha256:42faa7c3ce84c0f5c7d7784e97add9b7bedaf38f2f6b3221b2bc304d864d43b6", + "sourceHash": "sha256:e989c0564fe64acbba6215cf25eacb1e79c6da59f17756ad9ea74500a059dc8e", "status": "translated" }, "references/release-notes/3_22_0.md": { - "sourceHash": "sha256:bc2ca2fdef85e48d39ea13b915a49c718151242d913c81e0e6882a1e9bbf6327", + "sourceHash": "sha256:71625baf4598ce69ea8022cde2848d04b60229d8c60ad15bb9fb806540d62288", "status": "translated" }, "references/release-notes/3_22_1.md": { - "sourceHash": "sha256:ff9af1848652e8e0b7bd21630e2ac64b802c17454d37394ae9355efff2c23c67", + "sourceHash": "sha256:1e3e64b4b3955f35edd2a9c0bae68ca092be3729c7cf46ee376234ef1f8432f8", "status": "translated" }, "references/release-notes/3_23_0.md": { - "sourceHash": "sha256:4d58b8bce271a54de47e21a2adef2fe4eda998ef7ca6a8bfb5b51fd9aa150dee", + "sourceHash": "sha256:5dfe7273a83bb750fb67556bfaa0494f3474489e8584d8a4416f81db7638e4b6", "status": "translated" }, "references/release-notes/3_23_1.md": { - "sourceHash": "sha256:3ff1341b1e0137f5fe9207ae386a30817be1497345d8cfa5df1a4b4b8dd6107f", + "sourceHash": "sha256:a9693c900f2bd1ba870cbd36bbd3dae8c5b2cc07046953c0f1c62f8ab073b41a", "status": "translated" }, "references/release-notes/3_24_0.md": { - "sourceHash": "sha256:ce832ff6d1995bc3638f1965f1ecefde7c0e49932f2fc42507b450193b179935", + "sourceHash": "sha256:7e06a6582ce450f85e58773fa486f8dfa2b57ae5ff8ba5228e90a2df42b2b0a2", "status": "translated" }, "references/release-notes/3_24_1.md": { - "sourceHash": "sha256:80e1125a923675ad3e4504b46b37640376e194ff5ecbacf6a00330b20a8a6ec5", + "sourceHash": "sha256:2f6edfbfe6bac0cf0c6e0ca0c941208626b1ae2df232c8dc14046a2c6312fd9a", "status": "translated" }, "references/release-notes/3_24_2.md": { - "sourceHash": "sha256:893114bb290ec42da3d606631f01ed47ba501f9fcd4cf7d4bdcdd73af8ed9836", + "sourceHash": "sha256:002bd955620438ac8a76221d201bf17027262f15b5356fd994a340dae2953c46", "status": "translated" }, "references/release-notes/3_25_0.md": { - "sourceHash": "sha256:1ea99aa120d56dd1cce9309c1cef346bb8bf62619420099a278e13d2ce123622", + "sourceHash": "sha256:94073554f6e49579ce01eeb1ad53b54d66d8a2949d9fb2281123688b597594b1", "status": "translated" }, "references/release-notes/3_25_1.md": { - "sourceHash": "sha256:a87e83af303e7f21e202aff5230470e76aff37a0f4d047a451875ebd1c17aad4", + "sourceHash": "sha256:5327769633fab3d45273026fa50388e6cc572bc4e3ad4e1685ae544d777d96be", "status": "translated" }, "references/release-notes/3_25_2.md": { - "sourceHash": "sha256:112207b0ea9c112fff765c543e76ab4126a57aa808225f33b4540b5923b9e827", + "sourceHash": "sha256:ed0cfa9bb38b492a133d7742ffb1a57169616ea1f604ef3091d0dba292a5d9c5", "status": "translated" }, "references/release-notes/3_25_3.md": { - "sourceHash": "sha256:4012345c860df7cd946fb6841ffae2b784639ddcbd5a219b16155addca036efa", + "sourceHash": "sha256:e2aedc2e010c287c6a6480f950eb5abaa06c24faeec99b6f298ca9236e8ecc11", "status": "translated" }, "references/release-notes/3_25_5.md": { - "sourceHash": "sha256:7144981670ed4e6fe18d1fb3e28b158cd32912ba22b1bd20798bd20d32cad741", + "sourceHash": "sha256:cbceecff8d36b2bb9f4ba3a54c9957fd081f06ec869f2d37eaa14bd731854744", + "status": "translated" + }, + "references/release-notes/3_26_0.md": { + "sourceHash": "sha256:877aaef72b1b712c4ac6060a0812b8f6a37e85b5d7f7c907d73aaf0aec50b4c0", + "status": "translated" + }, + "references/release-notes/3_26_1.md": { + "sourceHash": "sha256:454c83599c5098dc644a438bdf15a80714a45f53fb63ac17d8514b1b197050c8", + "status": "translated" + }, + "references/release-notes/3_26_2.md": { + "sourceHash": "sha256:fa204a34a64c2f6a9e2764031e5c0c255b684edd6cbe18fd195a0d4fea2978bd", + "status": "translated" + }, + "references/release-notes/3_26_3.md": { + "sourceHash": "sha256:feb01ccd22d33c9adb705a0b7f6ef3523f4dfbdf1905410809d6b3f193515335", "status": "translated" }, "references/release-notes/3_2_0.md": { - "sourceHash": "sha256:6ecf64ba7d0f65cd64c9b32ab46e3850b1c7ec033b9476a6192d5769c5bb12f2", + "sourceHash": "sha256:f80a34035dde22a0619af4ce18d51f59866d2eff27418c893e676f9da877b10c", "status": "translated" }, "references/release-notes/3_2_1.md": { - "sourceHash": "sha256:ea8937b624cc1128a892a687bfd23fdc536638d8a81efef8a2048baf632cc7a5", + "sourceHash": "sha256:43e1be32e10f7a17a7d86df369eef21a78ebe5b2a52b53a1f83b6fd386b29049", "status": "translated" }, "references/release-notes/3_2_2.md": { - "sourceHash": "sha256:744d48cbe380075f3a3d442bebfff7444fa9023e2d3945a6e50898e4600841fe", + "sourceHash": "sha256:2da5802638de5f4ebb2f12cffb33be191351e460ce411293e865bf8310ea9747", "status": "translated" }, "references/release-notes/3_2_3.md": { - "sourceHash": "sha256:8c5389af01656e536faa8eabe7460e18c6aab4a62c543d80299e1cfdcc1afd50", + "sourceHash": "sha256:233ad017abfdf9b6cf5938bcd58d76dfac5aaa83c691e6b8613e8501ead44f6e", "status": "translated" }, "references/release-notes/3_3_0.md": { - "sourceHash": "sha256:f15db7654636a547034b22e1f5bba04be511349b93e1aef08fde7010f43eabaf", + "sourceHash": "sha256:6407130a08fc4aba3742cdc01f6fcdbe315036324a6ef4ef485750bbab90d75e", "status": "translated" }, "references/release-notes/3_3_1.md": { - "sourceHash": "sha256:d272ef003c110516559a348f5295cf82a4ee1bff2e009b882cb7bc7cb51092e0", + "sourceHash": "sha256:069220239a8a82d1f0d310a468b3af4959525d5b03e11298fb2049e0ebacc52a", "status": "translated" }, "references/release-notes/3_3_2.md": { - "sourceHash": "sha256:d08125afaed41782db0bb8fbf83f2e78c28e8c46db887d437cfb31545a3932f9", + "sourceHash": "sha256:e11afa6e505c7d0d7a2cc19b12232f05b12a402d1534d689a3987a692ad6eabd", "status": "translated" }, "references/release-notes/3_3_3.md": { - "sourceHash": "sha256:e5b8c6b56a4b96f45972422c4fbfa03fbb709c018d5f11590deb5c2b71d8fd83", + "sourceHash": "sha256:b25a3e45ea9245ff2348aef322932081aa9419469de741bd98ab45435fc01106", "status": "translated" }, "references/release-notes/3_3_4.md": { - "sourceHash": "sha256:84c5028c878ef1c45ce054680d1333e441f23771611bfb7ea382481a1ffad6fc", + "sourceHash": "sha256:dabac3bc52ea3ecb59ffc39e10a13d252a29530412a604c284cf33fa988e7ccb", "status": "translated" }, "references/release-notes/3_3_5.md": { - "sourceHash": "sha256:d5feae59a649bf8b811f5cf755c2916166de03e67770cd8e34b11553b38a87c0", + "sourceHash": "sha256:b88d6bc31375caca6ccbc5af1f95d1c64a64bac5d5861d540a5a111046ec2709", "status": "translated" }, "references/release-notes/3_3_6.md": { - "sourceHash": "sha256:65208a7bf0072e7a0d2c03b7bff4e915f51a9887a394bd439576b5146db3282c", + "sourceHash": "sha256:2eea5eec39317d1fc007cffd6971bf08411e04b79984bac9e9e7c5f4a9237329", "status": "translated" }, "references/release-notes/3_4_0.md": { - "sourceHash": "sha256:f20043008ef5673020ea9daa33bf6f8587005a191248e74852f7b45b12506442", + "sourceHash": "sha256:45921e73217c3fd5d69b614e10428aa428b0d466bdb49d83058cb79a49b846c7", "status": "translated" }, "references/release-notes/3_4_1.md": { - "sourceHash": "sha256:429344a948a9ffe9a834aa625598625247f93164f72bb04d712ad3346f3b61b0", + "sourceHash": "sha256:2c4222b3083566a37505c7ede112ee3076111acf89a9cacb7335241c5195a1be", "status": "translated" }, "references/release-notes/3_4_2.md": { - "sourceHash": "sha256:36fb93b69db715b9f5b5b398530c3f9b58026020af5876ac9959996d4676cadb", + "sourceHash": "sha256:8aca2f841be6c495daf791f1c8a812605c4215639dd6d61bf9a99a1450913eb3", "status": "translated" }, "references/release-notes/3_5_0.md": { - "sourceHash": "sha256:617775d38e30f4b632cee846088b385ad92586f59a159b2b47da954df2ceba36", + "sourceHash": "sha256:3e8574e571c37b8fdf644f42377a8f20e4494f57683aa2aeeb66f6cc5dbb3b3d", "status": "translated" }, "references/release-notes/3_5_1.md": { - "sourceHash": "sha256:886e863e3e341e4f6f2247545ca023731546bb974b7280b8414f40a9b1d9984b", + "sourceHash": "sha256:bbf6c0a5eaf0581ef564c23861341ba086a336a7a0c8556b18c82a3b97263de0", "status": "translated" }, "references/release-notes/3_6_0.md": { - "sourceHash": "sha256:596c121f04782773b41eb122d56f79a8110a9cec2bd7356577d392dbe1dbdcb2", + "sourceHash": "sha256:390ff39fcddb43462ce6371e434faeffaec2542bf1ce3871bf109c31afbebb8e", "status": "translated" }, "references/release-notes/3_7_0.md": { - "sourceHash": "sha256:855ac7bfe0b863764f62d2fe015af40dff189ba01e925b209937f91183327ed1", + "sourceHash": "sha256:1193e309b3e6b7c5ab312cdfac506a029c5d3fe1a46e55c44cfa73ac02b05559", "status": "translated" }, "references/release-notes/3_7_1.md": { - "sourceHash": "sha256:728972a441ab5076cd1c8d6fec671071f19709e59e8ba3461e75947efcf27b0b", + "sourceHash": "sha256:bb30b3f2c2edfb66b5b948a092b63522ffae1661d74837bec19ab8df42931536", "status": "translated" }, "references/release-notes/3_8_0.md": { - "sourceHash": "sha256:22f799307afd7023f568e1ab561d84a04ab17873724b175fc5297a38122db9df", + "sourceHash": "sha256:59c7b7c50555df06c3b4d8862429c9dac365f71abe0b2867433c4eb467ff227a", "status": "translated" }, "references/release-notes/3_9_0.md": { - "sourceHash": "sha256:1a84abd1786511b5866baca58ae4e73d03b64a76948a8ff19a7fb2a2cc23988b", + "sourceHash": "sha256:435580f98c2577516a67e1c12f4c451c0f3116bc864b4dc1f474b47650d048c1", "status": "translated" }, "references/release-notes/beta-16_6.md": { - "sourceHash": "sha256:6f69b0b3fecd9de0dd92b1e8ddb826554594f74ec46c71a4a0e3548871b40290", + "sourceHash": "sha256:ef23109d790ae2a02b9e8103f0832d951f09230342655ae7d67360e532b2b2a5", "status": "translated" }, "references/release-notes/beta-17_4.md": { - "sourceHash": "sha256:8172363576b8f546b2ebe6cd9ce517f99fdf56ce3ff29ea87eabd5bccfaeb3b3", + "sourceHash": "sha256:5fc5a6537d9c3b6e46e1798fa60fcc348258e23722c2fa7936f1950a6e94e9ac", "status": "translated" }, "references/release-notes/beta-18_1.md": { - "sourceHash": "sha256:9a598f4bc2cf7575f46ddb732bc0d96ba65659dd6dfb180488c5eb667e1ac53c", + "sourceHash": "sha256:948893b0e25d9c457f7757c8c2aed0be34f9fd7897e47dfbb9d48adf34281a9c", "status": "translated" }, "references/release-notes/beta-18_2.md": { - "sourceHash": "sha256:03a0435624e0bd41b2667a98fee65f90a4c80355f4a2237cd941ad85a138577e", + "sourceHash": "sha256:44023c249d6312fe7174132138a20bee776cbaac990f9f63be3609f405cf7ecd", "status": "translated" }, "references/release-notes/beta-18_3.md": { - "sourceHash": "sha256:7493081d9011736b7ac078eb92bcc615e41f8a76aa4d0af6f966ed5a9ea671f3", + "sourceHash": "sha256:a65ccb795d0cfe9ad39045068462b4c4fcb92940cca33dc06907e32c84994f90", "status": "translated" }, "references/release-notes/beta-18_4.md": { - "sourceHash": "sha256:1c13e6e1d9e4d852a20450200cdd205a3f8ab0c655a8ccfe81a52ae0e2bf6246", + "sourceHash": "sha256:f41a8fb3249f05fcee27d35961101bacc220c9914d9cc3c74f1d515ae6b6723d", "status": "translated" }, "references/release-notes/beta-18_5.md": { - "sourceHash": "sha256:6d943c183893bf768292e4a185899aa9f78839292bc0839eb685583ddec03358", - "status": "translated" - }, - "kb/bpa-avoid-invalid-characters-descriptions.md": { - "sourceHash": "sha256:e1ab3b636de9a49a6c62cc900b49c51e138d0ee3cc1588bd2c102b16056449f2", - "status": "translated" - }, - "kb/bpa-avoid-invalid-characters-names.md": { - "sourceHash": "sha256:dbb102b008123984fdb1e23f2a6c47e8e6741cc85a28ad6c20111e1915548198", - "status": "translated" - }, - "kb/bpa-avoid-provider-partitions-structured.md": { - "sourceHash": "sha256:448f378b6fc6d8d04266a43c75302dd7252d623fb811555c1cf0fb8240e6c6cb", - "status": "translated" - }, - "kb/bpa-calculation-groups-no-items.md": { - "sourceHash": "sha256:3437dabfe44b34fa1a43cf96ce9853060fe16f0e3e742906bb14dc13860628c5", - "status": "translated" - }, - "kb/bpa-data-column-source.md": { - "sourceHash": "sha256:b7f6ae4783b0623ae359df487021142386d62c94ae785950e33cf58aa9b4964a", - "status": "translated" - }, - "kb/bpa-date-table-exists.md": { - "sourceHash": "sha256:cdd48113cf8b398961e8180b357e538ce668dac9d7957a7cd57f9b5f79383347", - "status": "translated" - }, - "kb/bpa-do-not-summarize-numeric.md": { - "sourceHash": "sha256:6ba62d431be290fc77e7a8292ce582d2e0f0a427b70b3ff8139aba8467c5fd9a", - "status": "translated" - }, - "kb/bpa-expression-required.md": { - "sourceHash": "sha256:70979585bf4e27b51ab9fbc2ceb7fe01d0b380a6422f32e3dafb25831f505ccd", - "status": "translated" - }, - "kb/bpa-format-string-columns.md": { - "sourceHash": "sha256:e21436631db1326ec9bb6e5144c3596b6b4cf08fab5c2b50753baa3925c2c815", - "status": "translated" - }, - "kb/bpa-format-string-measures.md": { - "sourceHash": "sha256:3bc53cc91e1370a99162e252117bc534514140bd54d9f174d0b7905d97f63ac8", - "status": "translated" - }, - "kb/bpa-hide-foreign-keys.md": { - "sourceHash": "sha256:299852702c84c3e412fd28183dd3c3a1a14c7fed99391bfa4b9a55e2e95d83c6", - "status": "translated" - }, - "kb/bpa-many-to-many-single-direction.md": { - "sourceHash": "sha256:69900d8c9a83fa75dde15bb6015b975d1bafbfb55f5f9e81303705aa3ab792f8", - "status": "translated" - }, - "kb/bpa-perspectives-no-objects.md": { - "sourceHash": "sha256:fb6fdaf5edb9d02bfbfeff47bcab9ed92c613d4a73aafa23b3be594913b5fd4b", - "status": "translated" - }, - "kb/bpa-powerbi-latest-compatibility.md": { - "sourceHash": "sha256:9385968afdcce0b67a7332c7ccd7efb4c633b4b485581a2d244530269082c52a", - "status": "translated" - }, - "kb/bpa-relationship-same-datatype.md": { - "sourceHash": "sha256:a61a0744ac2497ace5ff92e59f31d563df61216a04ce64aeced2e30240d0ac4f", - "status": "translated" - }, - "kb/bpa-remove-auto-date-table.md": { - "sourceHash": "sha256:96c8da84cf9e550ab9b1d40aa8253796cc325ffc2386bd875e350f5fadd4db52", - "status": "translated" - }, - "kb/bpa-remove-unused-data-sources.md": { - "sourceHash": "sha256:c49846acf4d5326b6dcfaffd42ae33d38a110cf111c14cca3476ca89d9819c32", - "status": "translated" - }, - "kb/bpa-set-isavailableinmdx-false.md": { - "sourceHash": "sha256:f3d18c1a403e7cad3d49570f209c68ce1ab539301c0e0099f4aa0bbe1e8f5f8c", - "status": "translated" - }, - "kb/bpa-set-isavailableinmdx-true-necessary.md": { - "sourceHash": "sha256:b0bb9ccba1eaddf3abaa7def7c1fe17c9cbdfd7ea8e4f728d919f54b6b516f66", - "status": "translated" - }, - "kb/bpa-specify-application-name.md": { - "sourceHash": "sha256:e93bf3c1c57fa05f0522694980f7b511779ac127d6a6abf47571a4500d7320f9", - "status": "translated" - }, - "kb/bpa-translate-descriptions.md": { - "sourceHash": "sha256:241a9cbde9056914f8494f0d29e37d0f17266b9aaaf672c0c731610bc1130d94", - "status": "translated" - }, - "kb/bpa-translate-display-folders.md": { - "sourceHash": "sha256:aad20fc98449ad88378f6fcec1d4da25429fc0dd15710d8e0cddb4062944480d", - "status": "translated" - }, - "kb/bpa-translate-hierarchy-levels.md": { - "sourceHash": "sha256:71d10084d1e0f2c546fce80d73388ea5da05135f813b8512fdf60a5593381711", - "status": "translated" - }, - "kb/bpa-translate-perspectives.md": { - "sourceHash": "sha256:b1ba0f2c572b59dd564e9fb2ac02cf2fc05d7fc495b0f6ab5406ee8cf4027912", - "status": "translated" - }, - "kb/bpa-translate-visible-names.md": { - "sourceHash": "sha256:fe5dd4b69830746c9af69840c8f8975c1e279f4c5959f90f7388ac27b0552001", - "status": "translated" - }, - "kb/bpa-trim-object-names.md": { - "sourceHash": "sha256:387cd8339cd496acfed940f772b2772b55a923269f4c2e8e90e5bd7f48a8bb75", - "status": "translated" - }, - "kb/bpa-visible-objects-no-description.md": { - "sourceHash": "sha256:a0eaaa105b4326ffa49f4e6240b4cb6f84e0007bfa455065d64bfe721bcf5f9e", - "status": "translated" - }, - "kb/DI001.md": { - "sourceHash": "sha256:25783e67a976a42a7cabe303f2c5381f58d07b67de7bc5f89b84f438c86cc299", - "status": "translated" - }, - "kb/DI002.md": { - "sourceHash": "sha256:561ec551f444faf58f604028df338a3bdb83cc37ac6cc296db53d1d0a2d8aa9b", - "status": "translated" - }, - "kb/DI003.md": { - "sourceHash": "sha256:76cfa3c7a491d3b15f4dcbb8793f0ca59da12cb8aed5db6cee66b15f6943f027", - "status": "translated" - }, - "kb/DI004.md": { - "sourceHash": "sha256:1caf47bc6102b0081dc285ecbd9219dd710073c8f3abc8662af3f9d88cac7ed9", - "status": "translated" - }, - "kb/DI005.md": { - "sourceHash": "sha256:7febf977cd43487cb392144bd4223f352d1911c03cb2976f5a9d967f774dfd17", - "status": "translated" - }, - "kb/DI006.md": { - "sourceHash": "sha256:c78dba443fb20fb07079a810a4e2564660e9a0519068713000d203ad52fef6a8", - "status": "translated" - }, - "kb/DI007.md": { - "sourceHash": "sha256:8633dc8c141fadd780cfc42dd27c94d89556a53f03282a1cfade47249922a4b7", + "sourceHash": "sha256:f2edb22650b9e8fc6e21507d01e9ad0326f3fb88d2be6d9981ad513c365f63fb", "status": "translated" }, - "kb/DI008.md": { - "sourceHash": "sha256:48f3168b416e177a426c6e933f7d5a48d8c18749f33693b1600f9882a4f25cbb", - "status": "translated" - }, - "kb/DI009.md": { - "sourceHash": "sha256:fb4313a94b1c454af18a68b8fb2e002f471e0a2d0d97bf100eb05363edff7a7a", - "status": "translated" - }, - "kb/DI010.md": { - "sourceHash": "sha256:43867cfd04ef7ee82d8ee6a8cfb3080bb0e4a703ff4bc7e7d34c8a09728f6e4b", - "status": "translated" - }, - "kb/DI011.md": { - "sourceHash": "sha256:c87f24040121b3efea63e5fee9fce16acf9f9075b76b9b2e4da412eb04720e35", - "status": "translated" - }, - "kb/DI012.md": { - "sourceHash": "sha256:33dd64c2880b75abea78fe99e999c79478066262665081c9269861c8aeacce10", - "status": "translated" - }, - "kb/DI013.md": { - "sourceHash": "sha256:b8ba1b12d831664e73e208b8778daea7cca6b39982b40aeac7f0c7cb67698fd1", - "status": "translated" - }, - "kb/DI014.md": { - "sourceHash": "sha256:2d337e7363fb934ca706e037e388710c2e05aefaea1c64f58b430ecec12cec99", - "status": "translated" - }, - "kb/DI015.md": { - "sourceHash": "sha256:08da38c3345155cb4d2826a3974c8aac8f7e5607baf2408bceefd7c3aa20f5eb", - "status": "translated" - }, - "kb/DR001.md": { - "sourceHash": "sha256:95d71ac50e5393d178a2810260b4d14ab400bd2472cedd7b34f4ab76eb4a95d0", + "references/roadmap.md": { + "sourceHash": "sha256:b0995af14ffeba3c104b4bcc0d65bfedea42ef4e3ddeeed9e8d2d1f594efc0ef", "status": "translated" }, - "kb/DR002.md": { - "sourceHash": "sha256:8f34b0a352f00b138fabe27f6f0c94d486a3ce71e7940eea7596fc66dfa91de7", + "references/Roadmap2-h.md": { + "sourceHash": "sha256:9c6d16935c781d75cd4eb81451ca7614291f880442892daf52d994b7106ccb69", "status": "translated" }, - "kb/DR003.md": { - "sourceHash": "sha256:0c8e4590399adb2a53a133b4495750caa773cd5c79d3de9b75d9c97917c09ff5", + "references/shortcuts3.md": { + "sourceHash": "sha256:4b911e79caf257e42f4a55171c8f98aa6fddfd20b855946a6855a250f3df8bf4", "status": "translated" }, - "kb/DR004.md": { - "sourceHash": "sha256:bb39fa203c9290c3be5226702a613cfde1af0b151f3f4caca990a199f6aa875d", + "references/SQL-Server-2017-support-h.md": { + "sourceHash": "sha256:05b6c42b0be102be9030d3b5b34f801cfdfcfb0c2be058f2b940c96a1d2d9dae", "status": "translated" }, - "kb/DR005.md": { - "sourceHash": "sha256:05894b5b1a0ab1c9f641ced87626eaa332f3a0c575af29ed6faa3a59bfd0a40d", + "references/supported-files.md": { + "sourceHash": "sha256:477feb6c5f662dcd2bd2728ae1309d80336e0603cf2a9bae8064adc2cf721200", "status": "translated" }, - "kb/DR006.md": { - "sourceHash": "sha256:1ba9f39490bc64310a41acd5cef7a9c3b939fa5d22c28ca3bd211b28b7cb0cc8", + "references/TabularEditor.TOMWrapper-h.md": { + "sourceHash": "sha256:d1ed449a9a96c0031fe8b280f1c808858196806f71363d8fa9447e0db6e7d71a", "status": "translated" }, - "kb/DR007.md": { - "sourceHash": "sha256:53f849a4055bb6875e47677d8f85296ebf8877a9dc0933abd126b141ac13fea3", - "status": "translated" + "references/toc.md": { + "sourceHash": "", + "status": "untranslated" }, - "kb/DR008.md": { - "sourceHash": "sha256:37f6d0770424f1182e0dbeeea9045ce087d9a42eb8492160b130351d49f6dfae", + "references/user-options.md": { + "sourceHash": "sha256:ef957dffafe614addc2110c8fd05b9c9021acc49fde1b975eddb3b431c349e1d", "status": "translated" }, - "kb/DR009.md": { - "sourceHash": "sha256:b0bb1af2ba8ad830e16441b9e4e1f9bfee463f3d4df83c5a663b67167f617368", + "references/user-settings-files-te2.md": { + "sourceHash": "sha256:0a2da6cad41a5e1983662c6d621ab9f78f671aa59f2389f31004b7847deef1f9", "status": "translated" }, - "kb/DR010.md": { - "sourceHash": "sha256:0d27c6fd8af6bafb4f91cb941cdca10fbcd3a624d0066daab88634354a3c5b4d", + "references/whats-new.md": { + "sourceHash": "sha256:1d8d160e5a2a13c2b73bd97fa4b42f9c7f0b6ee04dbce8501039f39eef107b4c", "status": "translated" }, - "kb/DR011.md": { - "sourceHash": "sha256:d2a50f75d0a4feb91e1ee4c7879e4839aa5d8193b0ad509da5fd892b655a6430", + "security/gdpr-delete.md": { + "sourceHash": "sha256:25dafde7519c658a7212697ee19bee0033405761a2dfda883549908d63e7ea1f", "status": "translated" }, - "kb/DR012.md": { - "sourceHash": "sha256:8394a610677b999f04ef2be435bb2b53b838fa924fcc8c6b195720fd20cc63cb", + "security/index.md": { + "sourceHash": "sha256:14f520a1bc03f8d8f7e417d4dea1e7034f2e4c45addb8b9cfacb97e6585efa65", "status": "translated" }, - "kb/DR013.md": { - "sourceHash": "sha256:57fb582e349dc96388134c30775e3956f18f1370030163b93c7d5f6b2ed74264", + "security/privacy-policy.md": { + "sourceHash": "sha256:3b3d626bc3e999fb30369deca46398b5799282797bf93325e4566fab677fa59b", "status": "translated" }, - "kb/DR014.md": { - "sourceHash": "sha256:204c5bed823d9cf87efdf87e45959ca9b172f4e066f8fd59f36417f1342ee8bf", - "status": "translated" + "security/security-privacy.md": { + "sourceHash": "", + "status": "untranslated" }, - "kb/index.md": { - "sourceHash": "sha256:86639aab87287a6fa22a24e4bd4e73b4c2101050db8fc40fec71f74578f6c6c2", + "security/terms.md": { + "sourceHash": "sha256:90fd51f0cff45e9e16b2c200fc4dc3ccf769b97a3c6afcd09b7a688cfc5b87f0", "status": "translated" }, - "kb/RW001.md": { - "sourceHash": "sha256:f1eb1722568740f11d8758df85670050ca3dcca28fa288911bdff6eeecd06c44", + "security/third-party-notices.md": { + "sourceHash": "sha256:f21ab9783b89a6295a1343c4cd986b90acbf7940eb0f49d2b3ef33aa05d2282f", "status": "translated" }, - "kb/RW002.md": { - "sourceHash": "sha256:b7a7ad43cd3d1034dc3bf9f7507601265960becd05ea316fd1bd8802d01b117b", + "security/toc.md": { + "sourceHash": "sha256:d9a103f54e335e2e91434c926317f8f4dda1ea8c20b45ae25b6700c958db3e83", "status": "translated" }, - "kb/RW003.md": { - "sourceHash": "sha256:e41f03c10279ba88c2bfb1b9d3a5eafeb8d9fa536f459809b57f0291407d25eb", + "toc.yml": { + "sourceHash": "sha256:11d5df2671e45efed1baf56cd10c6634fc48f5f34b8f40d5b83456258a929108", "status": "translated" }, - "kb/toc.md": { - "sourceHash": "sha256:64a6d46f855afa5a748dc2f2393641abdc38473e142817a9e0e043a924e01c1c", + "todo/as-cicd.md": { + "sourceHash": "sha256:b9c4e2179d5be3177a2119ec147214c8f0e5f19bc82993fce18a1712717ad839", "status": "translated" }, - "security/gdpr-delete.md": { - "sourceHash": "sha256:e21e2f61114f7765cf5dab166ac9204244d3b6d056567ceb947410171480436c", + "todo/Maintaining-Calculations-using-Scripting.md": { + "sourceHash": "sha256:f01409f53e2dd517b8504f021d56725efc821bff3e94d5cf25c223f6ee9ed977", "status": "translated" }, - "security/index.md": { - "sourceHash": "sha256:956d0f7d76b544f0b5f70f7de43436d701221ca2c842efa8bef4ebb0e91179b3", + "todo/powerbi-cicd.md": { + "sourceHash": "sha256:a564d8b5f8312289b739fa332d0dfae6a3b07822e454da25c6f10c16b1e4c016", "status": "translated" }, - "security/privacy-policy.md": { - "sourceHash": "sha256:da89cecb73fc1b4de82c27c50ed76e6ca8f9365c1018f329a2502d855c5d9b1b", + "todo/te2-advanced.md": { + "sourceHash": "sha256:02b6a9bfe2f57d4a8ca9cca47d635343f4dee8b1759807de26faa9ffd51157f2", "status": "translated" }, - "security/security-privacy.md": { - "sourceHash": "sha256:449a306a6aa7e7376460bfa3c802d42fa6e53781fac39a287a64bb2cea0e349c", + "troubleshooting/azure-openai-connection-errors.md": { + "sourceHash": "sha256:e0212bb587e9af4f18ba63af831a01f9e84b893a8306685475e6a55abbef8650", "status": "translated" }, - "security/te3-eula.md": { - "sourceHash": "sha256:52fed84c515c55a2415b99bfbece46051c6d12cd409b324fbd52fab222214435", + "troubleshooting/calendar-blank-value.md": { + "sourceHash": "sha256:0306783580e503f6745682cf15906a60703d87b56ce743eb0f4e04c6b2ed4ca9", "status": "translated" }, - "security/third-party-notices.md": { - "sourceHash": "sha256:38d3a67fc048930c6da47d2f6af09fbf463d002dced7a385e587389aab42f2f2", + "troubleshooting/composite-model-measure-formatting.md": { + "sourceHash": "sha256:752fe4e77aa95d8710d48287f8d650ab12b71097e3dd81e49f50f72dd62175fb", "status": "translated" }, - "security/toc.md": { - "sourceHash": "sha256:cd3387b30602644747d9a3268b58f11db953524611b1a0c565af57d99cc954ff", + "troubleshooting/databricks-column-comments-length.md": { + "sourceHash": "sha256:5fb9b67014b00e5af4fe732edd4c5b9c99d00adbe3e251f3213ab4d17f2751d5", "status": "translated" }, - "troubleshooting/calendar-blank-value.md": { - "sourceHash": "sha256:34be544d6ed74231467eb6250280305eec9ac7df68119d382026e4d6a3966169", + "troubleshooting/databricks-refresh-empty-catalog.md": { + "sourceHash": "sha256:b6c29f4798ad8be4abac566ab051314ee0f1e0b477f21160fdbc6c44702195d8", "status": "translated" }, "troubleshooting/direct-lake-entity-updates-reverting.md": { - "sourceHash": "sha256:02969e9dec89cad15a480bfe0ace86e8f3e8ef943b6e0a20ab233034846029c8", + "sourceHash": "sha256:aab510b71ae2d9128c2c1fc565e2e3a3bee5cf2e568fc57aa33a8710b1f650cc", "status": "translated" }, "troubleshooting/index.md": { - "sourceHash": "sha256:30d2e5d94e50a74d5ff8522ce6a738d1378034bc97e094cc7f319ce19148023b", + "sourceHash": "sha256:201e48fac8aefa81c9dbcf119fc81fc8783ecc822ec5bcf4dd6fc464b8c15393", "status": "translated" }, "troubleshooting/licensing-activation.md": { - "sourceHash": "sha256:d64bc93160e955bf39b7f7c316200882e301e466d0c40548e3f975bc258096fd", + "sourceHash": "sha256:55b69d4c3ecc2cf40666029a8ec010e1305a09be496c86b72367bd4bf769ecfd", "status": "translated" }, "troubleshooting/locale-not-supported.md": { - "sourceHash": "sha256:381d1e4b89e12bd52d76bf83d583c5df01918b3b1e0facdc2b9895c312db7290", + "sourceHash": "sha256:3913bccc1fa6aafb9d2d6ab07f4018ea91efa1c1cc34874fc7267ab9b7034091", "status": "translated" }, "troubleshooting/proxy-settings.md": { - "sourceHash": "sha256:312d26f827c50d0daaf4bc95aeb42b6e263330046db563374d5dc44c3d8524ab", + "sourceHash": "sha256:9de7a8f714d448d4dd5184645833cd2a0ca298d2743cae52595ef2ebb83044c8", "status": "translated" }, "troubleshooting/toc.md": { - "sourceHash": "sha256:123158de09eea87e20e872f5d2eb233c6cbed3f6a585170c2c304edcc4483577", - "status": "translated" + "sourceHash": "", + "status": "untranslated" }, "tutorials/calendars.md": { - "sourceHash": "sha256:cc9cfd5bd837c7e00deb3e1753e72ffa4a7e38441465f2debc08abfdc66fc138", + "sourceHash": "sha256:42d5fbcac1800e22943c402afc8cc0ab45ecae62d802ffafb492d37a3e4096b2", "status": "translated" }, "tutorials/connecting-to-azure-databricks.md": { - "sourceHash": "sha256:05f30942709cf7b0b25b56e593bc29c36564c8b85525eb6240b9c6dcae674156", + "sourceHash": "sha256:269182b9bd66273e3dfa41139b91525312377a98230583820217b5afe3971c99", "status": "translated" }, "tutorials/creating-macros.md": { - "sourceHash": "sha256:aaaf277d68310b19f1cb98127df696d654b1729b083a23211f5050435cddde01", + "sourceHash": "sha256:c6c5951e9b76a66c146878235441b06c929595c6d11e86e2a23f8bc00eb5e9d4", "status": "translated" }, - "tutorials/detail-rows-expression.md": { - "sourceHash": "sha256:525e717f1598862cc24d406a29ff960782a20bd05c80bf87acec6d4e2b26ca3a", + "tutorials/data-security/data-security-about.md": { + "sourceHash": "sha256:cb222b35457c3f4617e95b55a8415187ca28631d10f99b6d3472f61d6da93614", "status": "translated" }, - "tutorials/direct-lake-guidance.md": { - "sourceHash": "sha256:d2f12ce024b19491468051cd810308da7147965aa84fecee4cc255b6a929705d", + "tutorials/data-security/data-security-setup-ols.md": { + "sourceHash": "sha256:c2f2266fc6f918d57da5fc9347a5c21aee8b769c020eabca32b0fc4408605cf1", "status": "translated" }, - "tutorials/importing-tables.md": { - "sourceHash": "sha256:6dee33a9de491f235d0520976222c5fa84aecea75e9c20094fdef12d8bf7abf5", + "tutorials/data-security/data-security-setup-rls.md": { + "sourceHash": "sha256:0315eac2798ccef13b32c9b9566dc914fc754cbbd2d31c130c7a8eca249e0571", "status": "translated" }, - "tutorials/index.md": { - "sourceHash": "sha256:9c632bdce66bdb47d8a087fa61be80247151cdf61601ceb834481f8ef49da3ec", + "tutorials/data-security/data-security-testing.md": { + "sourceHash": "sha256:aa6878e48f87256250f79f03275616644eea55578b54f7e5cfc965b5ecd20449", "status": "translated" }, - "tutorials/new-as-model.md": { - "sourceHash": "sha256:3a3cf1f4b09fa5c93afeef984c89b548eb7c289e47e0b7f64cd61dce72ab88e2", + "tutorials/detail-rows-expression.md": { + "sourceHash": "sha256:649495b03f0cf86488f215704d4353ac0998be3de9eac2cb653a69b0a60b74ce", "status": "translated" }, - "tutorials/new-pbi-model.md": { - "sourceHash": "sha256:8461303b7161afcaf58f8513c85ae2a48f5e3d0c19c4bd45c2fef4149e3de363", + "tutorials/direct-lake-guidance.md": { + "sourceHash": "sha256:12780771ea12151faacfa1f4d911d88b512b898ce535f8634deb6a0fc60ce3e0", "status": "translated" }, - "tutorials/powerbi-xmla.md": { - "sourceHash": "sha256:4ec8d37ee8644e56a17ac7a3662a38de1498b8fdca1ab8a737109fa971ee0881", + "tutorials/importing-tables.md": { + "sourceHash": "sha256:1e9205918c6b3e8b7b93dd6c47efe358059e49876878db2233a25a8476257d1c", "status": "translated" }, - "tutorials/toc.md": { - "sourceHash": "sha256:e79cc11f5e05d84a3f615483f2822ce848db0c6f537da131957927b6569c643a", + "tutorials/incremental-refresh/incremental-refresh-about.md": { + "sourceHash": "sha256:e36870b0ec41dce6e7d95b2b95aed9d996707289be50590585645c26de0cc6a2", "status": "translated" }, - "tutorials/udfs.md": { - "sourceHash": "sha256:dcc0d3f956f4e241a9a6653e852f3823f57fcad790918099135eecc649efa3cf", + "tutorials/incremental-refresh/incremental-refresh-modify.md": { + "sourceHash": "sha256:b984b2ea73f6fe7e10f9d0971da6377b14b7dbd8adde86a7b99acf2b33f5e7e8", "status": "translated" }, - "tutorials/user-defined-aggregations.md": { - "sourceHash": "sha256:8e353aa782edadd1734d8e8c4c4e30b41b3edd150956cb0289c771002181005a", + "tutorials/incremental-refresh/incremental-refresh-schema.md": { + "sourceHash": "sha256:8d2ce1926f887ac402e3189d5f3264bd3f9ba3e5febbb8495945d2696b676cfd", "status": "translated" }, - "tutorials/workspace-mode.md": { - "sourceHash": "sha256:f90519dd1b92514690fdd3530c190719ceb7a6526ed5911a534f09800ff867ff", + "tutorials/incremental-refresh/incremental-refresh-setup.md": { + "sourceHash": "sha256:d2b72afa3364f56fe87a23b4f5cf68e462a1be587863c735ff6a4f3c4ecd14f9", "status": "translated" }, - "tutorials/data-security/data-security-about.md": { - "sourceHash": "sha256:67f06a996eb51a92f12af6334fb67e35bd0dbe90b7087f1d26a37ddfd4909381", + "tutorials/incremental-refresh/incremental-refresh-workspace-mode.md": { + "sourceHash": "sha256:f758272b3776eae20df95e68bca8be9f51091b177dc95b2192ae2a6b25503042", "status": "translated" }, - "tutorials/data-security/data-security-setup-ols.md": { - "sourceHash": "sha256:ba55db45323585ce76de9ac9702c90a0d6e0836161a3eabcecdb779351f653f7", + "tutorials/index.md": { + "sourceHash": "sha256:08b69c87240fadba66c946ff66b645ca69f32f49a7057d69509af1b6fce4f840", "status": "translated" }, - "tutorials/data-security/data-security-setup-rls.md": { - "sourceHash": "sha256:37aa92c73edc07e2f9d01afd3de6d85045b6730da6ddbbcc452f5d14b43e9460", + "tutorials/new-as-model.md": { + "sourceHash": "sha256:c69cc02c8746abaf67e28bd3d691a8ef39b85da924337f60500e9ef20b87697b", "status": "translated" }, - "tutorials/data-security/data-security-testing.md": { - "sourceHash": "sha256:bc93809b7fafe060954311b3a1d97aecce69e906f20fca244220384fcc1f1147", + "tutorials/new-pbi-model.md": { + "sourceHash": "sha256:c1bdce1cc3623b25d976edf1e6830a16765e4684e8cc48801987242b42083bed", "status": "translated" }, - "tutorials/incremental-refresh/incremental-refresh-about.md": { - "sourceHash": "sha256:80c779772e0e1f5638302bee4ae27ffcec86249050f34ca30ccdb8031537e894", + "tutorials/powerbi-xmla.md": { + "sourceHash": "sha256:f445f13880be03d2923a5b2ecfc85ce22148e6da2ac72661997e8548b5bcc124", "status": "translated" }, - "tutorials/incremental-refresh/incremental-refresh-modify.md": { - "sourceHash": "sha256:71a9811c9adec7c0ea3e188bc4d604eec123f11854c98611ff1e98d4582f6305", + "tutorials/toc.md": { + "sourceHash": "sha256:8a2515bffeac53dd28f78da28c6c321a4c7687437cb4952039b1f230347996b0", "status": "translated" }, - "tutorials/incremental-refresh/incremental-refresh-schema.md": { - "sourceHash": "sha256:aa35afdc0710e4e8ac5373c1a16ee44843848ecf0669e61e49769217a0b04d6d", + "tutorials/udfs.md": { + "sourceHash": "sha256:309fa14887cbea21f7148e07c67382301d071e3257d5e2356a6ac49bcdb113ef", "status": "translated" }, - "tutorials/incremental-refresh/incremental-refresh-setup.md": { - "sourceHash": "sha256:6899e4a1b7393cd795b3fec5bcd60339648769fc91e279f8e04102a97d7c9f3d", + "tutorials/user-defined-aggregations.md": { + "sourceHash": "sha256:27fbed8599f40b55b8277e0e40e5fba309feb664a92be7ad76ff662ac52ab922", "status": "translated" }, - "tutorials/incremental-refresh/incremental-refresh-workspace-mode.md": { - "sourceHash": "sha256:98f31b4429d3a98a7541041c417d062257da648ffd393b7e44bb7c5429660953", + "tutorials/workspace-mode.md": { + "sourceHash": "sha256:e35e6f82ed64cb2d6a73fe987b64d631ebd2416fba3bb1055e56bb91c89d4eed", "status": "translated" }, "whats-new/3-11-0.html": { @@ -1485,29 +1689,45 @@ "whats-new/index.html": { "sourceHash": "sha256:5b9c85b1dffecec8f9912e589adb5fb38de25f01803f8a3618053f6994580251", "status": "translated" - }, - "index.md": { - "sourceHash": "sha256:81a81e409a97ca4d17b0fc503180b5ef6926ce0ba00e62d9b257f58613dc5697", - "status": "translated" - }, - "toc.yml": { - "sourceHash": "sha256:364efc09fddb9e225cd3fa156dce1c88b52b39f816af4ea9d49622a2df522a65", - "status": "translated" - }, - "404.html": { - "sourceHash": "sha256:a630d6e508ff79709689c70f0dba4244fc700783402a0a6c75f4018f61cec3b5", - "status": "translated" - }, - "_ui-strings.json": { - "sourceHash": "sha256:b97210e8c2ad0c87c8f5ed95f8f2cc31c85725c96907e91c5ba950df78d14963", - "status": "translated" } }, "summary": { - "translated": 375, + "translated": 417, "outdated": 0, - "untranslated": 0, - "total": 375, - "completionPercent": 100.0 + "untranslated": 5, + "copied": 0, + "pinned": 0, + "total": 422, + "completionPercent": 98.8, + "pendingJobs": 2, + "failures": 0 + }, + "pendingJobs": { + "features/toc.md": { + "mode": "raw", + "idContent": "zh/features/toc.md", + "sourceHash": "sha256:9de7b2b89d41288caf042ff23187fa805f19ec472f91d34541b555b5f20d6de7", + "reason": "forced", + "contentType": "text/markdown", + "chars": 2109, + "jobId": 7955665, + "targetLanguage": "zh-CN", + "serviceType": "premium", + "environment": "sandbox", + "submittedAt": "2026-09-07T07:26:33Z" + }, + "_ui-strings.json": { + "mode": "json-placeholders", + "idContent": "zh/_ui-strings.json", + "sourceHash": "sha256:01a51cba4185663722f9194cdaf9c8c61d9d65dac841e841120c8661b7485fbf", + "reason": "forced", + "contentType": "application/json", + "chars": 1935, + "jobId": 7955666, + "targetLanguage": "zh-CN", + "serviceType": "premium", + "environment": "sandbox", + "submittedAt": "2026-09-07T07:26:33Z" + } } -} \ No newline at end of file +} diff --git a/localizedContent/zh/content/how-tos/includes/sample-metricview-deserialize.md b/localizedContent/zh/content/how-tos/includes/sample-metricview-deserialize.md deleted file mode 100644 index f2fc83de0..000000000 --- a/localizedContent/zh/content/how-tos/includes/sample-metricview-deserialize.md +++ /dev/null @@ -1,43 +0,0 @@ -## 对这些代码示例的 Metric View 进行反序列化 - -本操作指南使用一个示例电商 Metric View 来表示销售数据,其中三个维度表(product、customer、date)连接到一个事实表(orders)。 -如果你想在阅读本操作指南其余部分时跟着代码一起操作,请先运行下面的代码片段 - -```csharp -SemanticBridge.MetricView.Deserialize(""" - version: 0.1 - source: sales.fact.orders - joins: - - name: product - source: sales.dim.product - on: source.product_id = product.product_id - - name: customer - source: sales.dim.customer - on: source.customer_id = customer.customer_id - - name: date - source: sales.dim.date - on: source.order_date = date.date_key - dimensions: - - name: product_name - expr: product.product_name - - name: product_category - expr: product.category - - name: customer_segment - expr: customer.segment - - name: order_date - expr: date.full_date - - name: order_year - expr: date.year - - name: order_month - expr: date.month_name - measures: - - name: total_revenue - expr: SUM(revenue) - - name: order_count - expr: COUNT(order_id) - - name: avg_order_value - expr: AVG(revenue) - - name: unique_customers - expr: COUNT(DISTINCT customer_id) - """); -``` diff --git a/localizedContent/zh/content/security/te3-eula.md b/localizedContent/zh/content/security/te3-eula.md deleted file mode 100644 index 6c623d1af..000000000 --- a/localizedContent/zh/content/security/te3-eula.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -uid: te3-eula -title: 标准许可条款 -author: Søren Toft Joensen -updated: 2021-07-10 -applies_to: - products: - - product: Tabular Editor 2 - none: true - - product: Tabular Editor 3 - editions: - - edition: Desktop - full: true - - edition: Business - full: true - - edition: Enterprise - full: true ---- - -# Tabular Editor 3 标准许可条款 - -我们的许可条款最新版本始终可在 https://tabulareditor.com/license-terms 查阅 diff --git a/metadata/build-config.json b/metadata/build-config.json index 3cd1e63e4..f58e10d67 100644 --- a/metadata/build-config.json +++ b/metadata/build-config.json @@ -13,6 +13,32 @@ { "match": "/api/", "priority": 0.1 } ] }, + "translation": { + "_comment": "Automated translation via Translated (TranslationOS), see build_scripts/translate-content.py and the 'Translating Content' section of README.md. 'sources' are globs relative to content/ (the previous provider's scope, plus the sidebar toc.md files); files under sharedDirectories are always skipped; 'passthrough' files are copied from English as-is (not translated) and tracked the same way. The active environment is selected with the TRANSLATED_ENV environment variable / repository variable (sandbox | production); TRANSLATED_SERVICE_TYPE overrides the environment's serviceType. On this account the sandbox's machine-translation service type is 'economy' (verified 2026-09-07: it delivers within minutes; the spec's suggested 'premium' parks sandbox requests at 'analyzing' forever). Production service types are defined per account by Translated (see --probe), so production has no default and refuses to run until one is set. 'instructions' are file-level notes shown to linguists on human service types; glossaries and do-not-translate lists are configured by Translated on the service type itself.", + "environments": { + "sandbox": { "baseUrl": "https://api.sandbox.translated.com/v2/", "serviceType": "economy" }, + "production": { "baseUrl": "https://api.translated.com/v2/", "serviceType": null } + }, + "sourceLocale": "en-US", + "sources": [ + { "pattern": "**/*.md" }, + { "pattern": "404.html" }, + { "pattern": "getting-started/app/**/*.html" }, + { "pattern": "toc.yml" }, + { "pattern": "_ui-strings.json" } + ], + "passthrough": [ + { "pattern": "whats-new/**/*.html" } + ], + "ignore": [], + "batchSize": 200, + "sandboxLimits": { + "_comment": "Translated warns that excessive use of the sandbox human-translation workflow can get the sandbox key blocked. In sandbox mode a run refuses to exceed these unless --allow-unbounded is given.", + "maxFilesPerRun": 20, + "maxCharsPerRun": 200000 + }, + "instructions": "Technical documentation for Tabular Editor 3 (a Power BI / Analysis Services modelling tool). Keep all Markdown, YAML and HTML structure exactly as in the source: do not translate or alter code, code fences, inline code, file paths, URLs, link targets, anchors, xref/uid identifiers, HTML tags and attributes, keyboard shortcuts, alert markers such as [!NOTE] or [!include], or placeholders like {count}. Do not translate product and technology names: Tabular Editor, Power BI, Analysis Services, DAX, Tabular Object Model (TOM), Fabric, Best Practice Analyzer, C#, Visual Studio. Use the terminology of the localized Power BI user interface where it exists." + }, "contentDirectories": { "_comment": "Directories that contain translatable content (markdown and HTML files)", "directories": [ diff --git a/metadata/language-metadata.json b/metadata/language-metadata.json index 51821cb0b..9ed65b1ae 100644 --- a/metadata/language-metadata.json +++ b/metadata/language-metadata.json @@ -3,8 +3,8 @@ "defaultLanguage": "en", "languages": { "en": { "name": "English", "nativeName": "English" }, - "es": { "name": "Spanish", "nativeName": "Español" }, - "zh": { "name": "Chinese (Simplified)", "nativeName": "简体中文" }, + "es": { "name": "Spanish", "nativeName": "Español", "translatedLocale": "es-ES" }, + "zh": { "name": "Chinese (Simplified)", "nativeName": "简体中文", "translatedLocale": "zh-CN" }, "zh-tw": { "name": "Chinese (Traditional)", "nativeName": "繁體中文" }, "ja": { "name": "Japanese", "nativeName": "日本語" }, "ko": { "name": "Korean", "nativeName": "한국어" }, diff --git a/pyproject.toml b/pyproject.toml index 941fc2b60..3b6258e53 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,14 +10,18 @@ target-version = "py311" lint.select = ["F", "B", "SIM", "I", "UP"] include = [ "build_scripts/check_links.py", + "build_scripts/config_loader.py", "build_scripts/csharp_doctest.py", "build_scripts/te_script_runner.py", + "build_scripts/translate-content.py", ] [tool.mypy] files = [ "build_scripts/check_links.py", + "build_scripts/config_loader.py", "build_scripts/csharp_doctest.py", "build_scripts/te_script_runner.py", + "build_scripts/translate-content.py", ] strict = true