[
  {
    "path": ".dockerignore",
    "content": ".git\n.github\n.claude\n.venv\ndist\nbuild\n*.egg-info\n__pycache__\n.pytest_cache\n.ruff_cache\n.mypy_cache\nnode_modules\ntests\nscratch\nAIRAscore\n.vscode\n.idea\n*.log\n.DS_Store\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "# These are supported funding model platforms\n\ngithub: Spenhouet\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/1_bug_report.yaml",
    "content": "name: Bug report\ndescription: Report an error or unexpected behavior\nlabels: [\"bug\"]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Thank you for taking the time to report an issue! We're glad to have you involved with confluence-markdown-exporter.\n\n        **Before reporting, please make sure to search through [existing issues](https://github.com/Spenhouet/confluence-markdown-exporter/issues?q=is:issue+is:open+label:bug) (including [closed](https://github.com/Spenhouet/confluence-markdown-exporter/issues?q=is:issue%20state:closed%20label:bug)).**\n\n  - type: markdown\n    attributes:\n      value: |\n        ### Diagnostic info\n        Run `cme bugreport` and paste the full output in the **Diagnostic info** field below.\n        This command prints your version, system details, and configuration — with all secrets automatically redacted.\n\n  - type: textarea\n    attributes:\n      label: Description\n      description: |\n        A clear and concise description of the bug, including a minimal reproducible example.\n\n        Be sure to include the command you invoked (e.g., `cme pages https://company.atlassian.net/wiki/spaces/KEY/pages/123/Title`).\n    validations:\n      required: true\n\n  - type: textarea\n    attributes:\n      label: Diagnostic info\n      description: |\n        Paste the output of `cme bugreport` here.\n        This includes your version, Python/OS info, and configuration with secrets redacted.\n      placeholder: |\n        ## Bug Report Diagnostic Info\n\n        ### Version\n        confluence-markdown-exporter x.y.z\n\n        ### System\n        Python: ...\n        Platform: ...\n        Architecture: ...\n\n        ### Config\n        Config file: ...\n        ```yaml\n        ...\n        ```\n      render: markdown\n    validations:\n      required: false\n\n  - type: input\n    attributes:\n      label: Version\n      description: |\n        What version of confluence-markdown-exporter are you using?\n        (Already included in `cme bugreport` output — fill in here only if you didn't run that command.)\n      placeholder: e.g., confluence-markdown-exporter 4.0.3\n    validations:\n      required: false\n\n  - type: input\n    attributes:\n      label: Confluence Version\n      description: |\n        What Confluence version are you using? Include whether it's Cloud or Server/Data Center.\n        Example: `Confluence Cloud` or `Confluence Server 7.19.2`\n      placeholder: e.g., Confluence Cloud or Confluence Server 7.19.2\n    validations:\n      required: false\n\n  - type: input\n    attributes:\n      label: Jira Version\n      description: |\n        What Jira version are you using (or not)? Include whether it's Cloud or Server/Data Center.\n        Example: `Jira Cloud` or `Jira Server 8.20.5`\n      placeholder: e.g., Jira Cloud or Jira Server 8.20.5\n    validations:\n      required: false\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/2_feature_request.yaml",
    "content": "name: Feature request\ndescription: Suggest a new feature or enhancement\nlabels: [\"enhancement\"]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Thank you for taking the time to suggest a feature! We're glad to have you involved with confluence-markdown-exporter.\n\n        **Before submitting, please make sure to search through [existing feature requests](https://github.com/Spenhouet/confluence-markdown-exporter/issues?q=is:issue+is:open+label:enhancement) (including [closed](https://github.com/Spenhouet/confluence-markdown-exporter/issues?q=is:issue%20state:closed%20label:enhancement)).**\n\n  - type: textarea\n    attributes:\n      label: Problem Description\n      description: |\n        A clear and concise description of the problem or limitation you're experiencing.\n\n        What is the use case? What workflow or task would this feature enable or improve?\n    validations:\n      required: true\n\n  - type: textarea\n    attributes:\n      label: Proposed Solution\n      description: |\n        A clear and concise description of what you want to happen.\n\n        How do you envision this feature working? What would the ideal implementation look like?\n\n        If you have ideas about commands, options, or configuration, please include examples:\n        ```bash\n        # Example command or usage\n        confluence-markdown-exporter <your-suggested-command>\n        ```\n    validations:\n      required: true\n\n  - type: textarea\n    attributes:\n      label: Alternatives Considered\n      description: |\n        A clear and concise description of any alternative solutions or features you've considered.\n\n        Are there workarounds you're currently using? What other tools or approaches have you tried?\n    validations:\n      required: false\n\n  - type: textarea\n    attributes:\n      label: Use Cases\n      description: |\n        Describe specific scenarios where this feature would be helpful.\n\n        Please provide concrete examples of how you (or others) would use this feature in practice.\n    validations:\n      required: false\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/3_question.yaml",
    "content": "name: Question\ndescription: Ask a question about confluence-markdown-exporter\nlabels: [\"question\"]\nbody:\n  - type: textarea\n    attributes:\n      label: Question\n      description: Describe your question in detail.\n    validations:\n      required: true\n\n  - type: input\n    attributes:\n      label: Version\n      description: What version of confluence-markdown-exporter are you using? (see `confluence-markdown-exporter version`)\n      placeholder: e.g., confluence-markdown-exporter 3.0.3\n    validations:\n      required: false\n\n  - type: input\n    attributes:\n      label: Confluence Version\n      description: |\n        What Confluence version are you using? Include whether it's Cloud or Server/Data Center.\n        Example: `Confluence Cloud` or `Confluence Server 7.19.2`\n      placeholder: e.g., Confluence Cloud or Confluence Server 7.19.2\n    validations:\n      required: false\n\n  - type: input\n    attributes:\n      label: Jira Version\n      description: |\n        What Jira version are you using (or not)? Include whether it's Cloud or Server/Data Center.\n        Example: `Jira Cloud` or `Jira Server 8.20.5`\n      placeholder: e.g., Jira Cloud or Jira Server 8.20.5\n    validations:\n      required: false\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "blank_issues_enabled: false\ncontact_links:\n  - name: Documentation\n    url: https://github.com/Spenhouet/confluence-markdown-exporter#readme\n    about: Read the project documentation and README\n"
  },
  {
    "path": ".github/PULL_REQUEST_TEMPLATE.md",
    "content": "<!--\nThank you for contributing to confluence-markdown-exporter! To help us out with reviewing, please consider the following:\n\n- Does this pull request include a summary of the change? (See below.)\n- Does this pull request include a descriptive title?\n- Does this pull request include references to any relevant issues?\n-->\n\n## Summary\n\n<!-- What's the purpose of the change? What does it do, and why? -->\n\n## Test Plan\n\n<!-- How was it tested? -->\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "version: 2\nupdates:\n  - package-ecosystem: \"github-actions\"\n    directory: \"/\"\n    schedule:\n      interval: \"daily\"\n    groups:\n      actions:\n        patterns:\n          - \"*\"\n"
  },
  {
    "path": ".github/workflows/docker-build.yml",
    "content": "name: Build Docker image\n\non:\n  pull_request:\n    branches: [main]\n    paths:\n      - Dockerfile\n      - .dockerignore\n      - .github/workflows/docker-build.yml\n      - pyproject.toml\n      - uv.lock\n      - confluence_markdown_exporter/**\n  # Also build on push to main so the GHA cache is primed on the default\n  # branch. Tag-triggered publish runs fall back to the default branch's\n  # cache, which would otherwise stay cold until the first release.\n  push:\n    branches: [main]\n    paths:\n      - Dockerfile\n      - .dockerignore\n      - .github/workflows/docker-build.yml\n      - pyproject.toml\n      - uv.lock\n      - confluence_markdown_exporter/**\n\npermissions:\n  contents: read\n\njobs:\n  build:\n    name: Build image (PR verification)\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v6\n\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v4\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v4\n\n      - name: Build (no push)\n        uses: docker/build-push-action@v7\n        with:\n          context: .\n          file: ./Dockerfile\n          platforms: linux/amd64,linux/arm64\n          push: false\n          cache-from: type=gha\n          cache-to: type=gha,mode=max,ignore-error=true\n"
  },
  {
    "path": ".github/workflows/docker-publish.yml",
    "content": "name: Publish Docker image\n\non:\n  workflow_call:\n    inputs:\n      version:\n        description: \"Release version to publish (e.g. 5.1.0)\"\n        required: true\n        type: string\n  workflow_dispatch:\n    inputs:\n      version:\n        description: \"Release version to publish (e.g. 5.1.0). Must match an existing git tag.\"\n        required: true\n        type: string\n\npermissions:\n  contents: read\n\njobs:\n  publish:\n    name: Publish image to Docker Hub\n    runs-on: ubuntu-latest\n    environment:\n      name: dockerhub\n      url: https://hub.docker.com/r/${{ vars.DOCKERHUB_IMAGE || 'spenhouet/confluence-markdown-exporter' }}\n    env:\n      IMAGE_NAME: ${{ vars.DOCKERHUB_IMAGE || 'spenhouet/confluence-markdown-exporter' }}\n    steps:\n      - name: Checkout release tag\n        uses: actions/checkout@v6\n        with:\n          ref: ${{ inputs.version }}\n\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v4\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v4\n\n      - name: Log in to Docker Hub\n        uses: docker/login-action@v4\n        with:\n          username: ${{ secrets.DOCKERHUB_USERNAME }}\n          password: ${{ secrets.DOCKERHUB_TOKEN }}\n\n      - name: Extract metadata\n        id: meta\n        uses: docker/metadata-action@v6\n        with:\n          images: ${{ env.IMAGE_NAME }}\n          tags: |\n            type=semver,pattern={{version}},value=${{ inputs.version }}\n            type=semver,pattern={{major}}.{{minor}},value=${{ inputs.version }}\n            type=semver,pattern={{major}},value=${{ inputs.version }}\n            type=raw,value=latest\n          labels: |\n            org.opencontainers.image.title=confluence-markdown-exporter\n            org.opencontainers.image.description=Export Confluence pages to Markdown\n            org.opencontainers.image.url=https://github.com/${{ github.repository }}\n            org.opencontainers.image.source=https://github.com/${{ github.repository }}\n            org.opencontainers.image.version=${{ inputs.version }}\n            org.opencontainers.image.licenses=MIT\n\n      - name: Build and push\n        uses: docker/build-push-action@v7\n        with:\n          context: .\n          file: ./Dockerfile\n          platforms: linux/amd64,linux/arm64\n          push: true\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n          cache-from: type=gha\n          cache-to: type=gha,mode=max,ignore-error=true\n          provenance: true\n\n      - name: Update Docker Hub description\n        uses: peter-evans/dockerhub-description@v5\n        continue-on-error: true\n        with:\n          username: ${{ secrets.DOCKERHUB_USERNAME }}\n          password: ${{ secrets.DOCKERHUB_TOKEN }}\n          repository: ${{ env.IMAGE_NAME }}\n          short-description: Export Confluence pages to Markdown (CLI)\n          readme-filepath: ./README.md\n"
  },
  {
    "path": ".github/workflows/docs.yml",
    "content": "name: Deploy docs\n\non:\n  push:\n    branches: [main]\n    paths:\n      - \"docs/**\"\n      - \"versioned_docs/**\"\n      - \"versioned_sidebars/**\"\n      - \"versions.json\"\n      - \"src/**\"\n      - \"static/**\"\n      - \"docusaurus.config.ts\"\n      - \"sidebars.ts\"\n      - \"tsconfig.json\"\n      - \"package.json\"\n      - \"package-lock.json\"\n      - \".github/workflows/docs.yml\"\n  workflow_dispatch:\n\npermissions:\n  contents: read\n  pages: write\n  id-token: write\n\nconcurrency:\n  group: pages\n  cancel-in-progress: false\n\njobs:\n  build:\n    name: Build docs\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v6\n        with:\n          fetch-depth: 0\n\n      - name: Setup Node\n        uses: actions/setup-node@v6\n        with:\n          node-version: 20\n          cache: npm\n\n      - name: Install dependencies\n        run: npm ci\n\n      - name: Build site (with versioned docs from git tags)\n        run: npm run build:versioned\n\n      - name: Upload artifact\n        uses: actions/upload-pages-artifact@v5\n        with:\n          path: build\n\n  deploy:\n    name: Deploy to GitHub Pages\n    needs: build\n    runs-on: ubuntu-latest\n    environment:\n      name: github-pages\n      url: ${{ steps.deployment.outputs.page_url }}\n    steps:\n      - name: Deploy to GitHub Pages\n        id: deployment\n        uses: actions/deploy-pages@v5\n"
  },
  {
    "path": ".github/workflows/python-build.yml",
    "content": "name: Build Python package\n\non:\n  push:\n    branches: [main]\n  pull_request:\n    branches: [main]\n\njobs:\n  test:\n    name: Test, lint and build\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v6\n\n      - name: Install uv\n        uses: astral-sh/setup-uv@v7\n        with:\n          enable-cache: true\n\n      - name: Install dependencies\n        run: uv sync --locked --all-groups\n\n      - name: Run linting with ruff\n        run: uv run ruff check\n\n      - name: Run tests with pytest\n        run: uv run pytest\n\n      - name: Test build (with sources for development)\n        run: uv build\n\n      - name: Test build (without sources for publication)\n        run: |\n          rm -rf dist/\n          uv build --no-sources\n\n      - name: Test package installation and import\n        run: |\n          uv run --with dist/*.whl --no-project -- python -c \"import confluence_markdown_exporter; print('Package imports successfully')\"\n\n      - name: Test CLI commands\n        run: |\n          uv run --with dist/*.whl --no-project confluence-markdown-exporter --help\n          uv run --with dist/*.whl --no-project cme --help\n\n      - name: Upload build artifacts for inspection\n        uses: actions/upload-artifact@v7\n        with:\n          name: build-artifacts\n          path: dist/\n          retention-days: 5\n"
  },
  {
    "path": ".github/workflows/python-publish.yml",
    "content": "name: Publish Python package\n\non:\n  workflow_call:\n    inputs:\n      version:\n        description: \"Release version to publish (e.g. 5.1.0)\"\n        required: true\n        type: string\n  workflow_dispatch:\n    inputs:\n      version:\n        description: \"Release version to publish (e.g. 5.1.0). Must match an existing git tag.\"\n        required: true\n        type: string\n\npermissions:\n  contents: write\n  id-token: write\n  attestations: write\n\njobs:\n  publish:\n    name: Publish to PyPI\n    runs-on: ubuntu-latest\n    environment:\n      name: release\n      url: https://pypi.org/p/confluence-markdown-exporter\n    steps:\n      - name: Checkout release tag\n        uses: actions/checkout@v6\n        with:\n          ref: ${{ inputs.version }}\n\n      - name: Install uv\n        uses: astral-sh/setup-uv@v7\n        with:\n          enable-cache: true\n\n      - name: Install dependencies\n        run: uv sync --locked --all-groups\n\n      - name: Build distributions\n        run: uv build --no-sources\n\n      - name: Generate artifact attestations\n        uses: actions/attest-build-provenance@v4.1.0\n        with:\n          subject-path: \"dist/*\"\n\n      - name: Publish to PyPI\n        run: uv publish\n\n      - name: Sign the distributions with Sigstore\n        uses: sigstore/gh-action-sigstore-python@v3.3.0\n        with:\n          inputs: >-\n            ./dist/*.tar.gz\n            ./dist/*.whl\n\n      - name: Upload signed artifacts to GitHub Release\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n        run: |\n          gh release upload \"${{ inputs.version }}\" dist/** \\\n            --repo \"$GITHUB_REPOSITORY\"\n"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "name: Release\n\non:\n  workflow_dispatch:\n    inputs:\n      version_bump:\n        description: \"Version bump type\"\n        required: true\n        default: \"patch\"\n        type: choice\n        options:\n          - patch\n          - minor\n          - major\n          - alpha\n          - beta\n          - rc\n      custom_version:\n        description: \"Custom version (leave empty to use bump type)\"\n        required: false\n        type: string\n\npermissions:\n  contents: write\n  id-token: write\n  attestations: write\n\njobs:\n  release:\n    name: Bump version and create release\n    runs-on: ubuntu-latest\n    permissions:\n      contents: write\n    outputs:\n      version: ${{ steps.export-version.outputs.value }}\n    steps:\n      - uses: actions/checkout@v6\n        with:\n          token: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Install uv\n        uses: astral-sh/setup-uv@v7\n        with:\n          enable-cache: true\n\n      - name: Install dependencies\n        run: uv sync --locked --all-groups\n\n      - name: Update version (custom)\n        if: ${{ github.event.inputs.custom_version != '' }}\n        run: |\n          uv version ${{ github.event.inputs.custom_version }}\n          echo \"NEW_VERSION=${{ github.event.inputs.custom_version }}\" >> $GITHUB_ENV\n\n      - name: Update version (bump)\n        if: ${{ github.event.inputs.custom_version == '' }}\n        run: |\n          NEW_VERSION=$(uv version --bump ${{ github.event.inputs.version_bump }} | awk '{print $NF}')\n          echo \"NEW_VERSION=${NEW_VERSION}\" >> $GITHUB_ENV\n\n      - name: Export version as job output\n        id: export-version\n        run: echo \"value=${NEW_VERSION}\" >> \"$GITHUB_OUTPUT\"\n\n      - name: Test build with new version\n        run: |\n          uv build --no-sources\n          uv run --with dist/*.whl --no-project -- python -c \"import confluence_markdown_exporter; print('Package imports successfully')\"\n\n      - name: Update version references in README and docs\n        run: scripts/bump-docs-version.sh \"${{ env.NEW_VERSION }}\"\n\n      - name: Commit version update\n        run: |\n          git config --local user.email \"action@github.com\"\n          git config --local user.name \"GitHub Action\"\n          # -u: stage modifications to tracked files only; never add untracked files.\n          git add -u pyproject.toml uv.lock README.md docs src\n          git diff --cached --quiet || git commit -m \"Bump version to ${{ env.NEW_VERSION }}\"\n          git push\n\n      - name: Create release tag\n        run: |\n          git tag \"${{ env.NEW_VERSION }}\"\n          git push origin \"${{ env.NEW_VERSION }}\"\n\n      - name: Create and publish GitHub Release\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n        run: |\n          gh release create \"${{ env.NEW_VERSION }}\" \\\n            --title \"Release ${{ env.NEW_VERSION }}\" \\\n            --generate-notes\n\n  publish-python:\n    name: Publish Python package\n    needs: release\n    uses: ./.github/workflows/python-publish.yml\n    with:\n      version: ${{ needs.release.outputs.version }}\n    secrets: inherit\n\n  publish-docker:\n    name: Publish Docker image\n    needs: release\n    uses: ./.github/workflows/docker-publish.yml\n    with:\n      version: ${{ needs.release.outputs.version }}\n    secrets: inherit\n"
  },
  {
    "path": ".gitignore",
    "content": "### Custom ###\n\n**/*.env\nscratch/\nlog/\n.ssh/\n\n_tmp/*\n*.tar.gz\n*.sh~\n\n*.zip\n*.jpg\n\n### LLM Agents ###\n# The source stays vendor agnostic\n.claude/\n\n### Virtual Environments ###\n.venv/\n.venv-*/\n\n# Created by https://www.gitignore.io/api/code,python\n# Edit at https://www.gitignore.io/?templates=code,python\n\n### Code ###\n.vscode/*\n!.vscode/settings.json\n!.vscode/tasks.json\n!.vscode/launch.json\n!.vscode/extensions.json\n\n### Docusaurus ###\nnode_modules/\n.docusaurus/\n.docusaurus-faster/\ndocs-build/\n# Versioned docs are generated at build time from git tags by scripts/build-versions.mjs\nversioned_docs/\nversioned_sidebars/\nversions.json\n\n### Python ###\n# Byte-compiled / optimized / DLL files\n__pycache__/\n*.py[cod]\n*$py.class\n\n# C extensions\n*.so\n\n# Distribution / packaging\n.Python\nbuild/\ndevelop-eggs/\ndist/\ndownloads/\neggs/\n.eggs/\nlib/\nlib64/\nparts/\nsdist/\nvar/\nwheels/\npip-wheel-metadata/\nshare/python-wheels/\n*.egg-info/\n.installed.cfg\n*.egg\nMANIFEST\n\n# PyInstaller\n#  Usually these files are written by a python script from a template\n#  before PyInstaller builds the exe, so as to inject date/other infos into it.\n*.manifest\n*.spec\n\n# Installer logs\npip-log.txt\npip-delete-this-directory.txt\n\n# Unit test / coverage reports\nhtmlcov/\n.tox/\n.nox/\n.coverage\n.coverage.*\n.cache\nnosetests.xml\ncoverage.xml\n*.cover\n.hypothesis/\n.pytest_cache/\n\n# Translations\n*.mo\n*.pot\n\n# Scrapy stuff:\n.scrapy\n\n# Sphinx documentation\ndocs/_build/\n\n# PyBuilder\ntarget/\n\n# pipenv\n#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.\n#   However, in case of collaboration, if having platform-specific dependencies or dependencies\n#   having no cross-platform support, pipenv may install dependencies that don't work, or not\n#   install all needed dependencies.\n#Pipfile.lock\n\n# celery beat schedule file\ncelerybeat-schedule\n\n# SageMath parsed files\n*.sage.py\n\n# Spyder project settings\n.spyderproject\n.spyproject\n\n# Rope project settings\n.ropeproject\n\n# Mr Developer\n.mr.developer.cfg\n.project\n.pydevproject\n\n# mkdocs documentation\n/site\n\n# mypy\n.mypy_cache/\n.dmypy.json\ndmypy.json\n\n# Pyre type checker\n.pyre/\n\n# End of https://www.gitignore.io/api/code,python\n\n\n# Beads / Dolt files (added by bd init)\n.dolt/\n*.db\n.beads-credential-key\n"
  },
  {
    "path": ".python-version",
    "content": "3.10.12\n"
  },
  {
    "path": ".vscode/extensions.json",
    "content": "{\n  // See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations.\n  // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp\n  // List of extensions which should be recommended for users of this workspace.\n  \"recommendations\": [\n    \"astral-sh.ty\",\n    \"charliermarsh.ruff\",\n    \"github.vscode-github-actions\",\n    \"ms-python.python\",\n    \"njpwerner.autodocstring\",\n  ],\n  // List of extensions recommended by VS Code that should not be recommended for users of this workspace.\n  \"unwantedRecommendations\": []\n}"
  },
  {
    "path": ".vscode/launch.json",
    "content": "{\r\n  // Use IntelliSense to learn about possible attributes.\r\n  // Hover to view descriptions of existing attributes.\r\n  // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387\r\n  \"version\": \"0.2.0\",\r\n  \"configurations\": [\r\n    {\r\n      \"name\": \"Python: Current File\",\r\n      \"type\": \"debugpy\",\r\n      \"request\": \"launch\",\r\n      \"program\": \"${file}\",\r\n      \"justMyCode\": false,\r\n      \"console\": \"integratedTerminal\",\r\n      \"cwd\": \"${workspaceFolder}\",\r\n      \"env\": {\r\n        \"PYTHONPATH\": \"${workspaceRoot}\"\r\n      }\r\n    },\r\n    {\r\n      \"name\": \"Python: Export Page(s)\",\r\n      \"type\": \"debugpy\",\r\n      \"request\": \"launch\",\r\n      \"program\": \"${workspaceFolder}/confluence_markdown_exporter/main.py\",\r\n      \"justMyCode\": false,\r\n      \"args\": [\r\n        \"pages\",\r\n        \"<page-url>\"\r\n      ],\r\n      \"console\": \"integratedTerminal\",\r\n      \"cwd\": \"${workspaceFolder}\",\r\n      \"env\": {\r\n        \"PYTHONPATH\": \"${workspaceRoot}\",\r\n        \"CME_CONFIG_PATH\": \"scratch/cme_config.json\",\r\n        \"CME_EXPORT__LOG_LEVEL\": \"DEBUG\",\r\n        \"CME_EXPORT__OUTPUT_PATH\": \"scratch\"\r\n      }\r\n    },\r\n    {\r\n      \"name\": \"Python: Export Page(s) with Descendants\",\r\n      \"type\": \"debugpy\",\r\n      \"request\": \"launch\",\r\n      \"program\": \"${workspaceFolder}/confluence_markdown_exporter/main.py\",\r\n      \"justMyCode\": false,\r\n      \"args\": [\r\n        \"pages-with-descendants\",\r\n        \"<page-url>\"\r\n      ],\r\n      \"console\": \"integratedTerminal\",\r\n      \"cwd\": \"${workspaceFolder}\",\r\n      \"env\": {\r\n        \"PYTHONPATH\": \"${workspaceRoot}\",\r\n        \"CME_CONFIG_PATH\": \"scratch/cme_config.json\",\r\n        \"CME_EXPORT__LOG_LEVEL\": \"DEBUG\",\r\n        \"CME_EXPORT__OUTPUT_PATH\": \"scratch\"\r\n      }\r\n    },\r\n    {\r\n      \"name\": \"Python: Export Space(s)\",\r\n      \"type\": \"debugpy\",\r\n      \"request\": \"launch\",\r\n      \"program\": \"${workspaceFolder}/confluence_markdown_exporter/main.py\",\r\n      \"justMyCode\": false,\r\n      \"args\": [\r\n        \"spaces\",\r\n        \"<space-url>\"\r\n      ],\r\n      \"console\": \"integratedTerminal\",\r\n      \"cwd\": \"${workspaceFolder}\",\r\n      \"env\": {\r\n        \"PYTHONPATH\": \"${workspaceRoot}\",\r\n        \"CME_CONFIG_PATH\": \"scratch/cme_config.json\",\r\n        \"CME_EXPORT__LOG_LEVEL\": \"DEBUG\",\r\n        \"CME_EXPORT__OUTPUT_PATH\": \"scratch\"\r\n      }\r\n    },\r\n    {\r\n      \"name\": \"Python: Export Org(s)\",\r\n      \"type\": \"debugpy\",\r\n      \"request\": \"launch\",\r\n      \"program\": \"${workspaceFolder}/confluence_markdown_exporter/main.py\",\r\n      \"justMyCode\": false,\r\n      \"args\": [\r\n        \"orgs\",\r\n        \"<base-url>\"\r\n      ],\r\n      \"console\": \"integratedTerminal\",\r\n      \"cwd\": \"${workspaceFolder}\",\r\n      \"env\": {\r\n        \"PYTHONPATH\": \"${workspaceRoot}\",\r\n        \"CME_CONFIG_PATH\": \"scratch/cme_config.json\",\r\n        \"CME_EXPORT__LOG_LEVEL\": \"DEBUG\",\r\n        \"CME_EXPORT__OUTPUT_PATH\": \"scratch\"\r\n      }\r\n    },\r\n    {\r\n      \"name\": \"Python: Config (Interactive)\",\r\n      \"type\": \"debugpy\",\r\n      \"request\": \"launch\",\r\n      \"program\": \"${workspaceFolder}/confluence_markdown_exporter/main.py\",\r\n      \"justMyCode\": false,\r\n      \"args\": [\r\n        \"config\"\r\n      ],\r\n      \"console\": \"integratedTerminal\",\r\n      \"cwd\": \"${workspaceFolder}\",\r\n      \"env\": {\r\n        \"PYTHONPATH\": \"${workspaceRoot}\",\r\n        \"CME_CONFIG_PATH\": \"scratch/cme_config.json\",\r\n        \"CME_EXPORT__LOG_LEVEL\": \"DEBUG\"\r\n      }\r\n    }\r\n  ]\r\n}"
  },
  {
    "path": ".vscode/settings.json",
    "content": "{\r\n  \"files.eol\": \"\\n\",\r\n  \"editor.formatOnSave\": true,\r\n  \"autoDocstring.docstringFormat\": \"google\",\r\n  \"autoDocstring.startOnNewLine\": true,\r\n  \"python.testing.unittestEnabled\": false,\r\n  \"python.testing.pytestEnabled\": true,\r\n  \"python.defaultInterpreterPath\": \"${workspaceFolder}/.venv/bin/python\",\r\n  \"jupyter.notebookFileRoot\": \"${workspaceFolder}\",\r\n  \"task.autoDetect\": \"off\",\r\n  \"[python]\": {\r\n    \"editor.defaultFormatter\": \"charliermarsh.ruff\",\r\n    \"editor.codeActionsOnSave\": {\r\n      \"source.fixAll\": \"explicit\",\r\n      \"source.organizeImports\": \"explicit\"\r\n    }\r\n  },\r\n  \"[json]\": {\r\n    \"editor.defaultFormatter\": \"vscode.json-language-features\"\r\n  },\r\n  \"jupyter.debugJustMyCode\": false,\r\n  \"debugpy.debugJustMyCode\": false,\r\n  \"[markdown]\": {\r\n    \"diffEditor.ignoreTrimWhitespace\": false,\r\n    \"editor.unicodeHighlight.ambiguousCharacters\": false,\r\n    \"editor.unicodeHighlight.invisibleCharacters\": false,\r\n    \"editor.wordWrap\": \"on\",\r\n    \"editor.quickSuggestions\": {\r\n      \"comments\": \"off\",\r\n      \"strings\": \"off\",\r\n      \"other\": \"on\"\r\n    },\r\n    \"editor.fontLigatures\": true,\r\n    \"editor.glyphMargin\": false,\r\n    \"editor.minimap.enabled\": false,\r\n    \"editor.wrappingIndent\": \"indent\",\r\n    \"editor.overviewRulerBorder\": false,\r\n    \"editor.lineHeight\": 24,\r\n    \"editor.renderWhitespace\": \"none\",\r\n    \"editor.suggest.showSnippets\": false,\r\n    \"editor.tabSize\": 2,\r\n    \"editor.wordBasedSuggestions\": \"off\",\r\n    \"files.autoSave\": \"onFocusChange\",\r\n    \"files.insertFinalNewline\": true,\r\n  },\r\n  \"markdown.updateLinksOnFileMove.enabled\": \"prompt\",\r\n  \"markdown.validate.enabled\": true,\r\n}"
  },
  {
    "path": ".vscode/tasks.json",
    "content": "{\n  \"version\": \"2.0.0\",\n  \"tasks\": []\n}"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing\n\nAny contribution is welcome! This document provides guidelines for contributing to the confluence-markdown-exporter project.\n\n## Table of Contents\n\n- [Getting Started](#getting-started)\n- [Development Workflow](#development-workflow)\n- [Testing](#testing)\n- [Code Quality](#code-quality)\n- [Release Process](#release-process)\n- [Pull Request Guidelines](#pull-request-guidelines)\n\n## Getting Started\n\n### Prerequisites\n\n- Python 3.10 or higher\n- Git\n- `uv` (Python package manager)\n- `jq` (for JSON processing)\n\n### Install jq\n\n```bash\nsudo apt-get install jq\n```\n\n### Install `uv`\n\nFollowing the [uv installation guide](https://docs.astral.sh/uv/getting-started/installation):\n\n```bash\ncurl -LsSf https://astral.sh/uv/install.sh | sh\n```\n\nAdd shell completion (optional):\n\n```bash\necho 'eval \"$(uv generate-shell-completion bash)\"' >> ~/.bashrc\n```\n\n### Project Setup\n\n1. **Fork and Clone the Repository**\n\n   ```bash\n   git clone https://github.com/Spenhouet/confluence-markdown-exporter.git\n   cd confluence-markdown-exporter\n   ```\n\n2. **Install Dependencies**\n\n   ```bash\n   uv sync --all-groups\n   ```\n\n   This will:\n\n   - Create a virtual environment\n   - Install all dependencies (including development dependencies via dependency groups)\n   - Install the project in editable mode\n\n3. **Verify Installation**\n\n   ```bash\n   uv run confluence-markdown-exporter --help\n   uv run cme --help\n   ```\n\n## Development Workflow\n\n### Running the Application\n\n```bash\n# Run with uv (recommended)\nuv run confluence-markdown-exporter [commands]\nuv run cme [commands]\n\n# Or activate the virtual environment\nsource .venv/bin/activate\nconfluence-markdown-exporter [commands]\n```\n\n### Adding Dependencies\n\n```bash\n# Add runtime dependency\nuv add package-name\n\n# Add development dependency (to dev group)\nuv add --group dev package-name\n\n# Add to custom dependency group\nuv add --group group-name package-name\n```\n\n### Updating Dependencies\n\n```bash\n# Update all dependencies\nuv sync --upgrade\n\n# Update specific dependency\nuv sync --upgrade-package package-name\n```\n\n## Testing\n\nWe use `pytest` for testing. Tests are located in the `tests/` directory.\n\n### Running Tests\n\n```bash\n# Run all tests\nuv run pytest\n\n# Run tests with verbose output\nuv run pytest -v\n\n# Run specific test file\nuv run pytest tests/test_basic.py\n\n# Run specific test\nuv run pytest tests/test_basic.py::test_package_imports\n```\n\n### Writing Tests\n\n1. **Create test files** in the `tests/` directory with the prefix `test_`\n2. **Follow naming conventions**: `test_*.py` files, `test_*` functions\n3. **Use descriptive test names** that explain what is being tested\n4. **Add docstrings** to explain complex test scenarios\n\nExample test structure:\n\n```python\ndef test_feature_description() -> None:\n    \"\"\"Test that the feature works as expected.\"\"\"\n    # Arrange\n    input_data = \"test input\"\n\n    # Act\n    result = function_under_test(input_data)\n\n    # Assert\n    assert result == expected_output\n```\n\n## Code Quality\n\n### Linting with Ruff\n\nWe use `ruff` for Python linting and code formatting.\n\n```bash\n# Check code quality\nuv run ruff check\n\n# Auto-fix issues where possible\nuv run ruff check --fix\n\n# Check specific files or directories\nuv run ruff check confluence_markdown_exporter/\nuv run ruff check tests/\n```\n\n### Code Style Guidelines\n\n- **Line length**: Maximum 100 characters\n- **Docstring style**: Google docstring convention\n- **Import formatting**: One import per line (enforced by ruff)\n- **Type hints**: Use type annotations for new code\n\n### Pre-commit Workflow\n\nBefore committing:\n\n1. **Run linting**: `uv run ruff check`\n2. **Run tests**: `uv run pytest`\n3. **Fix any issues** before committing\n\n## Release Process\n\n> [!NOTE]\n> Only relevant for maintainers.\n\n### Automated Release\n\nWe use GitHub Actions for automated releases:\n\n1. **Trigger Release Workflow**\n\n   - Go to GitHub Actions tab\n   - Run \"Release\" workflow\n   - Choose version bump type (patch/minor/major) or specify custom version\n\n2. **Automated Steps**\n   - Updates version in `pyproject.toml`\n   - Runs tests and builds\n   - Creates Git tag\n   - Publishes to PyPI\n   - Creates GitHub release with auto-generated notes\n   - Publishes the multi-arch Docker image to Docker Hub\n\n## Pull Request Guidelines\n\n### Before Submitting\n\n1. **Create a feature branch**\n\n   ```bash\n   git checkout -b feature/your-feature-name\n   ```\n\n2. **Run the full test suite**\n\n   ```bash\n   uv run ruff check\n   uv run pytest\n   uv build --no-sources  # Test build\n   ```\n\n3. **Update documentation** if needed\n\n### PR Requirements\n\n- ✅ **All tests pass** (verified by CI)\n- ✅ **Code passes linting** (ruff check)\n- ✅ **Descriptive PR title** and description\n- ✅ **Reference related issues** if applicable\n- ✅ **Update tests** for new functionality\n- ✅ **Update documentation** for user-facing changes\n\n## Development Environment\n\n### Recommended Tools\n\n- **IDE**: VS Code with Python extension\n- **Git client**: Command line or your preferred GUI\n- **Terminal**: Any modern terminal with shell completion\n\n### VS Code Extensions\n\nRecommended extensions for development:\n\n- Python (Microsoft)\n- Ruff (Astral Software)\n- GitLens (GitKraken)\n- markdownlint (David Anson)\n\n### Project Structure\n\n```text\nconfluence-markdown-exporter/\n├── .github/workflows/      # CI/CD workflows\n├── confluence_markdown_exporter/  # Main package\n│   ├── __init__.py\n│   ├── main.py            # CLI entry point\n│   ├── confluence.py      # Core functionality\n│   ├── api_clients.py     # API integrations\n│   └── utils/             # Utility modules\n├── tests/                 # Test suite\n├── .ruff.toml            # Ruff configuration\n├── pyproject.toml        # Project configuration\n├── uv.lock              # Dependency lock file\n└── CONTRIBUTING.md       # This file\n```\n\n## Getting Help\n\n- **GitHub Issues**: For bug reports and feature requests\n- **GitHub Discussions**: For questions and general discussion\n- **Documentation**: Check the README and code comments\n\nThank you for contributing to confluence-markdown-exporter! 🚀\n"
  },
  {
    "path": "Dockerfile",
    "content": "# syntax=docker/dockerfile:1.7\n\n# ---- builder ---------------------------------------------------------------\nFROM python:3.12-slim AS builder\n\nARG TARGETARCH\n\nCOPY --from=ghcr.io/astral-sh/uv:0.8 /uv /uvx /usr/local/bin/\n\nENV UV_LINK_MODE=copy \\\n    UV_COMPILE_BYTECODE=1 \\\n    UV_PYTHON_DOWNLOADS=never\n\nWORKDIR /app\n\n# Install runtime dependencies only. This layer is cached unless uv.lock or\n# pyproject.toml change. Metadata is bind-mounted so it does not get baked\n# into the layer and invalidate it on unrelated edits.\nRUN --mount=type=cache,target=/root/.cache/uv,id=uv-$TARGETARCH \\\n    --mount=type=bind,source=uv.lock,target=uv.lock \\\n    --mount=type=bind,source=pyproject.toml,target=pyproject.toml \\\n    --mount=type=bind,source=README.md,target=README.md \\\n    uv sync --locked --no-install-project --no-editable --no-dev\n\n# Install the project itself into the venv. Invalidates on source edits.\nCOPY pyproject.toml uv.lock README.md ./\nCOPY confluence_markdown_exporter ./confluence_markdown_exporter\nRUN --mount=type=cache,target=/root/.cache/uv,id=uv-$TARGETARCH \\\n    uv sync --locked --no-editable --no-dev\n\n# ---- runtime ---------------------------------------------------------------\nFROM python:3.12-slim AS runtime\n\nENV PYTHONDONTWRITEBYTECODE=1 \\\n    PYTHONUNBUFFERED=1 \\\n    PATH=\"/app/.venv/bin:$PATH\" \\\n    HOME=/data/config \\\n    XDG_CONFIG_HOME=/data/config \\\n    CME_CONFIG_PATH=/data/config/app_data.json \\\n    CME_EXPORT__OUTPUT_PATH=/data/output\n\nRUN groupadd --system --gid 1000 cme \\\n    && useradd  --system --uid 1000 --gid cme --home-dir /data/config --shell /usr/sbin/nologin cme \\\n    && mkdir -p /data/output /data/config \\\n    && chown -R cme:cme /data\n\n# Copy only the venv, not the source. `--no-editable` made the install\n# self-contained so the source tree is not needed at runtime.\nCOPY --from=builder /app/.venv /app/.venv\n\nUSER cme\nWORKDIR /data/output\n\nENTRYPOINT [\"confluence-markdown-exporter\"]\nCMD [\"--help\"]\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2025 Sebastian Penhouet\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "<p align=\"center\">\n  <a href=\"https://github.com/Spenhouet/confluence-markdown-exporter\"><img src=\"https://raw.githubusercontent.com/Spenhouet/confluence-markdown-exporter/b8caaba935eea7e7017b887c86a740cb7bf99708/logo.png\" alt=\"confluence-markdown-exporter\"></a>\n</p>\n<p align=\"center\">\n    <em>The confluence-markdown-exporter exports Confluence pages in Markdown format. This exporter helps in migrating content from Confluence to platforms that support Markdown e.g. Obsidian, Gollum, Azure DevOps (ADO), Foam, Dendron and more.</em>\n</p>\n<p align=\"center\">\n  <a href=\"https://github.com/Spenhouet/confluence-markdown-exporter/actions/workflows/python-build.yml\"><img src=\"https://github.com/Spenhouet/confluence-markdown-exporter/actions/workflows/python-build.yml/badge.svg\" alt=\"Build Python package\"></a>\n  <a href=\"https://github.com/Spenhouet/confluence-markdown-exporter/actions/workflows/release.yml\"><img src=\"https://github.com/Spenhouet/confluence-markdown-exporter/actions/workflows/release.yml/badge.svg\" alt=\"Build and publish to PyPI\"></a>\n  <a href=\"https://pypi.org/project/confluence-markdown-exporter\" target=\"_blank\">\n    <img src=\"https://img.shields.io/pypi/v/confluence-markdown-exporter?color=%2334D058&label=PyPI%20package\" alt=\"PyPI version\">\n   </a>\n  <a href=\"https://hub.docker.com/r/spenhouet/confluence-markdown-exporter\" target=\"_blank\">\n    <img src=\"https://img.shields.io/docker/v/spenhouet/confluence-markdown-exporter?sort=semver&label=Docker%20Hub&color=2496ED&logo=docker&logoColor=white\" alt=\"Docker Hub version\">\n   </a>\n  <a href=\"https://spenhouet.github.io/confluence-markdown-exporter/\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/docs-online-blue\" alt=\"Documentation\">\n   </a>\n</p>\n\n## What it does\n\nExports individual pages, pages with descendants, or entire Confluence spaces via the Atlassian API into clean Markdown. Skips unchanged pages by default, re-exporting only what has changed since the last run.\n\nSupported targets include Obsidian, Gollum, Azure DevOps (ADO) wikis, Foam, Dendron, and anything else that consumes Markdown.\n\nFull feature list, configuration reference, and target-system presets live in the **[documentation site](https://spenhouet.github.io/confluence-markdown-exporter/)**.\n\n## Quickstart\n\n### 1. Install\n\n**macOS and Linux**\n\n```bash\ncurl -LsSf uvx.sh/confluence-markdown-exporter/install.sh | sh\n```\n\n**Windows**\n\n```powershell\npowershell -ExecutionPolicy ByPass -c \"irm https://uvx.sh/confluence-markdown-exporter/install.ps1 | iex\"\n```\n\nInstalling a specific version:\n\n```bash\ncurl -LsSf uvx.sh/confluence-markdown-exporter/5.1.1/install.sh | sh\n```\n\nAlternative install methods (PyPI via `pip` / `uv`, prebuilt Docker image) are covered in the [installation docs](https://spenhouet.github.io/confluence-markdown-exporter/installation) and the [Docker page](https://spenhouet.github.io/confluence-markdown-exporter/docker).\n\n> **Using the Docker image?** Steps 2 and 3 below use the local `cme` CLI. Inside the Docker image there is no interactive `cme config` menu; you supply a pre-defined config (mounted JSON file or `CME_*` environment variables) and run a single export command per container invocation. See the [Docker page](https://spenhouet.github.io/confluence-markdown-exporter/docker) for the non-interactive flow.\n\n### 2. Authenticate\n\nSet Confluence credentials interactively (URL, username, API token / PAT):\n\n```sh\ncme config edit auth.confluence\n```\n\nSee [Authentication](https://spenhouet.github.io/confluence-markdown-exporter/configuration/authentication) for token scopes and Jira setup.\n\n### 3. Export\n\n```sh\n# A single page\ncme pages <page-url>\n\n# A page and all its descendants\ncme pages-with-descendants <page-url>\n\n# An entire space\ncme spaces <space-url>\n\n# Every space of an organisation\ncme orgs <base-url>\n```\n\nOutput goes to the configured `export.output_path` (current directory by default).\n\n## Documentation\n\nThe full documentation lives at **<https://spenhouet.github.io/confluence-markdown-exporter/>** and includes:\n\n- [Installation](https://spenhouet.github.io/confluence-markdown-exporter/installation) (curl / PowerShell / pip / uv)\n- [Usage guide](https://spenhouet.github.io/confluence-markdown-exporter/usage): pages, descendants, spaces, orgs, output layout\n- [Feature list](https://spenhouet.github.io/confluence-markdown-exporter/features): supported Confluence content, macros, and add-ons\n- [Configuration](https://spenhouet.github.io/confluence-markdown-exporter/configuration): config commands, ENV vars, full option reference\n- [Target-system presets](https://spenhouet.github.io/confluence-markdown-exporter/configuration/target-systems): Obsidian, Azure DevOps, …\n- [Docker](https://spenhouet.github.io/confluence-markdown-exporter/docker): prebuilt images for non-interactive / CI use\n- [CI / non-interactive use](https://spenhouet.github.io/confluence-markdown-exporter/configuration/ci)\n- [Compatibility](https://spenhouet.github.io/confluence-markdown-exporter/compatibility) and [Troubleshooting](https://spenhouet.github.io/confluence-markdown-exporter/troubleshooting)\n\n## Contributing\n\nIf you would like to contribute, please read [our contribution guideline](CONTRIBUTING.md).\n\n## License\n\nThis tool is an open source project released under the [MIT License](LICENSE).\n"
  },
  {
    "path": "confluence_markdown_exporter/__init__.py",
    "content": "\"\"\"Confluence Markdown Exporter package.\"\"\"\n\ntry:\n    from importlib.metadata import version\n\n    __version__ = version(\"confluence-markdown-exporter\")\nexcept Exception:  # noqa: BLE001\n    # fallback if package not installed or metadata not available\n    __version__ = \"unknown\"\n"
  },
  {
    "path": "confluence_markdown_exporter/api_clients.py",
    "content": "import logging\nimport re\nimport urllib.parse\nfrom threading import Lock\nfrom threading import local\nfrom typing import Annotated\n\nimport requests\nfrom atlassian import Confluence as ConfluenceApiSdk\nfrom atlassian import Jira as JiraApiSdk\nfrom pydantic import AfterValidator\nfrom pydantic import BaseModel\n\nfrom confluence_markdown_exporter.utils.app_data_store import ApiDetails\nfrom confluence_markdown_exporter.utils.app_data_store import AtlassianSdkConnectionConfig\nfrom confluence_markdown_exporter.utils.app_data_store import get_settings\nfrom confluence_markdown_exporter.utils.app_data_store import normalize_instance_url\nfrom confluence_markdown_exporter.utils.app_data_store import set_setting_with_keys\n\nlogger = logging.getLogger(__name__)\n\n# URL-keyed caches for API clients\n_confluence_clients: dict[str, ConfluenceApiSdk] = {}\n_jira_clients: dict[str, JiraApiSdk] = {}\n_clients_lock = Lock()\n\n# Thread-local storage for per-URL Confluence clients (one per worker thread per URL)\n_thread_local = local()\n\n_CLOUD_DOMAIN = \".atlassian.net\"\n_GATEWAY_PREFIX = \"https://api.atlassian.com/ex\"\n\n\ndef parse_gateway_url(url: str) -> tuple[str, str] | None:\n    m = re.search(r\"https://api\\.atlassian\\.com/ex/(confluence|jira)/([^/?#]+)\", url)\n    return (m.group(1).lower(), m.group(2)) if m else None\n\n\ndef build_gateway_url(service: str, cloud_id: str) -> str:\n    return f\"{_GATEWAY_PREFIX}/{service.lower()}/{cloud_id}\"\n\n\ndef ensure_service_gateway_url(url: str, service: str | None = None) -> str:\n    \"\"\"Ensure the gateway URL uses the specified service.\n\n    ``https://api.atlassian.com/ex/confluence/{cloudId}``\n    becomes ``https://api.atlassian.com/ex/jira/{cloudId}``.\n    Non-gateway URLs are returned as-is.\n    \"\"\"\n    if parsed := parse_gateway_url(url):\n        return build_gateway_url(service or parsed[0], parsed[1])\n\n    return url\n\n\ndef _is_standard_atlassian_cloud_url(url: str) -> bool:\n    \"\"\"Return True if *url* looks like a standard Atlassian Cloud instance URL.\"\"\"\n    try:\n        hostname = urllib.parse.urlparse(url).hostname or \"\"\n        return hostname.endswith(_CLOUD_DOMAIN)\n    except Exception:  # noqa: BLE001\n        return False\n\n\ndef _try_fetch_cloud_id(base_url: str) -> str | None:\n    \"\"\"Try to fetch the Atlassian Cloud ID from the public tenant info endpoint.\n\n    Returns the cloud ID string, or None if the fetch fails (e.g. for Server instances).\n    \"\"\"\n    try:\n        resp = requests.get(f\"{base_url}/_edge/tenant_info\", timeout=5)\n        if resp.ok:\n            return resp.json().get(\"cloudId\")\n    except Exception as e:  # noqa: BLE001\n        logger.debug(\"Could not fetch Cloud ID from %s/_edge/tenant_info: %s\", base_url, e)\n    return None\n\n\ndef _get_confluence_sdk_url(base_url: str, auth: ApiDetails) -> str:\n    \"\"\"Return the SDK URL for Confluence, using the API gateway when a Cloud ID is configured.\"\"\"\n    if auth.cloud_id:\n        return f\"{_GATEWAY_PREFIX}/confluence/{auth.cloud_id}\"\n    return base_url\n\n\ndef _get_jira_sdk_url(base_url: str, auth: ApiDetails) -> str:\n    \"\"\"Return the SDK URL for Jira, using the API gateway when a Cloud ID is configured.\"\"\"\n    if auth.cloud_id:\n        return f\"{_GATEWAY_PREFIX}/jira/{auth.cloud_id}\"\n    return base_url\n\n\ndef _decode_url_part(v: str | None) -> None | str:\n    if v is None or v == \"\":\n        return None\n    return urllib.parse.unquote_plus(v)\n\n\nclass ConfluenceRef(BaseModel):\n    space_key: Annotated[str, AfterValidator(_decode_url_part)]\n    page_id: int | None = None\n    page_title: Annotated[str | None, AfterValidator(_decode_url_part)] = None\n\n\n# 1) Cloud [/wiki]/spaces/{space_key}[/pages/{page_id}[/{page_title}]]\n_CLOUD_URL_RE = re.compile(\n    r\"^(?:/ex/confluence/[^/]+)?(?:/wiki)?/spaces/\"\n    r\"(?P<space_key>[A-Za-z0-9_~-]+)\"\n    r\"(?:/pages/(?P<page_id>\\d+)(?:/(?P<page_title>[^/?#]+))?)?\"\n    r\"(?:/(?!pages/)[^/?#]+)?/?$\"\n)\n\n# 2) Server [/display]/{space_key}[/{page_title}]\n_SERVER_URL_RE = re.compile(\n    r\"^(?:/display)?\"\n    r\"/(?P<space_key>[A-Za-z0-9._-]+)\"\n    r\"(?:/(?P<page_title>[^/?#]+))?/?$\"\n)\n\n\ndef parse_confluence_path(path: str) -> ConfluenceRef | None:\n    \"\"\"Parse only the path portion of a Confluence URL and return a ConfluenceRef dict.\n\n    Matching order:\n      1) Cloud [/wiki]/spaces/{space_key}[/pages/{page_id}[/{page_title}]]\n      2) Server [/display]/{space_key}[/{page_title}]\n    \"\"\"\n    if not path:\n        return None\n    if not path.startswith(\"/\"):\n        path = \"/\" + path\n    path = path.rstrip(\"/\")\n\n    if m := _CLOUD_URL_RE.match(path) or _SERVER_URL_RE.match(path):\n        return ConfluenceRef.model_validate(m.groupdict())\n\n    return None\n\n\nclass AuthNotConfiguredError(BaseException):\n    \"\"\"Raised when a connection attempt fails and no valid auth is configured for the URL.\n\n    Inherits from BaseException (not Exception) so that broad ``except Exception`` handlers\n    in export loops do not accidentally swallow it — it must propagate to the app boundary.\n    \"\"\"\n\n    def __init__(self, url: str, service: str = \"Confluence\") -> None:\n        self.url = url\n        self.service = service\n        super().__init__(f\"No valid authentication configured for {service} at {url}\")\n\n\nclass JiraAuthenticationError(Exception):\n    \"\"\"Raised when a Jira API response indicates an authentication failure.\"\"\"\n\n\ndef _jira_auth_failure_hook(\n    response: requests.Response, *_args: object, **_kwargs: object\n) -> requests.Response:\n    \"\"\"Raise JiraAuthenticationError when Jira signals authentication failure.\"\"\"\n    if response.headers.get(\"X-Seraph-Loginreason\") == \"AUTHENTICATED_FAILED\":\n        msg = f\"Jira authentication failed for request to {response.url}\"\n        raise JiraAuthenticationError(msg)\n    return response\n\n\ndef response_hook(\n    response: requests.Response, *_args: object, **_kwargs: object\n) -> requests.Response:\n    \"\"\"Log response headers when requests fail.\"\"\"\n    if not response.ok:\n        logger.warning(\n            \"Request to %s failed with status %s. Response headers: %s\",\n            response.url,\n            response.status_code,\n            dict(response.headers),\n        )\n    return response\n\n\nclass ApiClientFactory:\n    \"\"\"Factory for creating authenticated Confluence and Jira API clients with retry config.\"\"\"\n\n    def __init__(self, connection_config: AtlassianSdkConnectionConfig) -> None:\n        # Reconstruct as the base SDK type so model_dump() only yields SDK-compatible fields,\n        # even when a ConnectionConfig subclass is passed.\n        self.connection_config = AtlassianSdkConnectionConfig.model_validate(\n            connection_config.model_dump()\n        )\n\n    def create_confluence(self, url: str, auth: ApiDetails) -> ConfluenceApiSdk:\n        try:\n            instance = ConfluenceApiSdk(\n                url=url,\n                username=auth.username.get_secret_value() if auth.api_token else None,\n                password=auth.api_token.get_secret_value() if auth.api_token else None,\n                token=auth.pat.get_secret_value() if auth.pat else None,\n                **self.connection_config.model_dump(),\n            )\n            instance.get_all_spaces(limit=1)\n        except Exception as e:\n            msg = f\"Confluence connection failed: {e}\"\n            raise ConnectionError(msg) from e\n        return instance\n\n    def create_jira(self, url: str, auth: ApiDetails) -> JiraApiSdk:\n        try:\n            instance = JiraApiSdk(\n                url=url,\n                username=auth.username.get_secret_value() if auth.api_token else None,\n                password=auth.api_token.get_secret_value() if auth.api_token else None,\n                token=auth.pat.get_secret_value() if auth.pat else None,\n                **self.connection_config.model_dump(),\n            )\n            instance.get_all_projects()\n        except Exception as e:\n            msg = f\"Jira connection failed: {e}\"\n            raise ConnectionError(msg) from e\n        return instance\n\n\ndef get_confluence_instance(url: str) -> ConfluenceApiSdk:\n    \"\"\"Get authenticated Confluence API client for *url*.\n\n    Creates a new client if one doesn't exist for that URL yet and caches it.\n    Prompts for auth config on connection failure.\n\n    When the configured auth for *url* includes a Cloud ID, API calls are routed through\n    the Atlassian API gateway (``https://api.atlassian.com/ex/confluence/{cloud_id}``),\n    which enables the use of scoped API tokens.  For standard Atlassian Cloud instances\n    (``.atlassian.net``) the Cloud ID is fetched and stored automatically on first connection.\n    \"\"\"\n    url = normalize_instance_url(ensure_service_gateway_url(url, \"confluence\"))\n    with _clients_lock:\n        if url in _confluence_clients:\n            logger.debug(\"Confluence client cache hit for %s\", url)\n            return _confluence_clients[url]\n\n    settings = get_settings()\n\n    auth = settings.auth.get_instance(url)\n    if auth is None:\n        raise AuthNotConfiguredError(url, \"Confluence\")\n\n    logger.debug(\"Creating new Confluence client for %s\", url)\n\n    # Auto-fetch and store the Cloud ID for standard Atlassian Cloud instances\n    if not auth.cloud_id and _is_standard_atlassian_cloud_url(url):\n        cloud_id = _try_fetch_cloud_id(url)\n        if cloud_id:\n            logger.info(\"Auto-fetched Atlassian Cloud ID for %s — storing in config\", url)\n            set_setting_with_keys([\"auth\", \"confluence\", url, \"cloud_id\"], cloud_id)\n            settings = get_settings()\n\n    auth = settings.auth.get_instance(url) or ApiDetails()\n    sdk_url = _get_confluence_sdk_url(url, auth)\n    try:\n        client = ApiClientFactory(settings.connection_config).create_confluence(sdk_url, auth)\n        logger.info(\"Connected to Confluence at %s\", sdk_url)\n    except ConnectionError as e:\n        logger.exception(\"[red bold]Confluence authentication failed for %s.[/red bold]\", url)\n        raise AuthNotConfiguredError(url, \"Confluence\") from e\n\n    if settings.export.log_level == \"DEBUG\":\n        client.session.hooks[\"response\"] = [response_hook]\n\n    with _clients_lock:\n        _confluence_clients[url] = client\n    return client\n\n\ndef get_thread_confluence(base_url: str) -> ConfluenceApiSdk:\n    \"\"\"Get or create a thread-local Confluence client for *base_url*.\n\n    The atlassian-python-api Confluence client uses requests.Session, which is\n    NOT thread-safe.  Each worker thread keeps its own dict of clients keyed by\n    base URL so that multi-instance exports are also thread-safe.\n    \"\"\"\n    base_url = normalize_instance_url(base_url)\n    if not hasattr(_thread_local, \"clients\"):\n        _thread_local.clients = {}\n    if base_url not in _thread_local.clients:\n        logger.debug(\"Initializing thread-local Confluence client for %s\", base_url)\n        _thread_local.clients[base_url] = get_confluence_instance(base_url)\n    return _thread_local.clients[base_url]\n\n\ndef get_jira_instance(url: str) -> JiraApiSdk:\n    \"\"\"Get authenticated Jira API client for *url*.\n\n    Creates a new client if one doesn't exist for that URL yet and caches it.\n\n    When the input is a Confluence gateway URL (``/ex/confluence/{cloudId}``), it is\n    automatically converted to the Jira gateway URL (``/ex/jira/{cloudId}``) before\n    auth lookup and SDK connection.  This handles the common case where the caller\n    derives the Jira URL from a Confluence page's ``base_url``.\n\n    When the configured auth for *url* includes a Cloud ID, API calls are routed through\n    the Atlassian API gateway (``https://api.atlassian.com/ex/jira/{cloud_id}``).\n    For standard Atlassian Cloud instances the Cloud ID is fetched and stored automatically.\n    \"\"\"\n    # Always work with the Jira gateway URL, even if the caller passed the Confluence one.\n    url = normalize_instance_url(ensure_service_gateway_url(url, \"jira\"))\n    settings = get_settings()\n\n    if not settings.export.enable_jira_enrichment:\n        msg = \"Jira API client was requested eventhough Jira enrichment is disabled.\"\n        raise RuntimeWarning(msg)\n\n    with _clients_lock:\n        if url in _jira_clients:\n            logger.debug(\"Jira client cache hit for %s\", url)\n            return _jira_clients[url]\n\n    auth = settings.auth.get_jira_instance(url)\n    if auth is None:\n        raise AuthNotConfiguredError(url, \"Jira\")\n\n    logger.debug(\"Creating new Jira client for %s\", url)\n\n    # Auto-fetch and store the Cloud ID for standard Atlassian Cloud instances\n    if not auth.cloud_id and _is_standard_atlassian_cloud_url(url):\n        cloud_id = _try_fetch_cloud_id(url)\n        if cloud_id:\n            logger.info(\"Auto-fetched Atlassian Cloud ID for %s — storing in config\", url)\n            set_setting_with_keys([\"auth\", \"jira\", url, \"cloud_id\"], cloud_id)\n            settings = get_settings()\n\n    auth = settings.auth.get_jira_instance(url) or auth\n    sdk_url = _get_jira_sdk_url(url, auth)\n    try:\n        client = ApiClientFactory(settings.connection_config).create_jira(sdk_url, auth)\n        logger.info(\"Connected to Jira at %s\", sdk_url)\n    except ConnectionError as e:\n        logger.exception(\"[red bold]Jira authentication failed for %s.[/red bold]\", url)\n        raise AuthNotConfiguredError(url, \"Jira\") from e\n\n    client.session.hooks[\"response\"].append(_jira_auth_failure_hook)\n\n    if settings.export.log_level == \"DEBUG\":\n        client.session.hooks[\"response\"].append(response_hook)\n\n    with _clients_lock:\n        _jira_clients[url] = client\n    return client\n\n\ndef invalidate_confluence_client(url: str) -> None:\n    \"\"\"Remove a cached Confluence client so the next call creates a fresh one.\"\"\"\n    with _clients_lock:\n        _confluence_clients.pop(normalize_instance_url(url), None)\n\n\ndef invalidate_jira_client(url: str) -> None:\n    \"\"\"Remove a cached Jira client so the next call creates a fresh one.\"\"\"\n    with _clients_lock:\n        _jira_clients.pop(normalize_instance_url(url), None)\n\n\ndef handle_jira_auth_failure(url: str) -> None:\n    \"\"\"Handle a Jira authentication failure by invalidating the cached client and raising.\"\"\"\n    invalidate_jira_client(url)\n    raise AuthNotConfiguredError(url, \"Jira\")\n"
  },
  {
    "path": "confluence_markdown_exporter/config.py",
    "content": "\"\"\"Config sub-app for the cme CLI.\"\"\"\n\nimport json\nimport logging\nfrom typing import Annotated\n\nimport jmespath\nimport typer\nimport yaml\n\nfrom confluence_markdown_exporter.utils.app_data_store import APP_CONFIG_PATH\nfrom confluence_markdown_exporter.utils.app_data_store import get_settings\nfrom confluence_markdown_exporter.utils.app_data_store import reset_to_defaults\nfrom confluence_markdown_exporter.utils.app_data_store import set_setting\n\nlogger = logging.getLogger(__name__)\n\n# Each table row must be its own \\n\\n-separated block so typer's epilog\n# renderer keeps single \\n between rows, forming valid markdown table syntax.\n_CONFIG_KEYS_EPILOG = (\n    \"---\\n\\n\"\n    \"**Available config keys** (run `cme config list` to see all current values):\\n\\n\"\n    \"| Key | Description |\\n\\n\"\n    \"| --- | ----------- |\\n\\n\"\n    \"| `export.output_path` | Directory where exported files are saved |\\n\\n\"\n    \"| `export.log_level` | Verbosity: `DEBUG`, `INFO`, `WARNING`, `ERROR` |\\n\\n\"\n    \"| `export.save_log_to_file` | Also write logs to `cme.log` next to the config file |\\n\\n\"\n    \"| `export.skip_unchanged` | Skip pages unchanged since last export |\\n\\n\"\n    \"| `export.cleanup_stale` | Delete local files for removed pages |\\n\\n\"\n    \"| `export.page_path` | File path template for exported pages |\\n\\n\"\n    \"| `export.attachment_path` | File path template for exported attachments |\\n\\n\"\n    \"| `export.page_href` | Link style for pages: `relative` or `absolute` |\\n\\n\"\n    \"| `export.attachment_href` | Link style for attachments: `relative` or `absolute` |\\n\\n\"\n    \"| `export.include_document_title` | Prepend H1 title to each page |\\n\\n\"\n    \"| `export.include_toc` | Export Table of Contents macro (`true`/`false`) |\\n\\n\"\n    \"| `export.include_macro` | How to render `include`/`excerpt-include` macros:\"\n    \" `inline` (default) or `transclusion` (Obsidian `![[Page Title]]` embed) |\\n\\n\"\n    \"| `export.page_breadcrumbs` | Include breadcrumb links at top of page |\\n\\n\"\n    \"| `export.confluence_url_in_frontmatter` | Include Confluence page URL in YAML \"\n    \"front matter: `none`, `webui`, `tinyui`, `both` |\\n\\n\"\n    \"| `export.page_metadata_in_frontmatter` | Add Confluence page metadata \"\n    \"fields (page_id, space_key, type, created, created_by, last_modified, \"\n    \"last_modified_by, version) to YAML front matter (`true`/`false`) |\\n\\n\"\n    \"| `export.enable_jira_enrichment` | Fetch Jira data for enriched links |\\n\\n\"\n    \"| `export.attachments_export` | Which attachments to download:\"\n    \" `referenced` (default), `all`, `disabled` |\\n\\n\"\n    \"| `export.image_captions` | Use image captions as markdown alt text (`true`/`false`) |\\n\\n\"\n    \"| `export.comments_export` | Which comments to export to sidecar \"\n    \"`.comments.md` files: `none` (default), `inline`, `footer`, `all` |\\n\\n\"\n    \"| `export.convert_status_badges` | Convert Confluence status badges to `<mark>` elements |\\n\\n\"\n    \"| `export.convert_text_highlights` | Convert background-color spans to `<mark>` elements |\\n\\n\"\n    \"| `export.convert_font_colors` | Convert font-color spans to `<font>` elements |\\n\\n\"\n    \"| `export.filename_length` | Maximum filename length (default: 255) |\\n\\n\"\n    \"| `connection_config.max_workers` | Parallel export workers (default: 20) |\\n\\n\"\n    \"| `connection_config.use_v2_api` | Use Confluence REST API v2 (`true`/`false`) |\\n\\n\"\n    \"| `connection_config.verify_ssl` | Verify SSL certificates (`true`/`false`) |\\n\\n\"\n    \"| `connection_config.timeout` | API request timeout in seconds |\\n\\n\"\n    \"| `auth.confluence` | Credentials keyed by instance URL — use `cme config edit` |\\n\\n\"\n    \"| `auth.jira` | Jira credentials keyed by instance URL — use `cme config edit` |\\n\\n\"\n    \"---\\n\\n\"\n    \"Env var override: prefix with `CME_` and `__` as delimiter. \"\n    \"Examples: `CME_EXPORT__OUTPUT_PATH=/tmp/export`, `CME_CONNECTION_CONFIG__MAX_WORKERS=5`.\\n\\n\"\n)\n\napp = typer.Typer(\n    rich_markup_mode=\"markdown\",\n    invoke_without_command=True,\n    help=(\n        \"Manage configuration interactively or via subcommands.\\n\\n\"\n        \"Running `cme config` without a subcommand opens the **interactive menu**, \"\n        \"which lets you browse and change all settings including authentication credentials.\\n\\n\"\n        \"For scripting or automation, use the subcommands below.\"\n    ),\n    epilog=(\n        \"**Subcommands at a glance:**\\n\\n\"\n        \"- `cme config` — interactive menu\\n\\n\"\n        \"- `cme config list` — print full config as YAML\\n\\n\"\n        \"- `cme config list -o json` — print full config as JSON\\n\\n\"\n        \"- `cme config get export.log_level` — print a single value\\n\\n\"\n        \"- `cme config set export.log_level=DEBUG` — set a value\\n\\n\"\n        \"- `cme config edit auth.confluence` — edit credentials interactively\\n\\n\"\n        \"- `cme config path` — show config file path\\n\\n\"\n        \"- `cme config reset` — reset all settings to defaults\\n\\n\"\n        \"- `cme config reset export.log_level` — reset a single key to its default\\n\\n\"\n    ),\n)\n\n\n@app.callback(invoke_without_command=True)\ndef callback(ctx: typer.Context) -> None:\n    \"\"\"Open the interactive configuration menu if no subcommand is given.\"\"\"\n    if ctx.invoked_subcommand is None:\n        from confluence_markdown_exporter.utils.config_interactive import main_config_menu_loop\n\n        main_config_menu_loop(None)\n\n\n@app.command(\n    help=(\n        \"Reset configuration to defaults.\\n\\n\"\n        \"Without a `KEY` argument, resets the **entire configuration** to factory defaults. \"\n        \"Pass a dot-notation key to reset only that key or section.\\n\\n\"\n        \"Use `--yes` / `-y` to skip the confirmation prompt (useful in scripts).\"\n    ),\n    epilog=(\n        \"**Examples:**\\n\\n\"\n        \"- `cme config reset` — reset everything (prompts for confirmation)\\n\\n\"\n        \"- `cme config reset --yes` — skip confirmation prompt\\n\\n\"\n        \"- `cme config reset export.log_level` — reset a single key to its default\\n\\n\"\n        \"- `cme config reset connection_config` — reset a whole section to defaults\\n\\n\"\n    ),\n)\ndef reset(\n    key: Annotated[\n        str | None,\n        typer.Argument(\n            help=(\n                \"Dot-notation config key or section to reset to its default. \"\n                \"If omitted, the entire configuration is reset. \"\n                \"Examples: `export.log_level`, `connection_config`, `export`.\"\n            ),\n            metavar=\"KEY\",\n        ),\n    ] = None,\n    yes: Annotated[  # noqa: FBT002\n        bool,\n        typer.Option(\"--yes\", \"-y\", help=\"Skip the confirmation prompt.\"),\n    ] = False,\n) -> None:\n    if not yes:\n        target = f\"'{key}'\" if key else \"all configuration\"\n        confirmed = typer.confirm(f\"Reset {target} to defaults?\", default=False)\n        if not confirmed:\n            raise typer.Abort\n    reset_to_defaults(key)\n    target = f\"'{key}'\" if key else \"Configuration\"\n    typer.echo(f\"{target} reset to defaults.\")\n\n\n@app.command(\n    help=(\n        \"Print the path to the configuration file.\\n\\n\"\n        \"Override the config file location by setting the `CME_CONFIG_PATH` environment variable.\"\n    ),\n    epilog=(\n        \"**Example:**\\n\\n\"\n        \"- `cme config path`\\n\\n\"\n        \"- `CME_CONFIG_PATH=/custom/path.json cme config path` — custom config file\\n\\n\"\n    ),\n)\ndef path() -> None:\n    \"\"\"Output the path to the configuration file.\"\"\"\n    typer.echo(str(APP_CONFIG_PATH))\n\n\n@app.command(\n    name=\"list\",\n    help=(\n        \"Print the current configuration as YAML (default) or JSON.\\n\\n\"\n        \"Shows all settings and their current effective values. \"\n        \"Use this to discover available config keys for `cme config get` and \"\n        \"`cme config set`.\\n\\n\"\n        \"> **Note:** Secret values (API tokens, passwords) are printed in plaintext.\"\n    ),\n    epilog=(\n        \"**Examples:**\\n\\n\"\n        \"- `cme config list` — YAML output (default)\\n\\n\"\n        \"- `cme config list -o json` — JSON output\\n\\n\"\n        \"- `cme config list -o yaml` — explicit YAML\\n\\n\"\n    ),\n)\ndef list_config(\n    output: Annotated[\n        str,\n        typer.Option(\n            \"--output\",\n            \"-o\",\n            help=\"Output format. Accepted values: `yaml` (default) or `json`.\",\n            metavar=\"FORMAT\",\n        ),\n    ] = \"yaml\",\n) -> None:\n    \"\"\"Output the current configuration as YAML or JSON.\"\"\"\n    current_settings = get_settings()\n    data = json.loads(current_settings.model_dump_json())\n    fmt = output.lower()\n    if fmt == \"json\":\n        typer.echo(json.dumps(data, indent=2))\n    elif fmt in (\"yaml\", \"yml\"):\n        typer.echo(yaml.dump(data, default_flow_style=False, allow_unicode=True), nl=False)\n    else:\n        typer.echo(f\"Unknown format '{output}': expected 'yaml' or 'json'.\", err=True)\n        raise typer.Exit(code=1)\n\n\n@app.command(\n    help=(\n        \"Print the current value of a single config key.\\n\\n\"\n        \"Keys use dot notation to address nested settings \"\n        \"(e.g. `export.log_level`, `connection_config.max_workers`). \"\n        \"Nested sections are printed as YAML. \"\n        \"Run `cme config list` to see all available keys.\"\n    ),\n    epilog=(\n        \"**Examples:**\\n\\n\"\n        \"- `cme config get export.log_level`\\n\\n\"\n        \"- `cme config get export.output_path`\\n\\n\"\n        \"- `cme config get connection_config.max_workers`\\n\\n\"\n        \"- `cme config get connection_config` — prints the whole section as YAML\\n\\n\"\n        \"- `cme config get export` — prints all export settings\\n\\n\"\n        + _CONFIG_KEYS_EPILOG\n    ),\n)\ndef get(\n    key: Annotated[\n        str,\n        typer.Argument(\n            help=(\n                \"Config key in dot notation. \"\n                \"Examples: `export.log_level`, `connection_config.max_workers`, `export`.\"\n            ),\n            metavar=\"KEY\",\n        ),\n    ],\n) -> None:\n    \"\"\"Output the current value of a config key.\"\"\"\n    current_settings = get_settings()\n    data = json.loads(current_settings.model_dump_json())\n    value = jmespath.search(key, data)\n    if value is None:\n        typer.echo(f\"Key '{key}' not found.\", err=True)\n        raise typer.Exit(code=1)\n    if isinstance(value, dict | list):\n        typer.echo(yaml.dump(value, default_flow_style=False, allow_unicode=True), nl=False)\n    else:\n        typer.echo(str(value))\n\n\n@app.command(\n    name=\"set\",\n    help=(\n        \"Set one or more configuration values.\\n\\n\"\n        \"Each argument must be a `key=value` pair using dot notation for the key. \"\n        \"Values are parsed as JSON where possible \"\n        \"(so `true`, `false`, numbers, and JSON arrays work), \"\n        \"falling back to a plain string.\\n\\n\"\n        \"> **Note:** For auth keys that contain a URL \"\n        \"(e.g. `auth.confluence.https://...`), use `cme config edit auth.confluence` \"\n        \"instead — the interactive editor handles URL-based keys correctly.\"\n    ),\n    epilog=(\n        \"**Examples:**\\n\\n\"\n        \"- `cme config set export.log_level=DEBUG`\\n\\n\"\n        \"- `cme config set export.output_path=/tmp/export`\\n\\n\"\n        \"- `cme config set export.skip_unchanged=false`\\n\\n\"\n        \"- `cme config set connection_config.max_workers=5`\\n\\n\"\n        \"- `cme config set connection_config.verify_ssl=false`\\n\\n\"\n        \"- `cme config set export.log_level=INFO export.output_path=./out`\"\n        \" — multiple keys at once\\n\\n\"\n        + _CONFIG_KEYS_EPILOG\n    ),\n)\ndef set_config(\n    key_values: Annotated[\n        list[str],\n        typer.Argument(\n            help=(\n                \"One or more `key=value` pairs. \"\n                \"Keys use dot notation (e.g. `export.log_level=DEBUG`). \"\n                \"Values are parsed as JSON first, then as plain strings. \"\n                \"For auth keys containing URLs, use `cme config edit` instead.\"\n            ),\n            metavar=\"KEY=VALUE\",\n        ),\n    ],\n) -> None:\n    \"\"\"Set one or more configuration values.\"\"\"\n    for kv in key_values:\n        if \"=\" not in kv:\n            typer.echo(f\"Invalid format '{kv}': expected key=value.\", err=True)\n            raise typer.Exit(code=1)\n        key, _, raw_value = kv.partition(\"=\")\n        value = _parse_value(raw_value)\n        try:\n            set_setting(key.strip(), value)\n        except (ValueError, KeyError) as e:\n            typer.echo(f\"Failed to set '{key.strip()}': {e}\", err=True)\n            raise typer.Exit(code=1) from e\n    typer.echo(\"Configuration updated.\")\n\n\n@app.command(\n    help=(\n        \"Open the interactive editor for a specific config key.\\n\\n\"\n        \"Launches the interactive configuration menu pre-navigated to the given key. \"\n        \"Especially useful for editing authentication credentials, \"\n        \"where the instance URL is part of the key and cannot be set via `cme config set`.\"\n    ),\n    epilog=(\n        \"**Examples:**\\n\\n\"\n        \"- `cme config edit auth.confluence` — add or update Confluence credentials\\n\\n\"\n        \"- `cme config edit auth.jira` — edit Jira credentials\\n\\n\"\n        \"- `cme config edit export.log_level` — edit a setting interactively\\n\\n\"\n        \"- `cme config edit export.output_path` — set output path interactively\\n\\n\"\n    ),\n)\ndef edit(\n    key: Annotated[\n        str,\n        typer.Argument(\n            help=(\n                \"Config key to open in the interactive editor, using dot notation. \"\n                \"Examples: `auth.confluence`, `auth.jira`, `export.log_level`.\"\n            ),\n            metavar=\"KEY\",\n        ),\n    ],\n) -> None:\n    \"\"\"Open the interactive editor for a specific config key.\"\"\"\n    from confluence_markdown_exporter.utils.config_interactive import main_config_menu_loop\n\n    main_config_menu_loop(key)\n\n\ndef _parse_value(value_str: str) -> object:\n    \"\"\"Parse a CLI value string, trying JSON first then falling back to raw string.\n\n    Handles JSON scalars (true/false, numbers, null), arrays, and objects.\n    Also accepts Python-style True/False for convenience.\n    \"\"\"\n    try:\n        return json.loads(value_str)\n    except json.JSONDecodeError:\n        pass\n    lower = value_str.lower()\n    if lower == \"true\":\n        return True\n    if lower == \"false\":\n        return False\n    return value_str\n"
  },
  {
    "path": "confluence_markdown_exporter/confluence.py",
    "content": "\"\"\"Confluence API documentation.\n\nhttps://developer.atlassian.com/cloud/confluence/rest/v1/intro\n\"\"\"\n\nimport functools\nimport json\nimport logging\nimport mimetypes\nimport os\nimport re\nimport urllib.parse\nfrom collections.abc import Set\nfrom concurrent.futures import ThreadPoolExecutor\nfrom concurrent.futures import as_completed\nfrom os import PathLike\nfrom pathlib import Path\nfrom string import Template\nfrom typing import Any\nfrom typing import ClassVar\nfrom typing import Literal\nfrom typing import TypeAlias\nfrom typing import cast\nfrom urllib.parse import unquote\nfrom urllib.parse import urlparse\n\nimport yaml\nfrom atlassian.errors import ApiError\nfrom atlassian.errors import ApiNotFoundError\nfrom bs4 import BeautifulSoup\nfrom bs4 import Tag\nfrom markdownify import ATX\nfrom markdownify import MarkdownConverter\nfrom pydantic import BaseModel\nfrom pydantic import Field\nfrom requests import HTTPError\nfrom requests import RequestException\nfrom rich.progress import BarColumn\nfrom rich.progress import MofNCompleteColumn\nfrom rich.progress import Progress\nfrom rich.progress import SpinnerColumn\nfrom rich.progress import TaskProgressColumn\nfrom rich.progress import TextColumn\nfrom rich.progress import TimeElapsedColumn\nfrom rich.progress import TimeRemainingColumn\nfrom tabulate import tabulate\n\nfrom confluence_markdown_exporter.api_clients import JiraAuthenticationError\nfrom confluence_markdown_exporter.api_clients import build_gateway_url\nfrom confluence_markdown_exporter.api_clients import get_confluence_instance\nfrom confluence_markdown_exporter.api_clients import get_jira_instance\nfrom confluence_markdown_exporter.api_clients import get_thread_confluence\nfrom confluence_markdown_exporter.api_clients import handle_jira_auth_failure\nfrom confluence_markdown_exporter.api_clients import parse_confluence_path\nfrom confluence_markdown_exporter.api_clients import parse_gateway_url\nfrom confluence_markdown_exporter.utils.app_data_store import get_settings\nfrom confluence_markdown_exporter.utils.app_data_store import normalize_instance_url\nfrom confluence_markdown_exporter.utils.drawio_converter import load_and_parse_drawio\nfrom confluence_markdown_exporter.utils.export import github_heading_slug\nfrom confluence_markdown_exporter.utils.export import sanitize_filename\nfrom confluence_markdown_exporter.utils.export import sanitize_key\nfrom confluence_markdown_exporter.utils.export import save_file\nfrom confluence_markdown_exporter.utils.lockfile import AttachmentEntry\nfrom confluence_markdown_exporter.utils.lockfile import LockfileManager\nfrom confluence_markdown_exporter.utils.page_registry import PageTitleRegistry\nfrom confluence_markdown_exporter.utils.rich_console import ExportStats\nfrom confluence_markdown_exporter.utils.rich_console import console\nfrom confluence_markdown_exporter.utils.rich_console import get_stats\nfrom confluence_markdown_exporter.utils.rich_console import reset_stats\nfrom confluence_markdown_exporter.utils.table_converter import TableConverter\n\nJsonResponse: TypeAlias = dict\nStrPath: TypeAlias = str | PathLike[str]\n\nlogger = logging.getLogger(__name__)\n_MAX_UNICODE_CODEPOINT = 0x10FFFF\n\n_RE_RGB_BG = re.compile(r\"background-color:\\s*rgb\\((\\d+),\\s*(\\d+),\\s*(\\d+)\\)\")\n_RE_RGB_COLOR = re.compile(r\"(?<![a-z-])color:\\s*rgb\\((\\d+),\\s*(\\d+),\\s*(\\d+)\\)\")\n_RE_COLORID_CSS = re.compile(r\"(?<![>\\w])\\[data-colorid=(\\w+)\\]\\{color:(#[0-9a-fA-F]+)\\}\")\n_RE_HEX_COLOR = re.compile(r\"^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$\")\n\n# Confluence default header backgrounds — applied automatically to <th> cells, and\n# (in matrix-style tables) to row-label <td>s. Treated as \"no user-chosen colour\".\n_DEFAULT_HEADER_BGS = frozenset({\"#f4f5f7\", \"#f2f2f2\"})\n\n\ndef _rgb_to_hex(r: int, g: int, b: int) -> str:\n    return f\"#{r:02x}{g:02x}{b:02x}\"\n\n\ndef _extract_cell_highlight_hex(el: Tag) -> str | None:\n    \"\"\"Return Confluence cell background hex from data-highlight-colour, or None.\n\n    Confluence Cloud sets `data-highlight-colour=\"#rrggbb\"` (or `\"transparent\"`)\n    on `<td>` / `<th>` when a cell background colour is applied.\n    \"\"\"\n    val = el.get(\"data-highlight-colour\")\n    if not isinstance(val, str):\n        return None\n    val = val.strip().lower()\n    if not val or val == \"transparent\" or val in _DEFAULT_HEADER_BGS:\n        return None\n    if _RE_HEX_COLOR.match(val):\n        return val\n    return None\n\n\n# Background colours for Confluence status-badge lozenges (Atlassian design token pastels).\n_LOZENGE_COLORS: dict[str, str] = {\n    \"aui-lozenge-complete\": \"#cce0ff\",  # blue\n    \"aui-lozenge-success\": \"#baf3db\",  # green\n    \"aui-lozenge-current\": \"#f8e6a0\",  # yellow / orange\n    \"aui-lozenge-error\": \"#ffd5d2\",  # red\n    \"aui-lozenge-progress\": \"#dfd8fd\",  # purple / violet\n}\n\n\ndef _require_dict(response: object, context: str) -> JsonResponse:\n    \"\"\"Validate that an API response is a dict, not an HTML redirect or error string.\n\n    SAML SSO redirects and session-expiry responses are returned as raw HTML strings\n    by the atlassian-python-api client instead of raising an exception.  Calling\n    .get() on such a string produces a confusing AttributeError; this helper surfaces\n    a clear message instead.\n    \"\"\"\n    if isinstance(response, dict):\n        return response\n    preview = str(response)[:120].replace(\"\\n\", \" \")\n    if \"SAMLRequest\" in str(response) or \"SAMLResponse\" in str(response):\n        msg = (\n            f\"Authentication failed for {context}: received a SAML SSO redirect instead of JSON. \"\n            \"Check that your Confluence token/credentials are correct and not expired.\"\n        )\n    else:\n        msg = f\"Unexpected non-dict response for {context}: {preview!r}\"\n    raise ValueError(msg)\n\n\ndef _extract_base_url(url: str) -> str:\n    \"\"\"Extract the base URL from a Confluence or Jira URL.\n\n    For Atlassian Cloud URLs (``*.atlassian.net``) returns ``{scheme}://{hostname}``.\n    For Atlassian API gateway URLs of the form\n    ``https://api.atlassian.com/ex/{service}/{cloudId}/...``\n    returns ``https://api.atlassian.com/ex/{service}/{cloudId}`` so that\n    the Cloud ID is preserved as part of the base URL used for auth lookup\n    and SDK initialisation.\n    For Server/Data Center instances with a context path (e.g.\n    ``https://host/confluence/spaces/KEY``), the context path is preserved\n    so the SDK client hits the correct REST endpoints.\n    \"\"\"\n    parsed = urllib.parse.urlparse(url)\n    if parsed.scheme is None or parsed.hostname is None:\n        msg = (\n            \"Invalid URL: a scheme (http:// or https://) and hostname are required. \"\n            \"Expected format: 'https://<hostname>[:port]/...'.\"\n        )\n        raise ValueError(msg)\n\n    if gateway := parse_gateway_url(url):\n        return normalize_instance_url(build_gateway_url(*gateway))\n\n    # For Server/DC instances the Confluence webapp may be deployed under a\n    # context path (e.g. ``/confluence``).  Preserve everything before the\n    # first path segment that belongs to Confluence's own routing.\n    _confluence_route_segments = {\n        \"wiki\",\n        \"display\",\n        \"spaces\",\n        \"rest\",\n        \"pages\",\n        \"plugins\",\n        \"dosearchsite.action\",\n    }\n    segments = [s for s in parsed.path.split(\"/\") if s]\n    context_parts: list[str] = []\n    for segment in segments:\n        if segment.lower() in _confluence_route_segments:\n            break\n        context_parts.append(segment)\n\n    base = f\"{parsed.scheme}://{parsed.hostname}\"\n    if parsed.port and parsed.port not in (80, 443):\n        base = f\"{parsed.scheme}://{parsed.hostname}:{parsed.port}\"\n    if context_parts:\n        base = f\"{base}/{'/'.join(context_parts)}\"\n    return normalize_instance_url(base)\n\n\ndef _join_confluence_link(data: JsonResponse, key: str) -> str:\n    links = data.get(\"_links\", {})\n    if not isinstance(links, dict):\n        return \"\"\n    base = links.get(\"base\")\n    rel = links.get(key)\n    if not isinstance(base, str) or not isinstance(rel, str) or not base or not rel:\n        return \"\"\n    return f\"{base.rstrip('/')}/{rel.lstrip('/')}\"\n\n\ndef _get_web_url(data: JsonResponse) -> str:\n    return _join_confluence_link(data, \"webui\")\n\n\ndef _get_tiny_url(data: JsonResponse) -> str:\n    return _join_confluence_link(data, \"tinyui\")\n\n\n_JIRA_ROUTE_SEGMENTS = {\n    \"agile\",\n    \"backlog\",\n    \"board\",\n    \"browse\",\n    \"issues\",\n    \"plugins\",\n    \"projects\",\n    \"rest\",\n    \"secure\",\n    \"servicedesk\",\n    \"software\",\n}\n\n_HTML_ELEMENTS = frozenset(\n    {\n        \"a\",\n        \"abbr\",\n        \"acronym\",\n        \"address\",\n        \"area\",\n        \"article\",\n        \"aside\",\n        \"audio\",\n        \"b\",\n        \"base\",\n        \"bdi\",\n        \"bdo\",\n        \"blockquote\",\n        \"body\",\n        \"br\",\n        \"button\",\n        \"canvas\",\n        \"caption\",\n        \"cite\",\n        \"code\",\n        \"col\",\n        \"colgroup\",\n        \"data\",\n        \"datalist\",\n        \"dd\",\n        \"del\",\n        \"details\",\n        \"dfn\",\n        \"dialog\",\n        \"div\",\n        \"dl\",\n        \"dt\",\n        \"em\",\n        \"embed\",\n        \"fieldset\",\n        \"figcaption\",\n        \"figure\",\n        \"font\",\n        \"footer\",\n        \"form\",\n        \"h1\",\n        \"h2\",\n        \"h3\",\n        \"h4\",\n        \"h5\",\n        \"h6\",\n        \"head\",\n        \"header\",\n        \"hgroup\",\n        \"hr\",\n        \"html\",\n        \"i\",\n        \"iframe\",\n        \"img\",\n        \"input\",\n        \"ins\",\n        \"kbd\",\n        \"keygen\",\n        \"label\",\n        \"legend\",\n        \"li\",\n        \"link\",\n        \"main\",\n        \"map\",\n        \"mark\",\n        \"menu\",\n        \"menuitem\",\n        \"meta\",\n        \"meter\",\n        \"nav\",\n        \"noscript\",\n        \"object\",\n        \"ol\",\n        \"optgroup\",\n        \"option\",\n        \"output\",\n        \"p\",\n        \"picture\",\n        \"pre\",\n        \"progress\",\n        \"q\",\n        \"rp\",\n        \"rt\",\n        \"ruby\",\n        \"s\",\n        \"samp\",\n        \"script\",\n        \"section\",\n        \"select\",\n        \"small\",\n        \"source\",\n        \"span\",\n        \"strong\",\n        \"style\",\n        \"sub\",\n        \"summary\",\n        \"sup\",\n        \"table\",\n        \"tbody\",\n        \"td\",\n        \"template\",\n        \"textarea\",\n        \"tfoot\",\n        \"th\",\n        \"thead\",\n        \"time\",\n        \"title\",\n        \"tr\",\n        \"track\",\n        \"u\",\n        \"ul\",\n        \"var\",\n        \"video\",\n        \"wbr\",\n    }\n)\n\n_ANGLE_BRACKET_RE = re.compile(r\"<([^<>\\n]*)>\")\n_CODE_FENCE_RE = re.compile(r\"^(`{3,}|~{3,})\")\n_INLINE_CODE_RE = re.compile(r\"`[^`\\n]*`\")\n_AUTOLINK_URI_RE = re.compile(r\"^[A-Za-z][A-Za-z0-9+.\\-]{1,31}:[^\\s<>]*$\")\n_AUTOLINK_EMAIL_RE = re.compile(\n    r\"^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~\\-]+@[A-Za-z0-9](?:[A-Za-z0-9\\-]{0,61}[A-Za-z0-9])?\"\n    r\"(?:\\.[A-Za-z0-9](?:[A-Za-z0-9\\-]{0,61}[A-Za-z0-9])?)*$\"\n)\n\n\ndef _extract_jira_base_url(url: str) -> str | None:\n    \"\"\"Extract the Jira instance base URL from a Jira issue URL.\n\n    Strips Jira-specific routing segments (e.g. ``browse``) so that the context\n    path is preserved for Server/DC deployments (e.g. ``https://host/jira``),\n    matching the key format used in ``auth.jira`` configuration.\n    Returns ``None`` when *url* is not an absolute URL.\n    \"\"\"\n    parsed = urllib.parse.urlparse(url)\n    if not parsed.scheme or not parsed.hostname:\n        return None\n\n    if gateway := parse_gateway_url(url):\n        return normalize_instance_url(build_gateway_url(*gateway))\n\n    segments = [s for s in parsed.path.split(\"/\") if s]\n    context_parts: list[str] = []\n    for segment in segments:\n        if segment.lower() in _JIRA_ROUTE_SEGMENTS:\n            break\n        context_parts.append(segment)\n\n    base = f\"{parsed.scheme}://{parsed.hostname}\"\n    if parsed.port and parsed.port not in (80, 443):\n        base = f\"{parsed.scheme}://{parsed.hostname}:{parsed.port}\"\n    if context_parts:\n        base = f\"{base}/{'/'.join(context_parts)}\"\n    return normalize_instance_url(base)\n\n\nsettings = get_settings()\n\n\nclass JiraIssue(BaseModel):\n    key: str\n    summary: str\n    description: str | None\n    status: str\n\n    @classmethod\n    def from_json(cls, data: JsonResponse) -> \"JiraIssue\":\n        fields = data.get(\"fields\", {})\n        return cls(\n            key=data.get(\"key\", \"\"),\n            summary=fields.get(\"summary\", \"\"),\n            description=fields.get(\"description\", \"\"),\n            status=fields.get(\"status\", {}).get(\"name\", \"\"),\n        )\n\n    @classmethod\n    def from_key(cls, issue_key: str, jira_url: str) -> \"JiraIssue | None\":\n        \"\"\"Fetch a Jira issue by key.\"\"\"\n        settings = get_settings()\n        if not settings.export.enable_jira_enrichment:\n            return None\n\n        try:\n            return cls._fetch_cached(issue_key, jira_url)\n        except JiraAuthenticationError:\n            handle_jira_auth_failure(jira_url)\n            return None\n\n    @classmethod\n    @functools.lru_cache(maxsize=100)\n    def _fetch_cached(cls, issue_key: str, jira_url: str) -> \"JiraIssue\":\n        jira_instance = get_jira_instance(jira_url)\n        issue_data = cast(\"JsonResponse\", jira_instance.get_issue(issue_key))\n        return cls.from_json(issue_data)\n\n\nclass User(BaseModel):\n    account_id: str\n    username: str\n    display_name: str\n    public_name: str\n    email: str\n\n    @classmethod\n    def from_json(cls, data: JsonResponse) -> \"User\":\n        return cls(\n            account_id=data.get(\"accountId\", \"\"),\n            username=data.get(\"username\", \"\"),\n            display_name=data.get(\"displayName\", \"\"),\n            public_name=data.get(\"publicName\", \"\"),\n            email=data.get(\"email\", \"\"),\n        )\n\n    @classmethod\n    @functools.lru_cache(maxsize=100)\n    def from_username(cls, username: str, base_url: str = \"\") -> \"User\":\n        return cls.from_json(\n            cast(\n                \"JsonResponse\",\n                get_thread_confluence(base_url).get_user_details_by_username(username),\n            )\n        )\n\n    @classmethod\n    @functools.lru_cache(maxsize=100)\n    def from_userkey(cls, userkey: str, base_url: str = \"\") -> \"User\":\n        return cls.from_json(\n            cast(\n                \"JsonResponse\",\n                get_thread_confluence(base_url).get_user_details_by_userkey(userkey),\n            )\n        )\n\n    @classmethod\n    @functools.lru_cache(maxsize=100)\n    def from_accountid(cls, accountid: str, base_url: str = \"\") -> \"User\":\n        return cls.from_json(\n            cast(\n                \"JsonResponse\",\n                get_thread_confluence(base_url).get_user_details_by_accountid(accountid),\n            )\n        )\n\n\nclass Version(BaseModel):\n    number: int\n    by: User\n    when: str\n    friendly_when: str\n\n    @classmethod\n    def from_json(cls, data: JsonResponse) -> \"Version\":\n        return cls(\n            number=data.get(\"number\", 0),\n            by=User.from_json(data.get(\"by\", {})),\n            when=data.get(\"when\", \"\"),\n            friendly_when=data.get(\"friendlyWhen\", \"\"),\n        )\n\n\nclass History(BaseModel):\n    created: str\n    created_by: User\n\n    @classmethod\n    def from_json(cls, data: JsonResponse) -> \"History\":\n        return cls(\n            created=data.get(\"createdDate\", \"\"),\n            created_by=User.from_json(data.get(\"createdBy\", {})),\n        )\n\n\nclass Organization(BaseModel):\n    base_url: str\n    spaces: list[\"Space\"]\n\n    @property\n    def pages(self) -> list[\"Page | Descendant\"]:\n        return [page for space in self.spaces for page in space.pages]\n\n    def export(self) -> None:\n        \"\"\"Export all pages across all spaces, showing per-space discovery progress.\"\"\"\n        all_pages: list[Page | Descendant] = []\n        n = len(self.spaces)\n        logger.info(\"Exporting %d space(s) from %s\", n, self.base_url)\n        with console.status(\"\", spinner=\"dots\") as status:\n            for i, space in enumerate(self.spaces, 1):\n                status.update(\n                    f\"[dim]Fetching pages for space [highlight]{space.name}[/highlight]\"\n                    f\" ({i}/{n})…[/dim]\"\n                )\n                all_pages.extend(space.pages)\n        logger.info(\"Discovered %d page(s) across %d space(s)\", len(all_pages), n)\n        export_pages(all_pages)\n\n    @classmethod\n    def from_json(cls, data: JsonResponse, base_url: str) -> \"Organization\":\n        return cls(\n            base_url=base_url,\n            spaces=[Space.from_json(space, base_url) for space in data.get(\"results\", [])],\n        )\n\n    @classmethod\n    @functools.lru_cache(maxsize=100)\n    def from_url(cls, base_url: str) -> \"Organization\":\n        logger.debug(\"Fetching space list from %s\", base_url)\n        with console.status(\n            f\"[dim]Fetching space list from [highlight]{base_url}[/highlight]…[/dim]\"\n        ):\n            org = cls.from_json(\n                cast(\n                    \"JsonResponse\",\n                    get_thread_confluence(base_url).get_all_spaces(\n                        space_type=\"global\", space_status=\"current\", expand=\"homepage\"\n                    ),\n                ),\n                base_url,\n            )\n        logger.info(\"Found %d space(s) in %s\", len(org.spaces), base_url)\n        return org\n\n\nclass Space(BaseModel):\n    base_url: str\n    key: str\n    name: str\n    description: str\n    homepage: int | None\n\n    @property\n    def pages(self) -> list[\"Page | Descendant\"]:\n        if self.homepage is None:\n            logger.warning(\n                f\"Space '{self.name}' (key: {self.key}) has no homepage. No pages will be exported.\"\n            )\n            return []\n\n        homepage = Page.from_id(self.homepage, self.base_url)\n        return [homepage, *homepage.descendants]\n\n    def export(self) -> None:\n        \"\"\"Export all pages in this space to Markdown.\"\"\"\n        logger.debug(\"Fetching pages for space '%s' (%s)\", self.name, self.key)\n        with console.status(\n            f\"[dim]Fetching pages for space [highlight]{self.name}[/highlight]…[/dim]\"\n        ):\n            pages = self.pages\n        logger.info(\"Found %d page(s) in space '%s'\", len(pages), self.name)\n        export_pages(pages)\n\n    @classmethod\n    def from_json(cls, data: JsonResponse, base_url: str) -> \"Space\":\n        return cls(\n            base_url=base_url,\n            key=data.get(\"key\", \"\"),\n            name=data.get(\"name\", \"\"),\n            description=data.get(\"description\", {}).get(\"plain\", {}).get(\"value\", \"\"),\n            homepage=data.get(\"homepage\", {}).get(\"id\"),\n        )\n\n    @classmethod\n    @functools.lru_cache(maxsize=100)\n    def from_key(cls, space_key: str, base_url: str) -> \"Space\":\n        return cls.from_json(\n            cast(\n                \"JsonResponse\",\n                get_thread_confluence(base_url).get_space(space_key, expand=\"homepage\"),\n            ),\n            base_url,\n        )\n\n    @classmethod\n    def from_url(cls, space_url: str) -> \"Space\":\n        \"\"\"Retrieve a Space object given a Confluence space URL.\n\n        The Confluence instance is selected automatically by matching the URL's\n        hostname against configured instances.  If no match is found, a new\n        entry is registered in the auth config so the user can fill in\n        credentials via the interactive config menu.\n\n        Supports standard instance URLs (``https://company.atlassian.net/wiki/spaces/KEY``)\n        and Atlassian API gateway URLs\n        (``https://api.atlassian.com/ex/confluence/{cloudId}/wiki/spaces/KEY``).\n        \"\"\"\n        base_url = _extract_base_url(space_url)\n\n        # Ensure a client exists (creates/prompts if first time for this host)\n        get_confluence_instance(base_url)\n\n        parsed = urllib.parse.urlparse(space_url)\n        base_path = urllib.parse.urlparse(base_url).path.rstrip(\"/\")\n        relative_path = parsed.path[len(base_path) :]\n        if match := parse_confluence_path(relative_path):\n            if match.space_key:\n                logger.debug(\"Resolved space key '%s' from URL %s\", match.space_key, space_url)\n                return cls.from_key(match.space_key, base_url)\n\n        msg = f\"Could not parse space URL {space_url}.\"\n        raise ValueError(msg)\n\n\nclass Label(BaseModel):\n    id: str\n    name: str\n    prefix: str\n\n    @classmethod\n    def from_json(cls, data: JsonResponse) -> \"Label\":\n        return cls(\n            id=data.get(\"id\", \"\"),\n            name=data.get(\"name\", \"\"),\n            prefix=data.get(\"prefix\", \"\"),\n        )\n\n\nclass Document(BaseModel):\n    base_url: str\n    title: str\n    space: Space\n    ancestors: list[\"Ancestor\"]\n    version: Version\n\n    @property\n    def _template_vars(self) -> dict[str, str]:\n        homepage_id = \"\"\n        homepage_title = \"\"\n        if self.space.homepage:\n            homepage_id = str(self.space.homepage)\n            homepage_title = sanitize_filename(\n                Page.from_id(self.space.homepage, self.base_url).title\n            )\n\n        return {\n            \"space_key\": sanitize_filename(self.space.key),\n            \"space_name\": sanitize_filename(self.space.name),\n            \"homepage_id\": homepage_id,\n            \"homepage_title\": homepage_title,\n            \"ancestor_ids\": \"/\".join(str(a.id) for a in self.ancestors),\n            \"ancestor_titles\": \"/\".join(sanitize_filename(a.title) for a in self.ancestors),\n        }\n\n\nclass Attachment(Document):\n    id: str\n    file_size: int\n    media_type: str\n    media_type_description: str\n    file_id: str\n    collection_name: str\n    download_link: str\n    comment: str\n\n    @property\n    def extension(self) -> str:\n        if self.comment == \"draw.io diagram\" and self.media_type == \"application/vnd.jgraph.mxfile\":\n            return \".drawio\"\n        if self.comment == \"draw.io preview\" and self.media_type == \"image/png\":\n            return \".drawio.png\"\n\n        return mimetypes.guess_extension(self.media_type) or \"\"\n\n    @property\n    def filename(self) -> str:\n        return f\"{self.file_id}{self.extension}\"\n\n    @property\n    def _template_vars(self) -> dict[str, str]:\n        ext = self.extension\n        title = self.title\n        title_without_ext = title[: -len(ext)] if ext and title.endswith(ext) else Path(title).stem\n        return {\n            **super()._template_vars,\n            \"attachment_id\": str(self.id),\n            \"attachment_title\": sanitize_filename(title_without_ext),\n            # file_id is a GUID and does not need sanitization. On\n            # Confluence Data Center / Server the API does not populate\n            # fileId, so fall back to the content id which is always\n            # present and unique.\n            \"attachment_file_id\": self.file_id or str(self.id),\n            \"attachment_extension\": self.extension,\n        }\n\n    @property\n    def export_path(self) -> Path:\n        filepath_template = Template(settings.export.attachment_path.replace(\"{\", \"${\"))\n        return Path(filepath_template.safe_substitute(self._template_vars))\n\n    @classmethod\n    def from_json(cls, data: JsonResponse, base_url: str) -> \"Attachment\":\n        extensions = data.get(\"extensions\", {})\n        container = data.get(\"container\", {})\n        return cls(\n            base_url=base_url,\n            id=data.get(\"id\", \"\"),\n            title=data.get(\"title\", \"\"),\n            space=Space.from_key(\n                data.get(\"_expandable\", {}).get(\"space\", \"\").split(\"/\")[-1], base_url\n            ),\n            file_size=extensions.get(\"fileSize\", 0),\n            media_type=extensions.get(\"mediaType\", \"\"),\n            media_type_description=extensions.get(\"mediaTypeDescription\", \"\"),\n            file_id=extensions.get(\"fileId\", \"\"),\n            collection_name=extensions.get(\"collectionName\", \"\"),\n            download_link=data.get(\"_links\", {}).get(\"download\", \"\"),\n            comment=extensions.get(\"comment\", \"\"),\n            ancestors=[\n                *[\n                    Ancestor.from_json(ancestor, base_url)\n                    for ancestor in container.get(\"ancestors\", [])\n                ],\n                Ancestor.from_json(container, base_url),\n            ][1:],\n            version=Version.from_json(data.get(\"version\", {})),\n        )\n\n    @classmethod\n    def from_page_id(cls, page_id: int, base_url: str) -> list[\"Attachment\"]:\n        attachments = []\n        start = 0\n        paging_limit = 50\n        size = paging_limit  # Initialize to limit to enter the loop\n\n        while size >= paging_limit:\n            response = cast(\n                \"JsonResponse\",\n                get_thread_confluence(base_url).get_attachments_from_content(\n                    page_id,\n                    start=start,\n                    limit=paging_limit,\n                    expand=\"container.ancestors,version\",\n                ),\n            )\n\n            attachments.extend(\n                [cls.from_json(att, base_url) for att in response.get(\"results\", [])]\n            )\n\n            size = response.get(\"size\", 0)\n            start += size\n\n        logger.debug(\"Found %d attachment(s) for page id=%s\", len(attachments), page_id)\n        return attachments\n\n    def export(self) -> None:\n        stats = get_stats()\n        filepath = settings.export.output_path / self.export_path\n        if filepath.exists():\n            logger.debug(\"Skipping attachment '%s' — already exists at %s\", self.title, filepath)\n            return\n\n        logger.debug(\"Downloading attachment '%s' to %s\", self.title, filepath)\n        client = get_thread_confluence(self.base_url)\n        try:\n            response = client.request(\n                method=\"GET\",\n                path=client.url + self.download_link,\n                absolute=True,\n                advanced_mode=True,\n            )\n            response.raise_for_status()  # Raise error if request fails\n        except HTTPError:\n            logger.warning(\"There is no attachment with title '%s'. Skipping export.\", self.title)\n            stats.inc_attachments_failed()\n            return\n        except RequestException as e:\n            logger.warning(\"Failed to download attachment '%s': %s. Skipping.\", self.title, e)\n            stats.inc_attachments_failed()\n            return\n\n        save_file(filepath, response.content)\n        logger.debug(\"Saved attachment '%s' (%d bytes)\", self.title, len(response.content))\n        stats.inc_attachments_exported()\n\n\nclass Ancestor(Document):\n    id: int\n\n    @classmethod\n    def from_json(cls, data: JsonResponse, base_url: str) -> \"Ancestor\":\n        return cls(\n            base_url=base_url,\n            id=data.get(\"id\", 0),\n            title=data.get(\"title\", \"\"),\n            space=Space.from_key(\n                data.get(\"_expandable\", {}).get(\"space\", \"\").split(\"/\")[-1], base_url\n            ),\n            ancestors=[],  # Ancestors of ancestor is not needed for now.\n            version=Version.from_json({}),  # Version of ancestor is not needed for now.\n        )\n\n\nclass Descendant(Document):\n    id: int\n\n    @property\n    def _template_vars(self) -> dict[str, str]:\n        return {\n            **super()._template_vars,\n            \"page_id\": str(self.id),\n            \"page_title\": sanitize_filename(self.title),\n        }\n\n    @property\n    def export_path(self) -> Path:\n        filepath_template = Template(settings.export.page_path.replace(\"{\", \"${\"))\n        return Path(filepath_template.safe_substitute(self._template_vars))\n\n    @classmethod\n    def from_json(cls, data: JsonResponse, base_url: str) -> \"Descendant\":\n        return cls(\n            base_url=base_url,\n            id=data.get(\"id\", 0),\n            title=data.get(\"title\", \"\"),\n            space=Space.from_key(\n                data.get(\"_expandable\", {}).get(\"space\", \"\").split(\"/\")[-1], base_url\n            ),\n            ancestors=[\n                Ancestor.from_json(ancestor, base_url) for ancestor in data.get(\"ancestors\", [])\n            ][1:],\n            version=Version.from_json(data.get(\"version\", {})),\n        )\n\n\ndef _parse_image_captions(storage_xml: str) -> dict[str, str]:\n    \"\"\"Return {filename: caption} parsed from Confluence storage-format XML.\"\"\"\n    captions: dict[str, str] = {}\n    if not storage_xml:\n        return captions\n    for block in re.findall(r\"<ac:image[^>]*>.*?</ac:image>\", storage_xml, re.DOTALL):\n        filename_m = re.search(r'ri:filename=\"([^\"]+)\"', block)\n        if not filename_m:\n            continue\n        caption_m = re.search(r\"<ac:caption[^>]*>(.*?)</ac:caption>\", block, re.DOTALL)\n        if not caption_m:\n            continue\n        caption_content = caption_m.group(1)\n        # CDATA in ac:plain-text-body (older format)\n        cdata_m = re.search(\n            r\"<ac:plain-text-body>\\s*<!\\[CDATA\\[(.*?)\\]\\]>\\s*</ac:plain-text-body>\",\n            caption_content,\n            re.DOTALL,\n        )\n        if cdata_m:\n            caption = cdata_m.group(1).strip()\n        else:\n            # HTML elements in caption (e.g. <p>text</p>) — strip tags\n            caption = BeautifulSoup(caption_content, \"html.parser\").get_text().strip()\n        if caption:\n            captions[filename_m.group(1)] = caption\n    return captions\n\n\nclass Page(Document):\n    id: int\n    type: str = \"\"\n    web_url: str = \"\"\n    tiny_url: str = \"\"\n    body: str\n    body_export: str\n    editor2: str\n    body_storage: str = \"\"\n    labels: list[\"Label\"]\n    attachments: list[\"Attachment\"]\n    history: History = Field(\n        default_factory=lambda: History(created=\"\", created_by=User.from_json({}))\n    )\n\n    @property\n    def descendants(self) -> list[\"Descendant\"]:\n        url = \"rest/api/content/search\"\n        params = {\n            \"cql\": f\"type=page AND ancestor={self.id}\",\n            \"expand\": \"metadata.properties,ancestors,version\",\n            \"limit\": 250,\n        }\n        results = []\n        client = get_thread_confluence(self.base_url)\n\n        try:\n            response = cast(\"dict\", client.get(url, params=params))\n            results.extend(response.get(\"results\", []))\n            next_path = response.get(\"_links\", {}).get(\"next\")\n\n            while next_path:\n                response = cast(\"dict\", client.get(next_path))\n                results.extend(response.get(\"results\", []))\n                next_path = response.get(\"_links\", {}).get(\"next\")\n\n        except HTTPError as e:\n            if e.response.status_code == 404:  # noqa: PLR2004\n                logger.warning(\n                    f\"Content with ID {self.id} not found (404) when fetching descendants.\"\n                )\n                return []\n            return []\n        except Exception:\n            logger.exception(\n                f\"Unexpected error when fetching descendants for content ID {self.id}.\"\n            )\n            return []\n        return [Descendant.from_json(result, self.base_url) for result in results]\n\n    @property\n    def _template_vars(self) -> dict[str, str]:\n        return {\n            **super()._template_vars,\n            \"page_id\": str(self.id),\n            \"page_title\": sanitize_filename(self.title),\n        }\n\n    @property\n    def export_path(self) -> Path:\n        filepath_template = Template(settings.export.page_path.replace(\"{\", \"${\"))\n        return Path(filepath_template.safe_substitute(self._template_vars))\n\n    @property\n    def html(self) -> str:\n        if settings.export.include_document_title:\n            return f\"<h1>{self.title}</h1>{self.body}\"\n        return self.body\n\n    @property\n    def markdown(self) -> str:\n        return self.Converter(self).markdown\n\n    def export(self) -> dict[str, AttachmentEntry]:\n        if self.title == \"Page not accessible\":\n            logger.warning(\"Skipping export for inaccessible page id=%s\", self.id)\n            return {}\n\n        logger.debug(\"Exporting page id=%s '%s'\", self.id, self.title)\n        if settings.export.log_level == \"DEBUG\":\n            self.export_body()\n        # Export attachments first so the files can be utilized during markdown conversion\n        logger.debug(\"Exporting attachments for page id=%s\", self.id)\n        attachment_entries = self.export_attachments()\n        logger.debug(\"Converting to Markdown for page id=%s\", self.id)\n        self.export_markdown()\n        if settings.export.comments_export != \"none\":\n            logger.debug(\"Exporting comments for page id=%s\", self.id)\n            self.export_comments_sidecar()\n        logger.info(\n            \"Exported '%s' -> %s\", self.title, settings.export.output_path / self.export_path\n        )\n        return attachment_entries\n\n    def export_with_descendants(self) -> None:\n        with console.status(\n            f\"[dim]Fetching descendants of [highlight]{self.title}[/highlight]…[/dim]\"\n        ):\n            pages = [self, *self.descendants]\n        export_pages(pages)\n\n    def export_body(self) -> None:\n        soup = BeautifulSoup(self.html, \"html.parser\")\n        save_file(\n            settings.export.output_path\n            / self.export_path.parent\n            / f\"{self.export_path.stem}_body_view.html\",\n            str(soup.prettify()),\n        )\n        soup = BeautifulSoup(self.body_export, \"html.parser\")\n        save_file(\n            settings.export.output_path\n            / self.export_path.parent\n            / f\"{self.export_path.stem}_body_export_view.html\",\n            str(soup.prettify()),\n        )\n        save_file(\n            settings.export.output_path\n            / self.export_path.parent\n            / f\"{self.export_path.stem}_body_editor2.xml\",\n            str(self.editor2),\n        )\n\n    def export_markdown(self) -> None:\n        conv = self.Converter(self)\n        save_file(\n            settings.export.output_path / self.export_path,\n            conv.markdown,\n        )\n        self._marked_texts: dict[str, str] = conv._marked_texts\n\n    _COMMENT_TITLE_MAX_LEN = 60\n\n    def _fetch_inline_comments(self) -> list[dict]:\n        client = get_thread_confluence(self.base_url)\n        results: list[dict] = []\n        try:\n            resp = cast(\n                \"dict\",\n                client.get_page_comments(\n                    self.id,\n                    location=\"inline\",\n                    expand=\"extensions.inlineProperties,extensions.resolution,body.view,history.createdBy\",\n                    limit=50,\n                ),\n            )\n            for comment in resp.get(\"results\", []):\n                status = comment.get(\"extensions\", {}).get(\"resolution\", {}).get(\"status\", \"open\")\n                if status == \"open\":\n                    results.append(comment)\n            next_path = resp.get(\"_links\", {}).get(\"next\")\n            while next_path:\n                resp = cast(\"dict\", client.get(next_path))\n                for comment in resp.get(\"results\", []):\n                    status = (\n                        comment.get(\"extensions\", {}).get(\"resolution\", {}).get(\"status\", \"open\")\n                    )\n                    if status == \"open\":\n                        results.append(comment)\n                next_path = resp.get(\"_links\", {}).get(\"next\")\n        except Exception:  # noqa: BLE001\n            logger.warning(\"Failed to fetch inline comments for page id=%s\", self.id)\n        return results\n\n    def _fetch_page_comments(self) -> list[dict]:\n        client = get_thread_confluence(self.base_url)\n        results: list[dict] = []\n        try:\n            resp = cast(\n                \"dict\",\n                client.get_page_comments(\n                    self.id,\n                    location=\"footer\",\n                    expand=\"extensions.resolution,body.view,history.createdBy\",\n                    limit=50,\n                ),\n            )\n            for comment in resp.get(\"results\", []):\n                status = comment.get(\"extensions\", {}).get(\"resolution\", {}).get(\"status\", \"open\")\n                if status == \"open\":\n                    results.append(comment)\n            next_path = resp.get(\"_links\", {}).get(\"next\")\n            while next_path:\n                resp = cast(\"dict\", client.get(next_path))\n                for comment in resp.get(\"results\", []):\n                    status = (\n                        comment.get(\"extensions\", {}).get(\"resolution\", {}).get(\"status\", \"open\")\n                    )\n                    if status == \"open\":\n                        results.append(comment)\n                next_path = resp.get(\"_links\", {}).get(\"next\")\n        except Exception:  # noqa: BLE001\n            logger.warning(\"Failed to fetch page comments for page id=%s\", self.id)\n        return results\n\n    def _fetch_comment_replies(self, comment_id: str) -> list[dict]:\n        client = get_thread_confluence(self.base_url)\n        try:\n            resp = cast(\n                \"dict\",\n                client.get(\n                    f\"rest/api/content/{comment_id}/child/comment\",\n                    params={\"expand\": \"body.view,history.createdBy\", \"limit\": 50},\n                ),\n            )\n            return resp.get(\"results\", [])\n        except Exception:  # noqa: BLE001\n            return []\n\n    def export_comments_sidecar(self) -> None:\n        mode = settings.export.comments_export\n        inline = self._fetch_inline_comments() if mode in (\"inline\", \"all\") else []\n        page = self._fetch_page_comments() if mode in (\"footer\", \"all\") else []\n        if not inline and not page:\n            return\n\n        source_url = f\"{self.base_url}/wiki/spaces/{self.space.key}/pages/{self.id}\"\n\n        lines: list[str] = [\n            \"---\",\n            f\"confluence_page_id: '{self.id}'\",\n            f'confluence_page_title: \"{self.title}\"',\n            f'confluence_webui_url: \"{source_url}\"',\n            \"---\",\n            \"\",\n        ]\n\n        if inline:\n            lines.append(\"## Inline comments\")\n            lines.append(\"\")\n            self._render_inline_comments(lines, inline)\n\n        if page:\n            lines.append(\"## Page comments\")\n            lines.append(\"\")\n            self._render_page_comments(lines, page)\n\n        save_file(\n            settings.export.output_path\n            / self.export_path.parent\n            / f\"{self.export_path.stem}.comments.md\",\n            \"\\n\".join(lines),\n        )\n\n    def _render_inline_comments(self, lines: list[str], comments: list[dict]) -> None:\n        for comment in comments:\n            ref = comment.get(\"extensions\", {}).get(\"inlineProperties\", {}).get(\"markerRef\", \"\")\n            marked_md = self._marked_texts.get(ref, \"\")\n\n            plain = re.sub(r\"\\s+\", \" \", marked_md).strip()\n            n = self._COMMENT_TITLE_MAX_LEN\n            short_title = plain[:n] + \"…\" if len(plain) > n else plain\n            if not short_title:\n                short_title = f\"Comment {ref[:8]}\"\n            lines.append(f\"### {short_title}\")\n            lines.append(\"\")\n\n            if marked_md:\n                lines.extend(\n                    f\"> {line}\" if line.strip() else \">\" for line in marked_md.splitlines()\n                )\n                lines.append(\"\")\n\n            author = comment.get(\"history\", {}).get(\"createdBy\", {}).get(\"displayName\", \"Unknown\")\n            created = comment.get(\"history\", {}).get(\"createdDate\", \"\")[:10]\n            body_md = (\n                MarkdownConverter()\n                .convert(comment.get(\"body\", {}).get(\"view\", {}).get(\"value\", \"\"))\n                .strip()\n            )\n\n            lines.append(f\"**{author}** · {created}\")\n            lines.append(\"\")\n            if body_md:\n                lines.append(body_md)\n                lines.append(\"\")\n\n            for reply in self._fetch_comment_replies(comment[\"id\"]):\n                r_author = (\n                    reply.get(\"history\", {}).get(\"createdBy\", {}).get(\"displayName\", \"Unknown\")\n                )\n                r_created = reply.get(\"history\", {}).get(\"createdDate\", \"\")[:10]\n                r_body_md = (\n                    MarkdownConverter()\n                    .convert(reply.get(\"body\", {}).get(\"view\", {}).get(\"value\", \"\"))\n                    .strip()\n                )\n                lines.append(f\"**{r_author}** · {r_created}\")\n                lines.append(\"\")\n                if r_body_md:\n                    lines.append(r_body_md)\n                    lines.append(\"\")\n\n    def _render_page_comments(self, lines: list[str], comments: list[dict]) -> None:\n        for comment in comments:\n            body_md = (\n                MarkdownConverter()\n                .convert(comment.get(\"body\", {}).get(\"view\", {}).get(\"value\", \"\"))\n                .strip()\n            )\n\n            plain = re.sub(r\"\\s+\", \" \", body_md).strip()\n            n = self._COMMENT_TITLE_MAX_LEN\n            short_title = plain[:n] + \"…\" if len(plain) > n else plain\n            if not short_title:\n                short_title = f\"Comment {str(comment.get('id', ''))[:8]}\"\n            lines.append(f\"### {short_title}\")\n            lines.append(\"\")\n\n            author = comment.get(\"history\", {}).get(\"createdBy\", {}).get(\"displayName\", \"Unknown\")\n            created = comment.get(\"history\", {}).get(\"createdDate\", \"\")[:10]\n            lines.append(f\"**{author}** · {created}\")\n            lines.append(\"\")\n            if body_md:\n                lines.append(body_md)\n                lines.append(\"\")\n\n            for reply in self._fetch_comment_replies(comment[\"id\"]):\n                r_author = (\n                    reply.get(\"history\", {}).get(\"createdBy\", {}).get(\"displayName\", \"Unknown\")\n                )\n                r_created = reply.get(\"history\", {}).get(\"createdDate\", \"\")[:10]\n                r_body_md = (\n                    MarkdownConverter()\n                    .convert(reply.get(\"body\", {}).get(\"view\", {}).get(\"value\", \"\"))\n                    .strip()\n                )\n                lines.append(f\"**{r_author}** · {r_created}\")\n                lines.append(\"\")\n                if r_body_md:\n                    lines.append(r_body_md)\n                    lines.append(\"\")\n\n    def _attachments_for_export(self) -> list[\"Attachment\"]:\n        \"\"\"Return the subset of attachments that should be exported for this page.\"\"\"\n        if settings.export.attachments_export == \"all\":\n            return list(self.attachments)\n        bodies = self.body + self.body_export\n        return [\n            a\n            for a in self.attachments\n            if (a.filename.endswith(\".drawio\") and f\"diagramName={a.title}\" in self.body)\n            or (\n                a.filename.endswith((\".drawio.png\", \".drawio\"))\n                and a.title.replace(\" \", \"%20\") in self.body_export\n            )\n            or a.file_id in bodies\n            or a.id in bodies\n            or a.title in bodies\n            or a.title.replace(\" \", \"%20\") in bodies\n        ]\n\n    def export_attachments(self) -> dict[str, AttachmentEntry]:\n        if settings.export.attachments_export == \"disabled\":\n            logger.debug(\"Attachment download disabled for page id=%s\", self.id)\n            return {}\n        old_entries = LockfileManager.get_page_attachment_entries(str(self.id))\n        new_entries: dict[str, AttachmentEntry] = {}\n        output_path = settings.export.output_path\n        stats = get_stats()\n\n        for attachment in self._attachments_for_export():\n            att_id = attachment.id\n            att_version = attachment.version.number if attachment.version else 0\n\n            # Skip download if the same attachment version is tracked and the file still exists\n            if att_id in old_entries:\n                old = old_entries[att_id]\n                if old.version == att_version and (output_path / old.path).exists():\n                    new_entries[att_id] = old\n                    logger.debug(\n                        \"Skipping unchanged attachment '%s' (v%d)\", attachment.title, att_version\n                    )\n                    stats.inc_attachments_skipped()\n                    continue\n\n            attachment.export()\n            if att_version:\n                new_entries[att_id] = AttachmentEntry(\n                    version=att_version, path=str(attachment.export_path)\n                )\n\n        # Clean up orphaned attachment files when an attachment was re-versioned\n        for att_id, old_entry in old_entries.items():\n            if att_id in new_entries and old_entry.path != new_entries[att_id].path:\n                old_file = output_path / old_entry.path\n                old_file.unlink(missing_ok=True)\n                logger.info(\"Deleted old attachment file: %s\", old_entry.path)\n                stats.inc_attachments_removed()\n\n        return new_entries\n\n    def get_attachment_by_id(self, attachment_id: str) -> Attachment | None:\n        \"\"\"Get the Attachment object by its ID.\n\n        Confluence Server sometimes stores attachments without a file_id.\n        Fall back to the plain attachment.id and return None if nothing matches.\n        \"\"\"\n        for a in self.attachments:\n            if attachment_id in a.id:\n                return a\n            if a.file_id and attachment_id in a.file_id:\n                return a\n        return None\n\n    def get_attachment_by_file_id(self, file_id: str) -> Attachment | None:\n        for a in self.attachments:\n            if a.file_id and file_id in a.file_id:\n                return a\n        return None\n\n    def get_attachments_by_title(self, title: str) -> list[Attachment]:\n        return [attachment for attachment in self.attachments if attachment.title == title]\n\n    @classmethod\n    def from_json(cls, data: JsonResponse, base_url: str) -> \"Page\":\n        return cls(\n            base_url=base_url,\n            id=data.get(\"id\", 0),\n            type=data.get(\"type\", \"\"),\n            web_url=_get_web_url(data),\n            tiny_url=_get_tiny_url(data),\n            title=data.get(\"title\", \"\"),\n            space=Space.from_key(\n                data.get(\"_expandable\", {}).get(\"space\", \"\").split(\"/\")[-1], base_url\n            ),\n            body=data.get(\"body\", {}).get(\"view\", {}).get(\"value\", \"\"),\n            body_export=data.get(\"body\", {}).get(\"export_view\", {}).get(\"value\", \"\"),\n            editor2=data.get(\"body\", {}).get(\"editor2\", {}).get(\"value\", \"\"),\n            body_storage=data.get(\"body\", {}).get(\"storage\", {}).get(\"value\", \"\"),\n            labels=[\n                Label.from_json(label)\n                for label in data.get(\"metadata\", {}).get(\"labels\", {}).get(\"results\", [])\n            ],\n            attachments=Attachment.from_page_id(data.get(\"id\", 0), base_url),\n            ancestors=[\n                Ancestor.from_json(ancestor, base_url) for ancestor in data.get(\"ancestors\", [])\n            ][1:],\n            version=Version.from_json(data.get(\"version\", {})),\n            history=History.from_json(data.get(\"history\", {})),\n        )\n\n    @classmethod\n    @functools.lru_cache(maxsize=1000)\n    def from_id(cls, page_id: int, base_url: str) -> \"Page\":\n        _empty_space = Space(base_url=base_url, key=\"\", name=\"\", description=\"\", homepage=0)\n        if page_id is None:\n            logger.warning(\"Page ID is None, returning empty page\")\n            return cls(\n                base_url=base_url,\n                id=0,\n                title=\"Page not accessible\",\n                space=_empty_space,\n                body=\"\",\n                body_export=\"\",\n                editor2=\"\",\n                labels=[],\n                attachments=[],\n                ancestors=[],\n            )\n        logger.debug(\"Fetching page id=%s from %s\", page_id, base_url)\n        expand = (\n            \"body.view,body.export_view,body.editor2,body.storage,metadata.labels,\"\n            \"metadata.properties,ancestors,version,history,history.createdBy\"\n        )\n        try:\n            return cls.from_json(\n                _require_dict(\n                    get_thread_confluence(base_url).get_page_by_id(\n                        page_id,\n                        expand=expand,\n                    ),\n                    f\"page id={page_id} at {base_url}\",\n                ),\n                base_url,\n            )\n        except (ApiError, HTTPError):\n            logger.warning(\"Could not access page id=%s — treating as inaccessible\", page_id)\n            return cls(\n                base_url=base_url,\n                id=page_id,\n                title=\"Page not accessible\",\n                space=_empty_space,\n                body=\"\",\n                body_export=\"\",\n                editor2=\"\",\n                labels=[],\n                attachments=[],\n                ancestors=[],\n                version=Version.from_json({}),\n            )\n\n    @classmethod\n    def from_url(cls, page_url: str) -> \"Page\":\n        \"\"\"Retrieve a Page object given a Confluence page URL.\n\n        The Confluence instance is selected automatically by matching the URL's\n        hostname against configured instances.  If no match is found, a new\n        entry is registered in the auth config so the user can fill in\n        credentials via the interactive config menu.\n\n        Supports standard instance URLs and Atlassian API gateway URLs of the form\n        ``https://api.atlassian.com/ex/confluence/{cloudId}/wiki/spaces/KEY/pages/123``.\n        \"\"\"\n        base_url = _extract_base_url(page_url)\n\n        # Ensure a client exists (creates/prompts if first time for this host)\n        get_confluence_instance(base_url)\n\n        parsed = urllib.parse.urlparse(page_url)\n        query_params = urllib.parse.parse_qs(parsed.query)\n        page_id_param = next(\n            (\n                values[0]\n                for key, values in query_params.items()\n                if key.lower() == \"pageid\" and values and values[0]\n            ),\n            None,\n        )\n        if page_id_param and page_id_param.isdigit():\n            page_id = int(page_id_param)\n            logger.debug(\n                \"Resolved page id=%s from Confluence query string in URL %s\", page_id, page_url\n            )\n            return Page.from_id(page_id, base_url)\n\n        base_path = urllib.parse.urlparse(base_url).path.rstrip(\"/\")\n        relative_path = parsed.path[len(base_path) :]\n        if match := parse_confluence_path(relative_path):\n            if match.page_id:\n                logger.debug(\"Resolved page id=%s from Confluence URL %s\", match.page_id, page_url)\n                return Page.from_id(match.page_id, base_url)\n\n            if match.space_key and match.page_title:\n                logger.debug(\n                    \"Resolving page '%s' in space '%s' from Confluence URL %s\",\n                    match.page_title,\n                    match.space_key,\n                    page_url,\n                )\n                page_data = _require_dict(\n                    get_thread_confluence(base_url).get_page_by_title(\n                        space=match.space_key, title=match.page_title, expand=\"version\"\n                    ),\n                    f\"page title={match.page_title!r} space={match.space_key!r} at {base_url}\",\n                )\n                return Page.from_id(page_data[\"id\"], base_url)\n\n        msg = f\"Could not parse page URL {page_url}.\"\n        raise ValueError(msg)\n\n    class Converter(TableConverter, MarkdownConverter):\n        \"\"\"Create a custom MarkdownConverter for Confluence HTML to Markdown conversion.\"\"\"\n\n        class Options(MarkdownConverter.DefaultOptions):  # type: ignore[assignment]\n            bullets = \"-\"\n            heading_style = ATX\n            macros_to_ignore: Set[str] = frozenset([\"qc-read-and-understood-signature-box\"])\n            front_matter_indent = 2\n\n        def __init__(self, page: \"Page\", **options) -> None:  # noqa: ANN003\n            super().__init__(**options)\n            self.page = page\n            self.page_properties = {}\n            self._marked_texts: dict[str, str] = {}\n            self._colorid_map_cache: dict[str, str] | None = None\n            self._image_captions_cache: dict[str, str] | None = None\n            self._panel_icon_map_cache: dict[str, str] | None = None\n            self._plantuml_index: int = 0\n            self._storage_plantuml_macros_cache: list[Tag] | None = None\n\n        @property\n        def _colorid_map(self) -> dict[str, str]:\n            if self._colorid_map_cache is None:\n                cache: dict[str, str] = {}\n                soup = BeautifulSoup(self.page.html, \"html.parser\")\n                for style_tag in soup.find_all(\"style\"):\n                    css = style_tag.get_text()\n                    for m in _RE_COLORID_CSS.finditer(css):\n                        color_id = m.group(1)\n                        if color_id not in cache:\n                            cache[color_id] = m.group(2)\n                self._colorid_map_cache = cache\n            return self._colorid_map_cache\n\n        @property\n        def _storage_plantuml_macros(self) -> list[Tag]:\n            \"\"\"Cache and return all PlantUML structured-macros from body.storage.\"\"\"\n            if self._storage_plantuml_macros_cache is None:\n                macros: list[Tag] = []\n                if self.page.body_storage:\n                    wrapped = f\"<root>{self.page.body_storage}</root>\"\n                    soup = BeautifulSoup(wrapped, \"xml\")\n                    macros.extend(\n                        macro\n                        for macro in soup.find_all(\"structured-macro\")\n                        if isinstance(macro, Tag) and macro.get(\"name\") == \"plantuml\"\n                    )\n                self._storage_plantuml_macros_cache = macros\n            return self._storage_plantuml_macros_cache\n\n        @property\n        def _image_captions(self) -> dict[str, str]:\n            if self._image_captions_cache is None:\n                self._image_captions_cache = _parse_image_captions(self.page.body_storage)\n            return self._image_captions_cache\n\n        @property\n        def _panel_icon_map(self) -> dict[str, str]:\n            \"\"\"Map panel macro-id to its custom icon emoji from editor2 XML.\"\"\"\n            if self._panel_icon_map_cache is None:\n                cache: dict[str, str] = {}\n                if self.page.editor2:\n                    wrapped = f\"<root>{self.page.editor2}</root>\"\n                    soup = BeautifulSoup(wrapped, \"xml\")\n                    panel_names = {\"panel\", \"info\", \"note\", \"tip\", \"warning\"}\n                    for macro in soup.find_all(\"structured-macro\"):\n                        if not isinstance(macro, Tag):\n                            continue\n                        if macro.get(\"name\") not in panel_names:\n                            continue\n                        macro_id = macro.get(\"macro-id\")\n                        if not macro_id:\n                            continue\n                        emoji = self._extract_panel_emoji(macro)\n                        if emoji:\n                            cache[str(macro_id)] = emoji\n                self._panel_icon_map_cache = cache\n            return self._panel_icon_map_cache\n\n        @staticmethod\n        def _extract_panel_emoji(macro: Tag) -> str | None:\n            params: dict[str, str] = {}\n            for p in macro.find_all(\"parameter\", recursive=False):\n                if not isinstance(p, Tag):\n                    continue\n                name = p.get(\"name\")\n                if name:\n                    params[str(name)] = p.get_text(strip=True)\n            if text := params.get(\"panelIconText\"):\n                return text\n            if icon_id := params.get(\"panelIconId\"):\n                try:\n                    cps = [int(cp, 16) for cp in icon_id.split(\"-\")]\n                    if all(0 <= cp <= _MAX_UNICODE_CODEPOINT for cp in cps):\n                        return \"\".join(chr(cp) for cp in cps)\n                except (OverflowError, ValueError):\n                    pass\n            return None\n\n        @property\n        def markdown(self) -> str:\n            html = self._strip_excerpt_include_panel_titles(self.page.html)\n            md_body = self.convert(html)\n            md_body = self._escape_template_placeholders(md_body)\n            markdown = f\"{self.front_matter}\\n\"\n            if settings.export.page_breadcrumbs:\n                markdown += f\"{self.breadcrumbs}\\n\"\n            markdown += f\"{md_body}\\n\"\n            return markdown\n\n        @property\n        def front_matter(self) -> str:\n            indent = self.options[\"front_matter_indent\"]\n            self.set_page_properties(tags=self.labels)\n            self._add_confluence_url_properties()\n            self._add_page_metadata_properties()\n\n            if not self.page_properties:\n                return \"\"\n\n            yml = yaml.dump(self.page_properties, indent=indent).strip()\n            # Indent the root level list items\n            yml = re.sub(r\"^( *)(- )\", r\"\\1\" + \" \" * indent + r\"\\2\", yml, flags=re.MULTILINE)\n            return f\"---\\n{yml}\\n---\\n\"\n\n        def _add_confluence_url_properties(self) -> None:\n            mode = settings.export.confluence_url_in_frontmatter\n            if mode == \"none\":\n                return\n\n            if mode in (\"webui\", \"both\") and self.page.web_url:\n                key = sanitize_key(\"confluence_webui_url\")\n                if key not in self.page_properties:\n                    self.page_properties[key] = self.page.web_url\n\n            if mode in (\"tinyui\", \"both\") and self.page.tiny_url:\n                key = sanitize_key(\"confluence_tinyui_url\")\n                if key not in self.page_properties:\n                    self.page_properties[key] = self.page.tiny_url\n\n        def _add_page_metadata_properties(self) -> None:\n            if not settings.export.page_metadata_in_frontmatter:\n                return\n\n            page = self.page\n            version = page.version\n            history = page.history\n            metadata = {\n                # Stored as str to stay JS-safe-integer compatible: Confluence\n                # Cloud page IDs can exceed 2^53, which JS-based SSGs (Hugo,\n                # Astro, ...) parsing the front matter would silently truncate.\n                \"confluence_page_id\": str(page.id),\n                \"confluence_space_key\": page.space.key,\n                \"confluence_type\": page.type,\n                \"confluence_created\": history.created,\n                \"confluence_created_by\": history.created_by.display_name,\n                \"confluence_last_modified\": version.when,\n                \"confluence_last_modified_by\": version.by.display_name,\n                \"confluence_version\": version.number,\n            }\n            for raw_key, value in metadata.items():\n                if value in (None, \"\", 0):\n                    continue\n                key = sanitize_key(raw_key)\n                if key not in self.page_properties:\n                    self.page_properties[key] = value\n\n        @property\n        def breadcrumbs(self) -> str:\n            return (\n                \" > \".join(\n                    [self.convert_page_link(ancestor.id) for ancestor in self.page.ancestors]\n                )\n                + \"\\n\"\n            )\n\n        @property\n        def labels(self) -> list[str]:\n            return [label.name for label in self.page.labels]\n\n        def set_page_properties(self, **props: list[str] | str | None) -> None:\n            for key, value in props.items():\n                if value:\n                    self.page_properties[sanitize_key(key)] = value\n\n        def convert_page_properties(\n            self, el: BeautifulSoup, text: str, parent_tags: list[str]\n        ) -> str | None:\n            fmt = settings.export.page_properties_format\n\n            if fmt == \"table\":\n                return text\n\n            rows = [\n                cast(\"list[Tag]\", tr.find_all([\"th\", \"td\"]))\n                for tr in cast(\"list[Tag]\", el.find_all(\"tr\"))\n                if tr\n            ]\n            if not rows:\n                return None\n\n            props: dict[str, str] = {}\n            key_counts: dict[str, int] = {}\n            for row in rows:\n                if len(row) == 2:  # noqa: PLR2004\n                    raw_key = row[0].get_text(strip=True)\n                    count = key_counts.get(raw_key, 0) + 1\n                    key_counts[raw_key] = count\n                    unique_key = raw_key if count == 1 else f\"{raw_key} {count}\"\n                    props[unique_key] = self.convert(str(row[1])).strip()\n\n            if fmt in (\"frontmatter\", \"frontmatter_and_table\", \"meta-bind-view-fields\"):\n                self.set_page_properties(**props)\n\n            if fmt == \"frontmatter\":\n                return None\n\n            if fmt == \"frontmatter_and_table\":\n                return text\n\n            if fmt == \"dataview-inline-field\":\n                lines = \"\\n\".join(f\"{k}:: {v}\" for k, v in props.items())\n                return f\"\\n{lines}\\n\"\n\n            # meta-bind-view-fields: two-column table with VIEW fields in value column\n            table_data = [\n                (f\"**{k}**\", f\"`VIEW[{{{sanitize_key(k)}}}][text(renderMarkdown)]`\") for k in props\n            ]\n            return \"\\n\\n\" + tabulate(table_data, headers=[\"\", \"\"], tablefmt=\"pipe\") + \"\\n\"\n\n        def convert_alert(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:\n            \"\"\"Convert Confluence info macros to Markdown GitHub style alerts.\n\n            GitHub specific alert types: https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts\n\n            Inside table cells GitHub alerts don't render in most viewers\n            (Obsidian, etc.), so emit a leading emoji + plain text instead.\n            \"\"\"\n            alert_type_map = {\n                \"info\": \"IMPORTANT\",\n                \"panel\": \"NOTE\",\n                \"tip\": \"TIP\",\n                \"note\": \"WARNING\",\n                \"warning\": \"CAUTION\",\n            }\n            alert_emoji_map = {\n                \"NOTE\": \"\\U0001f4dd\",\n                \"TIP\": \"\\U0001f4a1\",\n                \"IMPORTANT\": \"❗\",\n                \"WARNING\": \"⚠️\",\n                \"CAUTION\": \"\\U0001f6d1\",\n            }\n\n            alert_type = alert_type_map.get(str(el[\"data-macro-name\"]), \"NOTE\")\n\n            macro_id = el.get(\"data-macro-id\")\n            custom_emoji = self._panel_icon_map.get(str(macro_id)) if macro_id else None\n            emoji = custom_emoji or alert_emoji_map[alert_type]\n\n            tags = parent_tags if isinstance(parent_tags, list | set) else set()\n            if \"td\" in tags or \"th\" in tags:\n                return f\"{emoji} {text.strip()}\"\n\n            blockquote = super().convert_blockquote(el, text, parent_tags)\n            return f\"\\n> [!{alert_type}]{blockquote}\"\n\n        def convert_div(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:\n            # Handle Confluence macros\n            if el.has_attr(\"data-macro-name\"):\n                macro_name = str(el[\"data-macro-name\"])\n                if macro_name in self.options[\"macros_to_ignore\"]:\n                    return \"\"\n\n                macro_handlers = {\n                    \"panel\": self.convert_alert,\n                    \"info\": self.convert_alert,\n                    \"note\": self.convert_alert,\n                    \"tip\": self.convert_alert,\n                    \"warning\": self.convert_alert,\n                    \"details\": self.convert_page_properties,\n                    \"drawio\": self.convert_drawio,\n                    \"plantuml\": self.convert_plantuml,\n                    \"scroll-ignore\": self.convert_hidden_content,\n                    \"toc\": self.convert_toc,\n                    \"jira\": self.convert_jira_table,\n                    \"attachments\": self.convert_attachments,\n                    \"markdown\": self.convert_markdown,\n                    \"mohamicorp-markdown\": self.convert_markdown,\n                    \"include\": self.convert_include,\n                    \"excerpt-include\": self.convert_include,\n                }\n                if macro_name in macro_handlers:\n                    return macro_handlers[macro_name](el, text, parent_tags)\n\n            class_handlers = {\n                \"expand-container\": self.convert_expand_container,\n                \"columnLayout\": self.convert_column_layout,\n            }\n            for class_name, handler in class_handlers.items():\n                if class_name in str(el.get(\"class\", \"\")):\n                    return handler(el, text, parent_tags)\n\n            return super().convert_div(el, text, parent_tags)\n\n        def convert_expand_container(\n            self, el: BeautifulSoup, text: str, parent_tags: list[str]\n        ) -> str:\n            \"\"\"Convert expand-container div to HTML details element.\"\"\"\n            # Extract summary text from expand-control-text\n            summary_element = el.find(\"span\", class_=\"expand-control-text\")\n            summary_text = (\n                summary_element.get_text().strip() if summary_element else \"Click here to expand...\"\n            )\n\n            # Extract content from expand-content\n            content_element = el.find(\"div\", class_=\"expand-content\")\n            # Recursively convert the content\n            content = (\n                self.process_tag(content_element, parent_tags).strip() if content_element else \"\"\n            )\n\n            # Return as details element\n            return f\"\\n<details>\\n<summary>{summary_text}</summary>\\n\\n{content}\\n\\n</details>\\n\\n\"\n\n        def _span_highlight(self, style: str, text: str) -> str | None:\n            bg_m = _RE_RGB_BG.search(style)\n            if not bg_m:\n                return None\n            hex_color = _rgb_to_hex(int(bg_m.group(1)), int(bg_m.group(2)), int(bg_m.group(3)))\n            return f'<mark style=\"background: {hex_color};\">{text}</mark>'\n\n        def _wrap_cell_highlight(self, el: BeautifulSoup, text: str) -> str:\n            if not settings.export.convert_text_highlights:\n                return text\n            bg = _extract_cell_highlight_hex(el)\n            if bg is None:\n                return text\n            inner = text or \"&nbsp;\"\n            return f'<mark style=\"background: {bg};\">{inner}</mark>'\n\n        def convert_td(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:\n            text = super().convert_td(el, text, parent_tags)\n            return self._wrap_cell_highlight(el, text)\n\n        def convert_th(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:\n            text = super().convert_th(el, text, parent_tags)\n            return self._wrap_cell_highlight(el, text)\n\n        def _span_font_color(self, el: BeautifulSoup, style: str, text: str) -> str | None:\n            color_m = _RE_RGB_COLOR.search(style)\n            if color_m:\n                hex_color = _rgb_to_hex(\n                    int(color_m.group(1)), int(color_m.group(2)), int(color_m.group(3))\n                )\n                return f'<font style=\"color: {hex_color};\">{text}</font>'\n            color_id = el.get(\"data-colorid\")\n            if isinstance(color_id, str):\n                hex_color = self._colorid_map.get(color_id)\n                if hex_color:\n                    return f'<font style=\"color: {hex_color};\">{text}</font>'\n            return None\n\n        def _span_status_badge(self, el: BeautifulSoup, text: str) -> str | None:\n            if not settings.export.convert_status_badges:\n                return None\n            classes = el.get(\"class\") or []\n            if not isinstance(classes, list):\n                return None\n            if \"status-macro\" not in classes:\n                return None\n            bg = \"#dfe1e6\"  # default gray\n            for cls, color in _LOZENGE_COLORS.items():\n                if cls in classes:\n                    bg = color\n                    break\n            return f'<mark style=\"background: {bg};\">{text.strip()}</mark>'\n\n        def convert_span(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:  # noqa: C901, PLR0911\n            if el.has_attr(\"data-macro-name\"):\n                if el[\"data-macro-name\"] == \"jira\":\n                    return self.convert_jira_issue(el, text, parent_tags)\n                if el[\"data-macro-name\"] == \"status\":\n                    result = self._span_status_badge(el, text)\n                    if result is not None:\n                        return result\n                if el[\"data-macro-name\"] == \"plantuml\":\n                    return self.convert_plantuml(el, text, parent_tags)\n\n            if el.has_attr(\"class\") and \"inline-comment-marker\" in el[\"class\"]:\n                return self.convert_inline_comment_marker(el, text, parent_tags)\n\n            raw_style = el.get(\"style\", \"\")\n            style = raw_style if isinstance(raw_style, str) else \"\"\n            if settings.export.convert_text_highlights:\n                result = self._span_highlight(style, text)\n                if result is not None:\n                    return result\n\n            if settings.export.convert_font_colors:\n                result = self._span_font_color(el, style, text)\n                if result is not None:\n                    return result\n\n            return text\n\n        def convert_inline_comment_marker(\n            self, el: BeautifulSoup, text: str, _parent_tags: list[str]\n        ) -> str:\n            if settings.export.comments_export in (\"inline\", \"all\"):\n                ref = el.get(\"data-ref\", \"\")\n                if isinstance(ref, str) and ref and ref not in self._marked_texts:\n                    self._marked_texts[ref] = text\n            return text\n\n        def convert_attachments(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:\n            file_header = el.find(\"th\", {\"class\": \"filename-column\"})\n            file_header_text = file_header.text.strip() if file_header else \"File\"\n\n            modified_header = el.find(\"th\", {\"class\": \"modified-column\"})\n            modified_header_text = modified_header.text.strip() if modified_header else \"Modified\"\n\n            def _get_path(p: Path) -> str:\n                attachment_path = self._get_path_for_href(p, settings.export.attachment_href)\n                return attachment_path.replace(\" \", \"%20\")\n\n            def _attachment_link(att: Attachment) -> str:\n                if settings.export.attachment_href == \"wiki\":\n                    return f\"[[{att.export_path.name}|{att.title}]]\"\n                return f\"[{att.title}]({_get_path(att.export_path)})\"\n\n            rows = [\n                {\n                    \"file\": _attachment_link(att),\n                    \"modified\": f\"{att.version.friendly_when} by {self.convert_user(att.version.by)}\",  # noqa: E501\n                }\n                for att in self.page.attachments\n            ]\n\n            html = f\"\"\"<table>\n            <tr><th>{file_header_text}</th><th>{modified_header_text}</th></tr>\n            {\"\".join(f\"<tr><td>{row['file']}</td><td>{row['modified']}</td></tr>\" for row in rows)}\n            </table>\"\"\"\n\n            return (\n                f\"\\n\\n{self.convert_table(BeautifulSoup(html, 'html.parser'), text, parent_tags)}\\n\"\n            )\n\n        def convert_column_layout(\n            self, el: BeautifulSoup, text: str, parent_tags: list[str]\n        ) -> str:\n            cells = el.find_all(\"div\", {\"class\": \"cell\"})\n\n            if len(cells) < 2:  # noqa: PLR2004\n                return super().convert_div(el, text, parent_tags)\n\n            html = f\"<table><tr>{''.join([f'<td>{cell!s}</td>' for cell in cells])}</tr></table>\"\n\n            return self.convert_table(BeautifulSoup(html, \"html.parser\"), text, parent_tags)\n\n        def convert_jira_table(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:\n            jira_tables = BeautifulSoup(self.page.body_export, \"html.parser\").find_all(\n                \"div\", {\"class\": \"jira-table\"}\n            )\n\n            if len(jira_tables) == 0:\n                logger.warning(\"No Jira table found. Ignoring.\")\n                return text\n\n            if len(jira_tables) > 1:\n                logger.exception(\"Multiple Jira tables are not supported. Ignoring.\")\n                return text\n\n            return self.process_tag(jira_tables[0], parent_tags)\n\n        def convert_toc(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:\n            if not settings.export.include_toc:\n                return \"\"\n\n            tocs = BeautifulSoup(self.page.body_export, \"html.parser\").find_all(\n                \"div\", {\"class\": \"toc-macro\"}\n            )\n\n            if len(tocs) == 0:\n                logger.warning(\"Could not find TOC macro. Ignoring.\")\n                return text\n\n            if len(tocs) > 1:\n                logger.exception(\"Multiple TOC macros are not supported. Ignoring.\")\n                return text\n\n            return self.process_tag(tocs[0], parent_tags)\n\n        def convert_hidden_content(\n            self, el: BeautifulSoup, text: str, parent_tags: list[str]\n        ) -> str:\n            content = super().convert_p(el, text, parent_tags)\n            if not content.strip():\n                return \"\"\n            return f\"\\n<!--{content}-->\\n\"\n\n        def convert_jira_issue(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:\n            issue_key = el.get(\"data-jira-key\")\n            link = cast(\"BeautifulSoup\", el.find(\"a\", {\"class\": \"jira-issue-key\"}))\n            if not link:\n                return text\n            if not issue_key:\n                return self.process_tag(link, parent_tags)\n\n            try:\n                jira_url = _extract_jira_base_url(str(link.get(\"href\", \"\"))) or self.page.base_url\n                issue = JiraIssue.from_key(str(issue_key), jira_url)\n            except HTTPError:\n                return f\"[[{issue_key}]]({link.get('href')})\"\n\n            if not issue:\n                return f\"[[{issue_key}]]({link.get('href')})\"\n\n            return f\"[[{issue.key}] {issue.summary}]({link.get('href')})\"\n\n        def convert_pre(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:  # type: ignore[override]\n            if not text:\n                return \"\"\n\n            code_language = \"\"\n            if el.has_attr(\"data-syntaxhighlighter-params\"):\n                match = re.search(r\"brush:\\s*([^;]+)\", str(el[\"data-syntaxhighlighter-params\"]))\n                if match:\n                    code_language = match.group(1)\n\n            if \"@startuml\" in text:\n                code_language = \"plantuml\"\n\n            return f\"\\n\\n```{code_language}\\n{text}\\n```\\n\\n\"\n\n        def convert_sub(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:\n            return f\"<sub>{text}</sub>\"\n\n        def convert_sup(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:\n            \"\"\"Convert superscript to Markdown footnotes.\"\"\"\n            if el.previous_sibling is None:\n                return f\"[^{text}]:\"  # Footnote definition\n            return f\"[^{text}]\"  # f\"<sup>{text}</sup>\"\n\n        def convert_a(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:  # noqa: PLR0911, PLR0912, C901\n            if \"user-mention\" in str(el.get(\"class\")):\n                return self.convert_user_mention(el, text, parent_tags)\n            if \"createpage.action\" in str(el.get(\"href\")) or \"createlink\" in str(el.get(\"class\")):\n                logger.warning(\n                    f\"Broken link detected: '{text}' on page '{self.page.title}' \"\n                    f\"(ID: {self.page.id}). This is likely a Confluence bug. \"\n                    f\"Please report this issue to Atlassian Support.\"\n                )\n                # Find fallback link without using string= parameter to avoid\n                # BeautifulSoup recursion bug. The string= parameter triggers\n                # recursive .string property access which fails on Fabric\n                # Editor v2 HTML with fab:media tags\n                try:\n                    soup = BeautifulSoup(self.page.editor2, \"html.parser\")\n                    for link in soup.find_all(\"a\"):\n                        # Use get_text() instead of .string to avoid recursion issues\n                        link_text = link.get_text(strip=True)\n                        if link_text == text:\n                            # Prevent infinite recursion if fallback is the same element\n                            if isinstance(link, Tag) and link.get(\"href\") != el.get(\"href\"):\n                                return self.convert_a(link, text, parent_tags)  # type: ignore[arg-type]\n                except RecursionError:\n                    # editor2 HTML contains problematic tags (e.g., fab:media)\n                    # that cause BS4 recursion. Skip fallback and return\n                    # wiki-style link\n                    pass\n                # If no matching link found, return wiki-style link\n                return f\"[[{text}]]\"\n            if \"page\" in str(el.get(\"data-linked-resource-type\")):\n                page_id = str(el.get(\"data-linked-resource-id\", \"\"))\n                if page_id and page_id != \"null\":\n                    return self.convert_page_link(int(page_id))\n            if \"attachment\" in str(el.get(\"data-linked-resource-type\")):\n                link = self.convert_attachment_link(el, text, parent_tags)\n                # convert_attachment_link may return None if the attachment meta is incomplete\n                return link or f\"[{text}]({el.get('href')})\"\n            href_str = str(el.get(\"href\", \"\"))\n            if href_str:\n                parsed_href = urlparse(href_str)\n                base_host = urlparse(getattr(self.page, \"base_url\", \"\") or \"\").hostname\n                if not parsed_href.hostname or parsed_href.hostname == base_host:\n                    query_params = urllib.parse.parse_qs(parsed_href.query)\n                    page_id_param = next(\n                        (\n                            values[0]\n                            for key, values in query_params.items()\n                            if key.lower() == \"pageid\" and values and values[0]\n                        ),\n                        None,\n                    )\n                    if page_id_param and page_id_param.isdigit():\n                        return self.convert_page_link(int(page_id_param))\n                    if match := parse_confluence_path(parsed_href.path):\n                        if match.page_id:\n                            return self.convert_page_link(match.page_id)\n            if (href := href_str).startswith(\"#\"):\n                if settings.export.page_href == \"wiki\":\n                    return f\"[[#{text}]]\"\n                return f\"[{text}](#{github_heading_slug(href[1:])})\"\n\n            return super().convert_a(el, text, parent_tags)\n\n        def convert_page_link(self, page_id: int) -> str:\n            if not page_id:\n                msg = \"Page link does not have valid page_id.\"\n                raise ValueError(msg)\n\n            page = Page.from_id(page_id, self.page.base_url)\n\n            if page.title == \"Page not accessible\":\n                logger.warning(\n                    f\"Confluence page link (ID: {page_id}) is not accessible, \"\n                    f\"referenced from page '{self.page.title}' (ID: {self.page.id})\"\n                )\n                return f\"[Page not accessible (ID: {page_id})]\"\n\n            PageTitleRegistry.register(int(page.id), page.title)\n\n            if settings.export.page_href == \"wiki\":\n                if PageTitleRegistry.is_ambiguous(page.title):\n                    vault_path = page.export_path.with_suffix(\"\").as_posix()\n                    return f\"[[{vault_path}|{page.title}]]\"\n                return f\"[[{page.title}]]\"\n\n            page_path = self._get_path_for_href(page.export_path, settings.export.page_href)\n            return f\"[{page.title}]({page_path.replace(' ', '%20')})\"\n\n        def convert_attachment_link(\n            self, el: BeautifulSoup, text: str, parent_tags: list[str]\n        ) -> str:\n            \"\"\"Build a Markdown link for an attachment.\n\n            If the attachment metadata is missing,\n            return the original Confluence URL instead of crashing.\n            \"\"\"\n            attachment = None\n            if fid := el.get(\"data-linked-resource-file-id\"):\n                attachment = self.page.get_attachment_by_file_id(str(fid))\n            if not attachment and (fid := el.get(\"data-media-id\")):\n                attachment = self.page.get_attachment_by_file_id(str(fid))\n            if not attachment and (aid := el.get(\"data-linked-resource-id\")):\n                attachment = self.page.get_attachment_by_id(str(aid))\n\n            if attachment is None:\n                href = el.get(\"href\") or text\n                return f\"[{text}]({href})\"\n\n            if settings.export.attachment_href == \"wiki\":\n                return f\"[[{attachment.export_path.name}|{attachment.title}]]\"\n\n            path = self._get_path_for_href(attachment.export_path, settings.export.attachment_href)\n            return f\"[{attachment.title}]({path.replace(' ', '%20')})\"\n\n        def convert_time(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:\n            if el.has_attr(\"datetime\"):\n                return f\"{el['datetime']}\"\n\n            return f\"{text}\"\n\n        def convert_user_mention(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:\n            if aid := el.get(\"data-account-id\"):\n                try:\n                    return self.convert_user(User.from_accountid(str(aid), self.page.base_url))\n                except ApiNotFoundError:\n                    logger.warning(f\"User {aid} not found. Using text instead.\")\n\n            return self.convert_user_name(text)\n\n        def convert_user(self, user: User) -> str:\n            return self.convert_user_name(user.display_name)\n\n        def convert_user_name(self, name: str) -> str:\n            return name.removesuffix(\"(Unlicensed)\").removesuffix(\"(Deactivated)\").strip()\n\n        def convert_li(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:\n            md = super().convert_li(el, text, parent_tags)\n            bullet = self.options[\"bullets\"][0]\n\n            # Convert Confluence task lists to GitHub task lists\n            if el.has_attr(\"data-inline-task-id\"):\n                is_checked = el.has_attr(\"class\") and \"checked\" in el[\"class\"]\n                return md.replace(f\"{bullet} \", f\"{bullet} {'[x]' if is_checked else '[ ]'} \", 1)\n\n            return md\n\n        _ATLASSIAN_EMOTICONS: ClassVar[dict[str, str]] = {\n            \"atlassian-check_mark\": \"✅\",\n            \"atlassian-cross_mark\": \"❌\",\n            \"atlassian-yes\": \"👍\",\n            \"atlassian-no\": \"👎\",\n            \"atlassian-information\": \"\\u2139\\ufe0f\",\n            \"atlassian-warning\": \"⚠️\",\n            \"atlassian-forbidden\": \"🚫\",\n            \"atlassian-plus\": \"\\u2795\",\n            \"atlassian-minus\": \"\\u2796\",\n            \"atlassian-question\": \"❓\",\n            \"atlassian-exclamation\": \"❗\",\n            \"atlassian-light_on\": \"💡\",\n            \"atlassian-light_off\": \"💡\",\n            \"atlassian-star_yellow\": \"⭐\",\n            \"atlassian-blue_star\": \"🔵\",\n            \"atlassian-smile\": \"😊\",\n            \"atlassian-sad\": \"😞\",\n            \"atlassian-tongue\": \"😛\",\n            \"atlassian-biggrin\": \"😁\",\n            \"atlassian-wink\": \"😉\",\n        }\n\n        def _convert_emoticon(self, el: BeautifulSoup) -> str | None:\n            classes = el.get(\"class\") or []\n            if \"emoticon\" not in classes:\n                return None\n            emoji_id = str(el.get(\"data-emoji-id\", \"\"))\n            fallback = str(el.get(\"data-emoji-fallback\", \"\"))\n            if fallback and not fallback.startswith(\":\"):\n                return fallback\n            if emoji_id:\n                try:\n                    codepoints = [int(cp, 16) for cp in emoji_id.split(\"-\")]\n                    if all(0 <= cp <= _MAX_UNICODE_CODEPOINT for cp in codepoints):\n                        return \"\".join(chr(cp) for cp in codepoints)\n                except (OverflowError, ValueError):\n                    pass\n                if emoji_id in self._ATLASSIAN_EMOTICONS:\n                    return self._ATLASSIAN_EMOTICONS[emoji_id]\n            shortname = str(el.get(\"data-emoji-shortname\", \"\"))\n            return shortname or fallback or str(el.get(\"alt\", \"\")) or None\n\n        def convert_img(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:  # noqa: C901, PLR0911, PLR0912\n            if emoticon := self._convert_emoticon(el):\n                return emoticon\n\n            attachment = None\n            if fid := el.get(\"data-media-id\"):\n                attachment = self.page.get_attachment_by_file_id(str(fid))\n            if not attachment and (fid := el.get(\"data-media-id\")):\n                attachment = self.page.get_attachment_by_file_id(str(fid))\n            if not attachment and (fid := el.get(\"data-linked-resource-file-id\")):\n                attachment = self.page.get_attachment_by_file_id(str(fid))\n            if not attachment and (aid := el.get(\"data-linked-resource-id\")):\n                attachment = self.page.get_attachment_by_id(str(aid))\n            if not attachment and (encoded_xml := el.get(\"data-encoded-xml\")):\n                decoded = unquote(str(encoded_xml))\n                if m := re.search(r'ri:filename=\"([^\"]+)\"', decoded):\n                    matches = self.page.get_attachments_by_title(m.group(1))\n                    if matches:\n                        attachment = matches[0]\n\n            url_src = str(el.get(\"src\", \"\"))\n\n            if \".drawio.png\" in url_src:\n                filename = unquote(urlparse(url_src).path.split(\"/\")[-1])\n                drawio_result = self._convert_drawio_embedded_mermaid(filename)\n                if drawio_result:\n                    return drawio_result\n                # If no mermaid diagram extracted, use PNG as attachment fallback\n                if attachment is None:\n                    drawio_images = self.page.get_attachments_by_title(filename)\n                    if len(drawio_images) > 0:\n                        attachment = drawio_images[0]\n\n            if attachment is None:\n                href = el.get(\"href\") or text\n                if href:\n                    return f\"![{text}]({href})\"\n                if url_src:\n                    return f\"![{text}]({url_src})\"\n                return text\n\n            caption = (\n                self._image_captions.get(attachment.title, \"\")\n                if settings.export.image_captions\n                else \"\"\n            )\n\n            if settings.export.attachment_href == \"wiki\":\n                img_md = f\"![[{attachment.export_path.name}]]\"\n                return f\"{img_md}\\n*{caption}*\" if caption else img_md\n\n            path = self._get_path_for_href(attachment.export_path, settings.export.attachment_href)\n            el[\"src\"] = path.replace(\" \", \"%20\")\n            tags = parent_tags if isinstance(parent_tags, list | set) else set()\n            if \"_inline\" in tags:\n                tags = set(tags)\n                tags.discard(\"_inline\")  # Always show images.\n            img_md = super().convert_img(el, text, tags)  # type: ignore[union-attr]\n            return f\"{img_md}\\n*{caption}*\" if caption else img_md\n\n        def _normalize_unicode_whitespace(self, text: str) -> str:\n            r\"\"\"Normalize Unicode whitespace to regular spaces.\n\n            This fixes an issue where markdownify's chomp() function strips Unicode\n            whitespace characters (like \\xa0 from &nbsp;) entirely, causing missing\n            spaces in markdown output.\n\n            Confluence often uses &nbsp; (non-breaking space, \\xa0) inside inline\n            formatting tags like <em>&nbsp;text</em>. BeautifulSoup correctly converts\n            this to \\xa0, but markdownify's chomp() doesn't preserve it, resulting in\n            output like \"word*text*\" instead of \"word *text*\".\n\n            This method normalizes all Unicode whitespace characters to regular ASCII\n            spaces so they are preserved by markdownify's chomp() function.\n\n            Args:\n                text: Text string to normalize\n\n            Returns:\n                Text with Unicode whitespace replaced by regular spaces\n            \"\"\"\n            # Normalize all Unicode whitespace to regular space\n            # This includes: \\xa0 (nbsp), \\u2000-\\u200a (various spaces),\n            # \\u2028 (line separator), \\u2029 (paragraph separator), etc.\n            # Keep \\n, \\r, \\t as-is since they have semantic meaning\n            normalized = text\n            for char in text:\n                if char.isspace() and char not in \" \\n\\r\\t\":\n                    # Replace Unicode whitespace with regular space\n                    normalized = normalized.replace(char, \" \")\n            return normalized\n\n        def escape(self, text: str, parent_tags: list[str]) -> str:\n            escaped: str = cast(\"Any\", MarkdownConverter).escape(self, text, parent_tags)\n            return escaped.replace(\"[\", r\"\\[\").replace(\"]\", r\"\\]\")\n\n        def _escape_template_placeholders(self, text: str) -> str:\n            r\"\"\"Escape <placeholder> patterns that Obsidian misparsed as HTML tags.\n\n            Confluence templates use <placeholder text> to mark values that need\n            replacing. Obsidian's renderer treats these as HTML, breaking page\n            formatting. This method escapes them to \\<placeholder text\\> so they\n            render as literal angle-bracket text.\n\n            Valid HTML tags (e.g. <br/>) are preserved. Content inside fenced code\n            blocks and inline code spans is left untouched.\n            \"\"\"\n\n            def _escape_if_placeholder(m: re.Match) -> str:\n                inner = m.group(1)\n                if _AUTOLINK_URI_RE.match(inner) or _AUTOLINK_EMAIL_RE.match(inner):\n                    return m.group(0)\n                # Strip leading slash (closing tag), get first token, strip trailing slash\n                stripped = inner.strip().lstrip(\"/\")\n                tag_name = re.split(r\"[\\s/]\", stripped)[0].lower() if stripped else \"\"\n                if tag_name in _HTML_ELEMENTS or inner.startswith(\"!\"):\n                    return m.group(0)\n                return f\"\\\\<{inner}\\\\>\"\n\n            lines = text.split(\"\\n\")\n            result = []\n            in_fence = False\n            for line in lines:\n                if _CODE_FENCE_RE.match(line):\n                    in_fence = not in_fence\n                    result.append(line)\n                    continue\n                if in_fence:\n                    result.append(line)\n                    continue\n                # Interleave non-code and inline-code parts; only process non-code\n                parts = _INLINE_CODE_RE.split(line)\n                codes = _INLINE_CODE_RE.findall(line)\n                processed = []\n                for i, part in enumerate(parts):\n                    processed.append(_ANGLE_BRACKET_RE.sub(_escape_if_placeholder, part))\n                    if i < len(codes):\n                        processed.append(codes[i])\n                result.append(\"\".join(processed))\n            return \"\\n\".join(result)\n\n        def convert_em(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:\n            \"\"\"Convert <em> tags, preserving spaces from Unicode whitespace entities.\"\"\"\n            text = self._normalize_unicode_whitespace(text)\n            return super().convert_em(el, text, parent_tags)\n\n        def convert_strong(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:\n            \"\"\"Convert <strong> tags, preserving spaces from Unicode whitespace entities.\"\"\"\n            text = self._normalize_unicode_whitespace(text)\n            return super().convert_strong(el, text, parent_tags)\n\n        def convert_code(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:\n            \"\"\"Convert <code> tags, preserving spaces from Unicode whitespace entities.\"\"\"\n            text = self._normalize_unicode_whitespace(text)\n            return super().convert_code(el, text, parent_tags)\n\n        def convert_i(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:\n            \"\"\"Convert <i> tags, preserving spaces from Unicode whitespace entities.\"\"\"\n            text = self._normalize_unicode_whitespace(text)\n            return super().convert_i(el, text, parent_tags)\n\n        def convert_b(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:\n            \"\"\"Convert <b> tags, preserving spaces from Unicode whitespace entities.\"\"\"\n            text = self._normalize_unicode_whitespace(text)\n            return super().convert_b(el, text, parent_tags)\n\n        def _convert_drawio_embedded_mermaid(self, filename: str) -> str | None:\n            \"\"\"Extract mermaid diagram from DrawIO PNG preview image.\n\n            Args:\n                filename: The filename of the drawio diagram image.\n\n            Returns:\n                Markdown formatted mermaid diagram or None if not found.\n            \"\"\"\n            drawio_title = filename.removesuffix(\".png\")\n            drawio_attachments = self.page.get_attachments_by_title(drawio_title)\n\n            if len(drawio_attachments) == 0:\n                return None\n\n            drawio_filepath = settings.export.output_path / drawio_attachments[0].export_path\n            if not drawio_filepath.exists():\n                return None\n\n            # Extract mermaid diagram from DrawIO file\n            return load_and_parse_drawio(str(drawio_filepath))\n\n        def convert_drawio(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:\n            if match := re.search(r\"\\|diagramName=(.+?)\\|\", str(el)):\n                drawio_name = match.group(1)\n                preview_name = f\"{drawio_name}.png\"\n                drawio_attachments = self.page.get_attachments_by_title(drawio_name)\n                preview_attachments = self.page.get_attachments_by_title(preview_name)\n\n                if not drawio_attachments or not preview_attachments:\n                    return f\"\\n<!-- Drawio diagram `{drawio_name}` not found -->\\n\\n\"\n\n                if settings.export.attachment_href == \"wiki\":\n                    preview_filename = preview_attachments[0].export_path.name\n                    drawio_filename = drawio_attachments[0].export_path.name\n                    drawio_image_embedding = f\"![[{preview_filename}|{drawio_name}]]\"\n                    drawio_link = f\"[[{drawio_filename}|{drawio_image_embedding}]]\"\n                else:\n                    drawio_path = self._get_path_for_href(\n                        drawio_attachments[0].export_path, settings.export.attachment_href\n                    )\n                    preview_path = self._get_path_for_href(\n                        preview_attachments[0].export_path, settings.export.attachment_href\n                    )\n                    drawio_image_embedding = f\"![{drawio_name}]({preview_path.replace(' ', '%20')})\"\n                    drawio_link = f\"[{drawio_image_embedding}]({drawio_path.replace(' ', '%20')})\"\n                return f\"\\n{drawio_link}\\n\\n\"\n\n            return \"\"\n\n        def _extract_uml_from_editor2(self, macro_id: str) -> str | None:\n            \"\"\"Extract PlantUML source from editor2 XML by macro-id (Cloud format).\"\"\"\n            if not self.page.editor2:\n                return None\n            wrapped = f\"<root>{self.page.editor2}</root>\"\n            soup = BeautifulSoup(wrapped, \"xml\")\n            for macro in soup.find_all(\"structured-macro\"):\n                if not isinstance(macro, Tag):\n                    continue\n                if macro.get(\"name\") != \"plantuml\" or macro.get(\"macro-id\") != macro_id:\n                    continue\n                plain_text_body = macro.find(\"plain-text-body\")\n                if not isinstance(plain_text_body, Tag):\n                    continue\n                cdata = plain_text_body.get_text(strip=True)\n                if not cdata:\n                    continue\n                try:\n                    return json.loads(cdata).get(\"umlDefinition\") or None\n                except json.JSONDecodeError:\n                    return None\n            return None\n\n        def _extract_uml_from_storage(self) -> str | None:\n            \"\"\"Extract PlantUML source from body.storage by position (Server format).\"\"\"\n            storage_macros = self._storage_plantuml_macros\n            idx = self._plantuml_index\n            self._plantuml_index += 1\n            if idx >= len(storage_macros):\n                return None\n            plain_text_body = storage_macros[idx].find(\"plain-text-body\")\n            if not isinstance(plain_text_body, Tag):\n                return None\n            uml = plain_text_body.get_text(strip=True)\n            return uml or None\n\n        def convert_plantuml(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:\n            \"\"\"Convert PlantUML diagrams to Markdown code blocks.\n\n            Supports two Confluence formats:\n\n            1. **Cloud / editor2**: The editor2 XML contains structured macros with\n               the UML definition in a JSON CDATA section (``{\"umlDefinition\": \"...\"}``).\n               Each macro carries a ``macro-id`` that is also present in the view\n               HTML as ``data-macro-id``.\n\n            2. **Server / Data Center**: ``editor2`` is often empty.  The UML source\n               lives as raw ``@startuml`` text inside ``<plain-text-body>`` CDATA\n               sections in ``body.storage``.  The view HTML renders each diagram\n               inside a ``<span data-macro-name=\"plantuml\">`` without a ``macro-id``.\n               Diagrams are matched by position (Nth diagram in storage corresponds\n               to the Nth ``<span>`` in view HTML).\n            \"\"\"\n            # Strategy 1: editor2 with macro-id (Cloud)\n            macro_id = el.get(\"data-macro-id\")\n            if macro_id:\n                uml = self._extract_uml_from_editor2(str(macro_id))\n                if uml:\n                    return f\"\\n```plantuml\\n{uml}\\n```\\n\\n\"\n\n            # Strategy 2: body.storage fallback (Server / Data Center)\n            uml = self._extract_uml_from_storage()\n            if uml:\n                return f\"\\n```plantuml\\n{uml}\\n```\\n\\n\"\n\n            logger.warning(\"PlantUML macro could not be resolved from editor2 or body.storage\")\n            return \"\\n<!-- PlantUML diagram (source not found) -->\\n\\n\"\n\n        def convert_include(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:\n            \"\"\"Convert Confluence `include` / `excerpt-include` macro.\n\n            When `include_macro = transclusion`, emit an Obsidian-style embed link\n            (`![[Page Title]]`) so the referenced page renders inline in Obsidian,\n            mimicking the Confluence include/excerpt behavior. Requires the target\n            page to also be exported so the link can resolve.\n\n            When `include_macro = inline` (default), the body_view content is\n            already expanded — fall through to normal div processing to render it.\n            \"\"\"\n            macro_name = str(el.get(\"data-macro-name\", \"\"))\n            macro_id = el.get(\"data-macro-id\")\n\n            target_title: str | None = None\n            if macro_id and isinstance(macro_id, str):\n                target_title = self._extract_include_target_title(macro_id)\n\n            if settings.export.include_macro == \"transclusion\" and target_title:\n                return f\"\\n![[{target_title}]]\\n\\n\"\n\n            if settings.export.include_macro == \"transclusion\":\n                logger.warning(\n                    f\"{macro_name} macro found but target page title could not be resolved; \"\n                    f\"falling back to inline content\"\n                )\n\n            inline = super().convert_div(el, text, parent_tags)  # type: ignore[misc]\n            if macro_name == \"excerpt-include\":\n                title_note = f\" from page '{target_title}'\" if target_title else \"\"\n                return (\n                    f\"\\n<!-- excerpt start{title_note} -->\\n\"\n                    f\"{inline}\"\n                    f\"\\n<!-- excerpt end{title_note} -->\\n\\n\"\n                )\n            return inline\n\n        def _strip_excerpt_include_panel_titles(self, html: str) -> str:\n            \"\"\"Strip the source-page-title panel from `excerpt-include` bodies.\n\n            Confluence's `excerpt-include` body.view wraps the included\n            content in a panel whose `panelHeader` is the source page title\n            unless `nopanel=true`. The `panelContent` div holds the actual\n            body. We unwrap to leave only the body.\n            \"\"\"\n            soup = BeautifulSoup(html, \"html.parser\")\n            for el in soup.find_all(attrs={\"data-macro-name\": \"excerpt-include\"}):\n                self._unwrap_excerpt_include_panel(el)\n            return str(soup)\n\n        def _unwrap_excerpt_include_panel(self, el: Tag) -> None:\n            classes = el.get(\"class\") or []\n            if not isinstance(classes, list) or \"panel\" not in classes:\n                return\n            header = el.find(\"div\", class_=\"panelHeader\")\n            if isinstance(header, Tag):\n                header.decompose()\n            content = el.find(\"div\", class_=\"panelContent\")\n            if isinstance(content, Tag):\n                content.unwrap()\n\n        def _extract_include_target_title(self, macro_id: str) -> str | None:\n            \"\"\"Resolve the target page title for an `include` / `excerpt-include` macro.\n\n            BeautifulSoup with `xml` parser strips namespace prefixes, so\n            `ac:structured-macro` becomes `structured-macro`, `ri:page` becomes\n            `page`, and `ri:content-title` becomes `content-title`.\n            \"\"\"\n            wrapped_editor2 = f\"<root>{self.page.editor2}</root>\"\n            soup_editor2 = BeautifulSoup(wrapped_editor2, \"xml\")\n            for macro in soup_editor2.find_all(\"structured-macro\"):\n                if not isinstance(macro, Tag):\n                    continue\n                if macro.get(\"name\") not in (\"include\", \"excerpt-include\"):\n                    continue\n                if macro.get(\"macro-id\") != macro_id:\n                    continue\n                ri_page = macro.find(\"page\")\n                if isinstance(ri_page, Tag):\n                    title = ri_page.get(\"content-title\")\n                    if isinstance(title, str) and title:\n                        return title\n            return None\n\n        def _find_element_with_namespace(self, parent: BeautifulSoup, tag_name: str) -> Tag | None:\n            \"\"\"Find an element with or without namespace prefix.\"\"\"\n            result = parent.find(f\"ac:{tag_name}\") or parent.find(tag_name)\n            return result if isinstance(result, Tag) else None\n\n        def _find_structured_macro(self, el: BeautifulSoup) -> Tag | None:\n            \"\"\"Find structured-macro element with or without namespace.\"\"\"\n            return self._find_element_with_namespace(el, \"structured-macro\")\n\n        def _extract_plain_text_body(self, el: BeautifulSoup | Tag) -> str | None:\n            \"\"\"Extract markdown content from plain-text-body element.\"\"\"\n            plain_text_body = self._find_element_with_namespace(el, \"plain-text-body\")  # type: ignore[arg-type]\n            if plain_text_body:\n                return plain_text_body.get_text()\n            return None\n\n        def _extract_markdown_parameter(self, el: BeautifulSoup | Tag) -> str | None:\n            \"\"\"Extract markdown content from parameter element.\"\"\"\n            param = el.find(\"ac:parameter\", {\"ac:name\": \"markdown\"})\n            if param is None:\n                param = el.find(\"parameter\", {\"name\": \"markdown\"})\n            if isinstance(param, Tag):\n                return param.get_text()\n            return None\n\n        def _extract_markdown_from_body(self, el: BeautifulSoup) -> str | None:\n            \"\"\"Extract markdown content from body HTML.\"\"\"\n            # Try plain-text-body first (standard markdown macro)\n            markdown_content = self._extract_plain_text_body(el)\n            if markdown_content:\n                return markdown_content\n\n            # Check in structured-macro child element\n            structured_macro = self._find_structured_macro(el)\n            if structured_macro:\n                markdown_content = self._extract_plain_text_body(structured_macro)\n                if markdown_content:\n                    return markdown_content\n\n            # Try parameter for mohamicorp-markdown\n            markdown_content = self._extract_markdown_parameter(el)\n            if markdown_content:\n                return markdown_content\n\n            # Check parameter in structured-macro child\n            if structured_macro:\n                markdown_content = self._extract_markdown_parameter(structured_macro)\n                if markdown_content:\n                    return markdown_content\n\n            return None\n\n        def _extract_markdown_from_editor2(self, macro_id: str) -> str | None:\n            \"\"\"Extract markdown content from editor2 XML.\"\"\"\n            wrapped_editor2 = f\"<root>{self.page.editor2}</root>\"\n            soup_editor2 = BeautifulSoup(wrapped_editor2, \"xml\")\n\n            # BeautifulSoup strips namespace prefixes, so ac:structured-macro\n            # becomes structured-macro\n            markdown_macros = soup_editor2.find_all(\"structured-macro\")\n            for macro in markdown_macros:\n                if not isinstance(macro, Tag):\n                    continue\n                if (\n                    macro.get(\"name\") in (\"markdown\", \"mohamicorp-markdown\")\n                    and macro.get(\"macro-id\") == macro_id\n                ):\n                    # Try plain-text-body first\n                    plain_text_body = macro.find(\"plain-text-body\")\n                    if isinstance(plain_text_body, Tag):\n                        return plain_text_body.get_text(strip=True)\n\n                    # Try parameter for mohamicorp-markdown\n                    param = macro.find(\"parameter\", {\"name\": \"markdown\"})\n                    if isinstance(param, Tag):\n                        return param.get_text(strip=True)\n\n            return None\n\n        def convert_markdown(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:\n            \"\"\"Convert Markdown macro fragments to Markdown.\n\n            Supports both standard 'markdown' macro and 'mohamicorp-markdown'\n            macro. The content is already in Markdown format, so we just extract\n            and return it.\n            \"\"\"\n            macro_name = el.get(\"data-macro-name\", \"\")\n\n            # First, try to extract from body HTML\n            markdown_content = self._extract_markdown_from_body(el)\n\n            # If not found, try editor2 XML (similar to plantuml)\n            if not markdown_content:\n                macro_id = el.get(\"data-macro-id\")\n                if macro_id and isinstance(macro_id, str):\n                    markdown_content = self._extract_markdown_from_editor2(macro_id)\n\n            if not markdown_content:\n                logger.warning(\n                    f\"Markdown macro ({macro_name}) found but no content could be extracted\"\n                )\n                return f\"\\n<!-- Markdown macro ({macro_name}) content not found -->\\n\\n\"\n\n            # Return the markdown content directly (it's already in markdown format)\n            # Add newlines for proper spacing\n            return f\"\\n{markdown_content}\\n\\n\"\n\n        def convert_table(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:\n            if el.has_attr(\"class\") and \"metadata-summary-macro\" in el[\"class\"]:\n                return self.convert_page_properties_report(el, text, parent_tags)\n\n            return super().convert_table(el, text, parent_tags)\n\n        def convert_page_properties_report(\n            self, el: BeautifulSoup, text: str, parent_tags: list[str]\n        ) -> str:\n            data_cql = el.get(\"data-cql\")\n            if not data_cql:\n                return \"\"\n\n            if settings.export.page_properties_report_format == \"dataview\":\n                dql = self._cql_to_dataview(el, str(data_cql))\n                if dql is not None:\n                    return f\"\\n```dataview\\n{dql}\\n```\\n\"\n\n            soup = BeautifulSoup(self.page.body_export, \"html.parser\")\n            table = soup.find(\"table\", {\"data-cql\": data_cql})\n            if not table:\n                return \"\"\n            return super().convert_table(table, \"\", parent_tags)  # type: ignore -\n\n        def _cql_to_dataview(self, el: BeautifulSoup, cql: str) -> str | None:\n            \"\"\"Translate a Confluence CQL query to an Obsidian Dataview DQL query.\n\n            Returns None if the CQL cannot be meaningfully translated.\n            \"\"\"\n            current_content_id = str(el.get(\"data-current-content-id\", \"\"))\n            headings_raw = str(el.get(\"data-headings\", \"\"))\n            first_col = str(el.get(\"data-first-column-heading\", \"Title\"))\n            sort_by = str(el.get(\"data-sort-by\", first_col))\n            reverse_sort = str(el.get(\"data-reverse-sort\", \"false\")).lower() == \"true\"\n\n            label_conditions = [\n                m.group(1) for m in re.finditer(r'label\\s*=\\s*\"([^\"]+)\"', cql, re.IGNORECASE)\n            ]\n\n            parent_match = re.search(r'parent\\s*=\\s*\"?(\\d+)\"?', cql, re.IGNORECASE)\n            current_content_match = re.search(\n                r'(?:ancestor|parent)\\s*=\\s*currentContent\\s*\\(\\s*\\)', cql, re.IGNORECASE\n            )\n\n            from_clause: str | None = None\n            if current_content_match or (\n                parent_match and parent_match.group(1) == current_content_id\n            ):\n                folder = str(self.page.export_path.parent).replace(\"\\\\\", \"/\")\n                from_clause = f'\"{folder}\"'\n\n            if from_clause is None and not label_conditions:\n                return None\n\n            lines: list[str] = []\n\n            if headings_raw:\n                headings = [h.strip() for h in headings_raw.split(\",\") if h.strip()]\n                col_names = \", \".join(f'{sanitize_key(h)} AS \"{h}\"' for h in headings)\n                lines.append(f\"TABLE {col_names}\")\n            else:\n                lines.append(\"TABLE\")\n\n            from_parts = ([from_clause] if from_clause else []) + [\n                f\"#{lbl}\" for lbl in label_conditions\n            ]\n            if from_parts:\n                lines.append(\"FROM \" + \" AND \".join(from_parts))\n\n            sort_col = sanitize_key(sort_by)\n            sort_dir = \"DESC\" if reverse_sort else \"ASC\"\n            lines.append(f\"SORT {sort_col} {sort_dir}\")\n\n            return \"\\n\".join(lines)\n\n        def _get_path_for_href(\n            self, path: Path, style: Literal[\"absolute\", \"relative\", \"wiki\"]\n        ) -> str:\n            \"\"\"Get the path to use in href attributes based on settings.\"\"\"\n            if style == \"absolute\":\n                # Note that usually absolute would be\n                # something like this: (settings.export.output_path / path).absolute()\n                # In this case the URL will be \"absolute\" to the export path.\n                # This is useful for local file links.\n                result = \"/\" + str(path).lstrip(\"/\")\n            elif style == \"wiki\":\n                result = path.name\n            else:\n                result = os.path.relpath(path, self.page.export_path.parent)\n            return result\n\n\n_CQL_MAX_BATCH_SIZE: int = 25\n\n\ndef _fetch_page_ids_v2_batch(batch: list[str], base_url: str) -> set[str]:\n    \"\"\"Single v2 API request for a batch of page IDs.\n\n    Uses GET /api/v2/pages?id=X&id=Y&...  (Atlassian Cloud).\n    The v2 API accepts multiple ``id`` params, so they are encoded directly\n    into the URL path since the SDK only accepts a dict for ``params``.\n    \"\"\"\n    query = urllib.parse.urlencode([(\"id\", pid) for pid in batch] + [(\"limit\", len(batch))])\n    response = cast(\"dict\", get_thread_confluence(base_url).get(f\"api/v2/pages?{query}\"))\n    if not response:\n        return set()\n    return {str(item[\"id\"]) for item in response.get(\"results\", [])}\n\n\ndef _fetch_page_ids_cql_batch(batch: list[str], base_url: str) -> set[str]:\n    \"\"\"Single CQL v1 request for a batch of page IDs.\n\n    Uses GET /rest/api/content/search with id in (...) (self-hosted / fallback).\n    \"\"\"\n    cql = \"id in ({})\".format(\",\".join(batch))\n    response = cast(\n        \"dict\",\n        get_thread_confluence(base_url).get(\n            \"rest/api/content/search\",\n            params={\"cql\": cql, \"limit\": len(batch), \"fields\": \"id\"},\n        ),\n    )\n    if not response:\n        return set()\n    return {str(item[\"id\"]) for item in response.get(\"results\", [])}\n\n\ndef fetch_deleted_page_ids(page_ids: list[str], base_url: str) -> set[str]:\n    \"\"\"Return the subset of *page_ids* that no longer exist in Confluence.\n\n    Uses the v2 REST API when ``connection_config.use_v2_api`` is enabled\n    (multiple ``id`` query params, up to ``export.existence_check_batch_size``\n    IDs per request), or the v1 CQL content search otherwise (capped at\n    :data:`_CQL_MAX_BATCH_SIZE` IDs per request).\n\n    Per-batch API failures are handled safely: affected IDs are assumed to\n    still exist so they are never accidentally deleted.\n    \"\"\"\n    if not page_ids:\n        return set()\n\n    use_v2 = settings.connection_config.use_v2_api\n    batch_size = settings.export.existence_check_batch_size\n    effective_batch_size = batch_size if use_v2 else min(batch_size, _CQL_MAX_BATCH_SIZE)\n    n_batches = -(-len(page_ids) // effective_batch_size)  # ceil division\n    logger.debug(\n        \"Checking existence of %d page(s) in %d batch(es) via %s API\",\n        len(page_ids),\n        n_batches,\n        \"v2\" if use_v2 else \"v1 CQL\",\n    )\n    existing: set[str] = set()\n\n    for i in range(0, len(page_ids), effective_batch_size):\n        batch = page_ids[i : i + effective_batch_size]\n        try:\n            if use_v2:\n                existing.update(_fetch_page_ids_v2_batch(batch, base_url))\n            else:\n                existing.update(_fetch_page_ids_cql_batch(batch, base_url))\n        except Exception:  # noqa: BLE001\n            logger.warning(\n                \"Failed to check page existence for batch (%d IDs). \"\n                \"Skipping deletion for these pages.\",\n                len(batch),\n            )\n            existing.update(batch)\n\n    return set(page_ids) - existing\n\n\ndef sync_removed_pages(base_url: str) -> None:\n    \"\"\"Orchestrate stale-file cleanup: check API for deleted pages, then clean up.\"\"\"\n    if not settings.export.cleanup_stale:\n        logger.debug(\"Stale page cleanup disabled — skipping.\")\n        return\n\n    unseen = LockfileManager.unseen_ids()\n    if not unseen:\n        logger.debug(\"No unseen pages in lockfile — nothing to clean up.\")\n        return\n\n    with console.status(f\"[dim]Checking {len(unseen)} unseen page(s) for removal…[/dim]\"):\n        deleted = fetch_deleted_page_ids(sorted(unseen), base_url)\n\n    if deleted:\n        logger.info(\"Removing %d stale page(s) from local export.\", len(deleted))\n    LockfileManager.remove_pages(deleted)\n\n\ndef _make_progress() -> Progress:\n    \"\"\"Build a rich Progress instance for page export.\"\"\"\n    return Progress(\n        SpinnerColumn(),\n        TextColumn(\"[progress.description]{task.description}\"),\n        BarColumn(),\n        MofNCompleteColumn(),\n        TaskProgressColumn(),\n        TimeElapsedColumn(),\n        TimeRemainingColumn(),\n        console=console,\n        transient=False,\n    )\n\n\ndef _export_page_worker(page: \"Page | Descendant\", stats: ExportStats | None = None) -> None:\n    \"\"\"Export a single Confluence page to Markdown (worker function).\n\n    Each page carries its own ``base_url`` so the correct thread-local client\n    is used automatically — no global state manipulation needed.\n\n    Args:\n        page: The page to export.\n        stats: Optional stats tracker to update on completion.\n    \"\"\"\n    _page = Page.from_id(page.id, page.base_url)\n    attachment_entries = _page.export()\n    LockfileManager.record_page(_page, attachment_entries)\n    if stats is not None:\n        stats.inc_exported()\n\n\ndef export_pages(pages: list[\"Page | Descendant\"]) -> None:\n    \"\"\"Export a list of Confluence pages to Markdown.\n\n    Pages are exported in parallel using ThreadPoolExecutor for significant\n    performance improvement. Worker count is read from\n    settings.connection_config.max_workers (default: 20).\n\n    Args:\n        pages: List of pages to export.\n    \"\"\"\n    # Mark all pages as seen so cleanup skips API checks for unchanged pages\n    LockfileManager.mark_seen([p.id for p in pages])\n    for p in pages:\n        PageTitleRegistry.register(int(p.id), p.title)\n    pages_to_export = [page for page in pages if LockfileManager.should_export(page)]\n\n    skipped_count = len(pages) - len(pages_to_export)\n    stats = reset_stats(total=len(pages))\n    for _ in range(skipped_count):\n        stats.inc_skipped()\n\n    if skipped_count:\n        logger.info(\"Skipping %d unchanged page(s).\", skipped_count)\n\n    if not pages_to_export:\n        logger.info(\"All %d page(s) unchanged — nothing to export.\", len(pages))\n        return\n\n    # Get worker count from config\n    max_workers = settings.connection_config.max_workers\n    serial = settings.export.log_level == \"DEBUG\" or max_workers <= 1\n\n    mode_label = \"serial\" if serial else f\"parallel ({max_workers} workers)\"\n    logger.debug(\"Export mode: %s, pages to export: %d\", mode_label, len(pages_to_export))\n\n    with _make_progress() as progress:\n        task = progress.add_task(\n            f\"[cyan]Exporting {len(pages_to_export)} page(s)[/cyan]\",\n            total=len(pages_to_export),\n        )\n\n        if serial:\n            for page in pages_to_export:\n                progress.update(task, description=f\"[cyan]Page {page.id}[/cyan]\")\n                try:\n                    _export_page_worker(page, stats)\n                except Exception:\n                    logger.exception(\"Failed to export page %s\", page.id)\n                    stats.inc_failed()\n                finally:\n                    progress.advance(task)\n        else:\n            with ThreadPoolExecutor(max_workers=max_workers) as executor:\n                futures = {\n                    executor.submit(_export_page_worker, page, stats): page\n                    for page in pages_to_export\n                }\n                for future in as_completed(futures):\n                    page = futures[future]\n                    try:\n                        future.result()\n                    except Exception:\n                        logger.exception(\"Failed to export page %s\", page.id)\n                        stats.inc_failed()\n                    finally:\n                        progress.advance(task)\n"
  },
  {
    "path": "confluence_markdown_exporter/main.py",
    "content": "import json\nimport logging\nimport platform\nimport sys\nimport urllib.parse\nfrom typing import Annotated\n\nimport typer\nimport typer.rich_utils\nimport yaml\nfrom rich.panel import Panel\nfrom rich.table import Table\n\nfrom confluence_markdown_exporter import __version__\nfrom confluence_markdown_exporter import config as config_module\nfrom confluence_markdown_exporter.utils.app_data_store import APP_CONFIG_PATH\nfrom confluence_markdown_exporter.utils.app_data_store import get_settings\nfrom confluence_markdown_exporter.utils.lockfile import LockfileManager\nfrom confluence_markdown_exporter.utils.measure_time import measure\nfrom confluence_markdown_exporter.utils.rich_console import console\nfrom confluence_markdown_exporter.utils.rich_console import get_rich_console\nfrom confluence_markdown_exporter.utils.rich_console import get_stats\nfrom confluence_markdown_exporter.utils.rich_console import reset_stats\nfrom confluence_markdown_exporter.utils.rich_console import setup_logging\n\ntyper.rich_utils._get_rich_console = get_rich_console\n\nlogger = logging.getLogger(__name__)\n\n\nclass _CmeTyper(typer.Typer):\n    \"\"\"Typer subclass that intercepts AuthNotConfiguredError at the app boundary.\n\n    When an export command raises AuthNotConfiguredError, the exception propagates\n    through any active console.status() context managers (stopping spinners cleanly\n    via their __exit__) before reaching here.  We then open the config menu at the\n    exact failing URL and exit — no traceback, no per-command boilerplate.\n    \"\"\"\n\n    def __call__(self, *args: object, **kwargs: object) -> None:\n        from confluence_markdown_exporter.api_clients import AuthNotConfiguredError\n\n        try:\n            super().__call__(*args, **kwargs)\n        except AuthNotConfiguredError as e:\n            from confluence_markdown_exporter.utils.config_interactive import main_config_menu_loop\n\n            console.print(\n                f\"Please configure {e.service} credentials for {e.url} and re-run the export.\"\n            )\n            main_config_menu_loop(f\"auth.{e.service.lower()}\", new_instance_url=e.url)\n            sys.exit(1)\n        except ValueError as e:\n            console.print(\n                f\"[red bold]{e}[/red bold]\\n\"\n                \"See [code]--help[/code] or [code]README.md[/code] for more information.\"\n            )\n            sys.exit(1)\n\n\n# Each list item must be its own \\n\\n-separated block so typer's epilog renderer\n# keeps single \\n between items, forming a valid markdown bullet list.\n_QUICKSTART_EPILOG = (\n    \"**Quick start:**\\n\\n\"\n    \"- Configure credentials: `cme config edit auth.confluence`\\n\\n\"\n    \"- Set output path: `cme config set export.output_path=./output`\\n\\n\"\n    \"- Export a page: `cme pages https://company.atlassian.net/wiki/spaces/KEY/pages/123/Title`\\n\\n\"\n    \"- Export a space: `cme spaces https://company.atlassian.net/wiki/spaces/MYSPACE`\\n\\n\"\n    \"- Export everything: `cme orgs https://company.atlassian.net`\\n\\n\"\n    \"- Each command also has a singular alias\"\n    \" (`page`, `space`, `org`) that behaves identically.\\n\\n\"\n)\n\n_PAGE_URL_FORMATS = (\n    \"**Supported URL formats:**\\n\\n\"\n    \"- **Cloud**: `https://company.atlassian.net/wiki/spaces/KEY/pages/123/Title`\\n\\n\"\n    \"- **Server (long)**: `https://confluence.company.com/display/KEY/Title`\\n\\n\"\n    \"- **Server (short)**: `https://confluence.company.com/KEY/Title`\\n\\n\"\n)\n\n_SPACE_URL_FORMATS = (\n    \"**Supported URL formats:**\\n\\n\"\n    \"- **Cloud**: `https://company.atlassian.net/wiki/spaces/SPACEKEY`\\n\\n\"\n    \"- **Server (long)**: `https://confluence.company.com/display/SPACEKEY`\\n\\n\"\n    \"- **Server (short)**: `https://confluence.company.com/SPACEKEY`\\n\\n\"\n)\n\napp = _CmeTyper(\n    rich_markup_mode=\"markdown\",\n    no_args_is_help=True,\n    help=(\n        \"Export Confluence pages, spaces, or entire organizations to Markdown files.\\n\\n\"\n        \"Authentication and settings are managed via `cme config`. \"\n        \"Run `cme config` to open the interactive menu, or use \"\n        \"`cme config set <key=value>` to set values directly.\\n\\n\"\n        \"Most settings can also be overridden with environment variables using the prefix \"\n        \"`CME_` and `__` as the nested delimiter \"\n        \"(e.g. `CME_EXPORT__OUTPUT_PATH=/tmp/export`).\"\n    ),\n    epilog=_QUICKSTART_EPILOG,\n)\napp.add_typer(config_module.app, name=\"config\")\n\n\ndef _init_logging() -> None:\n    \"\"\"Initialize logging from config (CME_EXPORT__LOG_LEVEL env var takes precedence).\"\"\"\n    export = get_settings().export\n    log_file = APP_CONFIG_PATH.parent / \"cme.log\" if export.save_log_to_file else None\n    setup_logging(export.log_level, log_file=log_file)\n\n\ndef _print_summary() -> None:\n    \"\"\"Print a rich summary panel with export statistics.\"\"\"\n    stats = get_stats()\n    if stats.total == 0:\n        return\n\n    output_path = get_settings().export.output_path\n\n    grid = Table.grid(padding=(0, 2))\n    grid.add_column(style=\"dim\", justify=\"right\")\n    grid.add_column()\n\n    grid.add_row(\"Pages\", \"\")\n    grid.add_row(\"  Total\", str(stats.total))\n    grid.add_row(\"  [success]Exported[/success]\", f\"[success]{stats.exported}[/success]\")\n    grid.add_row(\"  [dim]Skipped (unchanged)[/dim]\", str(stats.skipped))\n    if stats.removed:\n        grid.add_row(\"  [dim]Removed[/dim]\", str(stats.removed))\n    if stats.failed:\n        grid.add_row(\"  [error]Failed[/error]\", f\"[error]{stats.failed}[/error]\")\n\n    attachments_total = (\n        stats.attachments_exported + stats.attachments_skipped + stats.attachments_failed\n    )\n    if attachments_total or stats.attachments_removed:\n        grid.add_row(\"Attachments\", \"\")\n        if attachments_total:\n            grid.add_row(\"  Total\", str(attachments_total))\n        att_exp = stats.attachments_exported\n        grid.add_row(\"  [success]Exported[/success]\", f\"[success]{att_exp}[/success]\")\n        grid.add_row(\"  [dim]Skipped (unchanged)[/dim]\", str(stats.attachments_skipped))\n        if stats.attachments_removed:\n            grid.add_row(\"  [dim]Removed[/dim]\", str(stats.attachments_removed))\n        if stats.attachments_failed:\n            grid.add_row(\"  [error]Failed[/error]\", f\"[error]{stats.attachments_failed}[/error]\")\n\n    grid.add_row(\"Output\", str(output_path))\n\n    if stats.failed:\n        title = \"[warning]Export finished with errors[/warning]\"\n    else:\n        title = \"[success]Export complete[/success]\"\n    console.print(Panel(grid, title=title, expand=False))\n\n\n@app.command(\n    help=(\n        \"Export one or more Confluence pages by URL to Markdown.\\n\\n\"\n        \"Fetches each page via the Confluence API and writes a Markdown file to the \"\n        \"configured output directory (`export.output_path`). \"\n        \"Pages that have not changed since the last export are skipped by default \"\n        \"(`export.skip_unchanged=true`).\"\n    ),\n    epilog=(\n        \"**Examples:**\\n\\n\"\n        \"- `cme pages https://company.atlassian.net/wiki/spaces/KEY/pages/123/My+Page`\\n\\n\"\n        \"- `cme pages https://...page1 https://...page2` — export multiple pages at once\\n\\n\"\n        \"- `cme page URL` — singular alias, identical behaviour\\n\\n\"\n        \"---\\n\\n\" + _PAGE_URL_FORMATS\n    ),\n)\ndef pages(\n    page_urls: Annotated[\n        list[str],\n        typer.Argument(\n            help=(\n                \"One or more Confluence page URLs. \"\n                \"Supports Cloud and Server URL formats. \"\n                \"Example: https://company.atlassian.net/wiki/spaces/KEY/pages/123/Title\"\n            ),\n            metavar=\"PAGE_URL\",\n        ),\n    ],\n) -> None:\n    from confluence_markdown_exporter.confluence import Page\n    from confluence_markdown_exporter.confluence import sync_removed_pages\n    from confluence_markdown_exporter.utils.page_registry import PageTitleRegistry\n\n    _init_logging()\n    stats = reset_stats(total=len(page_urls))\n    with measure(f\"Export pages {', '.join(page_urls)}\"):\n        LockfileManager.init()\n\n        exported_urls: set[str] = set()\n        fetched_pages: list[Page] = []\n        for page_url in page_urls:\n            with console.status(f\"[dim]Fetching [highlight]{page_url}[/highlight]…[/dim]\"):\n                page = Page.from_url(page_url)\n            PageTitleRegistry.register(int(page.id), page.title)\n            fetched_pages.append(page)\n\n        for page in fetched_pages:\n            LockfileManager.mark_seen([page.id])\n            if not LockfileManager.should_export(page):\n                stats.inc_skipped()\n                exported_urls.add(page.base_url)\n                continue\n            try:\n                with console.status(f\"[dim]Exporting [highlight]{page.title}[/highlight]…[/dim]\"):\n                    attachment_entries = page.export()\n                LockfileManager.record_page(page, attachment_entries)\n                stats.inc_exported()\n            except Exception:\n                logger.exception(\"Failed to export page %s\", page.title)\n                stats.inc_failed()\n            exported_urls.add(page.base_url)\n\n        for base_url in exported_urls:\n            sync_removed_pages(base_url)\n\n    _print_summary()\n\n\napp.command(\n    name=\"page\",\n    help=(\n        \"Alias for `pages`. Export one or more Confluence pages by URL to Markdown.\\n\\n\"\n        \"See `cme pages --help` for full documentation and all supported URL formats.\"\n    ),\n    epilog=(\n        \"**Example:**\\n\\n\"\n        \"- `cme page https://company.atlassian.net/wiki/spaces/KEY/pages/123/My+Page`\\n\\n\"\n    ),\n)(pages)\n\n\n@app.command(\n    help=(\n        \"Export one or more Confluence pages **and all their descendants** by URL to Markdown.\\n\\n\"\n        \"Recursively fetches the given page(s) and every child page beneath them, \"\n        \"then writes Markdown files to the configured output directory. \"\n        \"Useful for exporting entire page trees without exporting a whole space.\"\n    ),\n    epilog=(\n        \"**Examples:**\\n\\n\"\n        \"- `cme pages-with-descendants https://company.atlassian.net/wiki/spaces/KEY/pages/123/Root`\\n\\n\"\n        \"- `cme pages-with-descendants https://...root1 https://...root2` — multiple trees\\n\\n\"\n        \"- `cme page-with-descendants URL` — singular alias, identical behaviour\\n\\n\"\n        \"---\\n\\n\" + _PAGE_URL_FORMATS\n    ),\n)\ndef pages_with_descendants(\n    page_urls: Annotated[\n        list[str],\n        typer.Argument(\n            help=(\n                \"One or more Confluence page URLs. \"\n                \"Each page and all its descendants will be exported. \"\n                \"Example: https://company.atlassian.net/wiki/spaces/KEY/pages/123/Title\"\n            ),\n            metavar=\"PAGE_URL\",\n        ),\n    ],\n) -> None:\n    from confluence_markdown_exporter.confluence import Page\n    from confluence_markdown_exporter.confluence import sync_removed_pages\n\n    _init_logging()\n    with measure(f\"Export pages {', '.join(page_urls)} with descendants\"):\n        LockfileManager.init()\n\n        exported_urls: set[str] = set()\n        for page_url in page_urls:\n            page = Page.from_url(page_url)\n            page.export_with_descendants()\n            exported_urls.add(page.base_url)\n\n        for base_url in exported_urls:\n            sync_removed_pages(base_url)\n\n    _print_summary()\n\n\napp.command(\n    name=\"page-with-descendants\",\n    help=(\n        \"Alias for `pages-with-descendants`. \"\n        \"Export a Confluence page and all its descendants by URL to Markdown.\\n\\n\"\n        \"See `cme pages-with-descendants --help` for full documentation.\"\n    ),\n    epilog=(\n        \"**Example:**\\n\\n\"\n        \"- `cme page-with-descendants https://company.atlassian.net/wiki/spaces/KEY/pages/123/Root`\\n\\n\"\n    ),\n)(pages_with_descendants)\n\n\n@app.command(\n    help=(\n        \"Export **all pages** in one or more Confluence spaces by URL to Markdown.\\n\\n\"\n        \"Fetches every page in each space via the Confluence API and writes Markdown files \"\n        \"to the configured output directory. \"\n        \"Pages that have not changed since the last export are skipped by default.\"\n    ),\n    epilog=(\n        \"**Examples:**\\n\\n\"\n        \"- `cme spaces https://company.atlassian.net/wiki/spaces/MYSPACE`\\n\\n\"\n        \"- `cme spaces https://...SPACE1 https://...SPACE2` — export multiple spaces\\n\\n\"\n        \"- `cme space URL` — singular alias, identical behaviour\\n\\n\"\n        \"---\\n\\n\" + _SPACE_URL_FORMATS\n    ),\n)\ndef spaces(\n    space_urls: Annotated[\n        list[str],\n        typer.Argument(\n            help=(\n                \"One or more Confluence space URLs. \"\n                \"All pages within each space will be exported. \"\n                \"Example: https://company.atlassian.net/wiki/spaces/MYSPACE\"\n            ),\n            metavar=\"SPACE_URL\",\n        ),\n    ],\n) -> None:\n    from confluence_markdown_exporter.confluence import Space\n    from confluence_markdown_exporter.confluence import sync_removed_pages\n\n    _init_logging()\n    with measure(f\"Export spaces {', '.join(space_urls)}\"):\n        LockfileManager.init()\n\n        exported_urls: set[str] = set()\n        for space_url in space_urls:\n            space = Space.from_url(space_url)\n            space.export()\n            exported_urls.add(space.base_url)\n\n        for base_url in exported_urls:\n            sync_removed_pages(base_url)\n\n    _print_summary()\n\n\napp.command(\n    name=\"space\",\n    help=(\n        \"Alias for `spaces`. Export all pages in a Confluence space by URL to Markdown.\\n\\n\"\n        \"See `cme spaces --help` for full documentation and all supported URL formats.\"\n    ),\n    epilog=(\"**Example:**\\n\\n- `cme space https://company.atlassian.net/wiki/spaces/MYSPACE`\\n\\n\"),\n)(spaces)\n\n\n@app.command(\n    help=(\n        \"Export **all spaces** of one or more Confluence organizations to Markdown.\\n\\n\"\n        \"Iterates over every space in the organization and exports all pages in each. \"\n        \"This is the broadest export scope — use `spaces` to target specific spaces, \"\n        \"or `pages` / `pages-with-descendants` for finer-grained control.\\n\\n\"\n        \"The base URL is the root of the Confluence instance, \"\n        \"e.g. `https://company.atlassian.net`.\"\n    ),\n    epilog=(\n        \"**Examples:**\\n\\n\"\n        \"- `cme orgs https://company.atlassian.net` — export everything\\n\\n\"\n        \"- `cme orgs https://company1.atlassian.net https://company2.atlassian.net`\"\n        \" — multiple orgs\\n\\n\"\n        \"- `cme org URL` — singular alias, identical behaviour\\n\\n\"\n    ),\n)\ndef orgs(\n    base_urls: Annotated[\n        list[str],\n        typer.Argument(\n            help=(\n                \"One or more Confluence base URLs (root of the instance). \"\n                \"All spaces and pages within each organization will be exported. \"\n                \"Example: https://company.atlassian.net\"\n            ),\n            metavar=\"BASE_URL\",\n        ),\n    ],\n) -> None:\n    from confluence_markdown_exporter.confluence import Organization\n    from confluence_markdown_exporter.confluence import sync_removed_pages\n\n    _init_logging()\n    with measure(\"Export all spaces\"):\n        LockfileManager.init()\n\n        for base_url in base_urls:\n            org = Organization.from_url(base_url)\n            org.export()\n            sync_removed_pages(base_url)\n\n    _print_summary()\n\n\napp.command(\n    name=\"org\",\n    help=(\n        \"Alias for `orgs`. \"\n        \"Export all spaces of a Confluence organization to Markdown.\\n\\n\"\n        \"See `cme orgs --help` for full documentation.\"\n    ),\n    epilog=(\"**Example:**\\n\\n- `cme org https://company.atlassian.net`\\n\\n\"),\n)(orgs)\n\n\n@app.command(\n    help=\"Show the installed version of confluence-markdown-exporter.\",\n)\ndef version() -> None:\n    \"\"\"Display the current version.\"\"\"\n    typer.echo(f\"confluence-markdown-exporter {__version__}\")\n\n\n_ATLASSIAN_NET = \"atlassian.net\"\n_REDACTED = \"[redacted]\"\n\n\ndef _redact_url(url: str) -> str:\n    \"\"\"Redact the instance URL.\n\n    Atlassian Cloud URLs (``*.atlassian.net``) are kept as\n    ``******.atlassian.net`` so the instance type is still visible.\n    All other URLs are fully replaced with ``[redacted]``.\n    \"\"\"\n    parsed = urllib.parse.urlparse(url)\n    host = parsed.hostname or \"\"\n    if host == _ATLASSIAN_NET or host.endswith(f\".{_ATLASSIAN_NET}\"):\n        return f\"https://******.{_ATLASSIAN_NET}\"\n    return _REDACTED\n\n\ndef _redact_config(data: dict) -> dict:\n    \"\"\"Return a deep copy of the config dict with sensitive values redacted.\n\n    Redacted fields: ``api_token``, ``pat``, ``username``, ``cloud_id`` (when non-empty),\n    ``export.output_path``, and instance URL keys in ``auth.confluence`` / ``auth.jira``.\n    \"\"\"\n    import copy\n\n    data = copy.deepcopy(data)\n    for service in (\"confluence\", \"jira\"):\n        auth_section: dict = data.get(\"auth\", {}).get(service, {})\n        redacted_section: dict = {}\n        for url, details in auth_section.items():\n            if isinstance(details, dict):\n                for field in (\"api_token\", \"pat\", \"username\", \"cloud_id\"):\n                    if details.get(field):\n                        details[field] = _REDACTED\n            redacted_section[_redact_url(url)] = details\n        data.setdefault(\"auth\", {})[service] = redacted_section\n    if data.get(\"export\", {}).get(\"output_path\"):\n        data[\"export\"][\"output_path\"] = _REDACTED\n    return data\n\n\n@app.command(\n    help=(\n        \"Print diagnostic information for filing a bug report.\\n\\n\"\n        \"Outputs the app version, Python and OS details, and the current configuration \"\n        \"with all secrets redacted (API tokens and PATs are masked; \"\n        \"instance URL hostnames are partially hidden).\\n\\n\"\n        \"Paste the full output into your GitHub issue when reporting a bug.\"\n    ),\n)\ndef bugreport() -> None:\n    \"\"\"Print version, system info, and redacted config for bug reports.\"\"\"\n    settings = get_settings()\n    config_data = json.loads(settings.model_dump_json())\n    redacted = _redact_config(config_data)\n\n    lines: list[str] = [\n        \"## Bug Report Diagnostic Info\",\n        \"\",\n        \"### Version\",\n        f\"confluence-markdown-exporter {__version__}\",\n        \"\",\n        \"### System\",\n        f\"Python: {sys.version}\",\n        f\"Platform: {platform.platform()}\",\n        f\"Architecture: {platform.machine()}\",\n        \"\",\n        \"### Config\",\n        f\"Config file: {_REDACTED}\",\n        \"```yaml\",\n        yaml.dump(redacted, default_flow_style=False, allow_unicode=True).rstrip(),\n        \"```\",\n    ]\n    typer.echo(\"\\n\".join(lines))\n\n\nif __name__ == \"__main__\":\n    app()\n"
  },
  {
    "path": "confluence_markdown_exporter/utils/__init__.py",
    "content": ""
  },
  {
    "path": "confluence_markdown_exporter/utils/app_data_store.py",
    "content": "\"\"\"Handles storage and retrieval of application data (auth and settings) for the exporter.\"\"\"\n\nimport contextlib\nimport json\nimport os\nfrom pathlib import Path\nfrom typing import Any\nfrom typing import Literal\n\nfrom pydantic import BaseModel\nfrom pydantic import Field\nfrom pydantic import SecretStr\nfrom pydantic import ValidationError\nfrom pydantic import field_serializer\nfrom pydantic import field_validator\nfrom pydantic import model_validator\nfrom pydantic_settings import BaseSettings\nfrom pydantic_settings import PydanticBaseSettingsSource\nfrom pydantic_settings import SettingsConfigDict\nfrom typer import get_app_dir\n\n\ndef get_app_config_path() -> Path:\n    \"\"\"Determine the path to the app config file, creating parent directories if needed.\"\"\"\n    config_env = os.environ.get(\"CME_CONFIG_PATH\")\n    if config_env:\n        path = Path(config_env)\n    else:\n        app_name = \"confluence-markdown-exporter\"\n        config_dir = Path(get_app_dir(app_name))\n        path = config_dir / \"app_data.json\"\n    path.parent.mkdir(parents=True, exist_ok=True)\n    return path\n\n\nAPP_CONFIG_PATH = get_app_config_path()\n\n\nclass AtlassianSdkConnectionConfig(BaseModel):\n    \"\"\"Connection parameters forwarded directly to the Atlassian SDK client constructors.\n\n    Only fields that are valid constructor keyword arguments for\n    atlassian.Confluence (ConfluenceApiSdk) and atlassian.Jira (JiraApiSdk)\n    may be added here.\n    \"\"\"\n\n    backoff_and_retry: bool = Field(\n        default=True,\n        title=\"Enable Retry\",\n        description=\"Enable or disable automatic retry with exponential backoff on network errors.\",\n    )\n    backoff_factor: int = Field(\n        default=2,\n        title=\"Backoff Factor\",\n        description=(\n            \"Multiplier for exponential backoff between retries. \"\n            \"For example, 2 means each retry waits twice as long as the previous.\"\n        ),\n    )\n    max_backoff_seconds: int = Field(\n        default=60,\n        title=\"Max Backoff Seconds\",\n        description=\"Maximum number of seconds to wait between retries.\",\n    )\n    max_backoff_retries: int = Field(\n        default=5,\n        title=\"Max Retries\",\n        description=\"Maximum number of retry attempts before giving up.\",\n    )\n    retry_status_codes: list[int] = Field(\n        default_factory=lambda: [413, 429, 502, 503, 504],\n        title=\"Retry Status Codes\",\n        description=\"HTTP status codes that should trigger a retry.\",\n    )\n    verify_ssl: bool = Field(\n        default=True,\n        title=\"Verify SSL\",\n        description=(\n            \"Whether to verify SSL certificates for HTTPS requests. \"\n            \"Set to False only if you are sure about the security of your connection.\"\n        ),\n    )\n    timeout: int = Field(\n        default=30,\n        title=\"Request Timeout\",\n        description=(\n            \"Timeout in seconds for API requests. Prevents hanging on slow/unresponsive servers.\"\n        ),\n    )\n\n\nclass ConnectionConfig(AtlassianSdkConnectionConfig):\n    \"\"\"Full connection configuration, extending the Atlassian SDK config with app-level settings.\"\"\"\n\n    use_v2_api: bool = Field(\n        default=False,\n        title=\"Use Confluence v2 REST API\",\n        description=(\n            \"Enable Confluence REST API v2 endpoints where available. \"\n            \"Supported by Atlassian Cloud and Confluence Data Center 8+. \"\n            \"Must be disabled for older self-hosted Confluence Server instances.\"\n        ),\n    )\n    max_workers: int = Field(\n        default=20,\n        title=\"Max Workers\",\n        description=(\n            \"Maximum number of parallel workers for page export. \"\n            \"Set to 1 for serial mode (useful for debugging). \"\n            \"Higher values improve performance but may hit API rate limits.\"\n        ),\n    )\n\n\nclass ApiDetails(BaseModel):\n    \"\"\"API authentication credentials for a single instance.\n\n    The instance URL is used as the dict key in AuthConfig, not stored here.\n    \"\"\"\n\n    username: SecretStr = Field(\n        default=SecretStr(\"\"),\n        title=\"Username (email)\",\n        description=\"Username or email for API authentication.\",\n    )\n    api_token: SecretStr = Field(\n        default=SecretStr(\"\"),\n        title=\"API Token\",\n        description=(\n            \"API token for authentication (if required). \"\n            \"Create an Atlassian API token at \"\n            \"https://id.atlassian.com/manage-profile/security/api-tokens. \"\n            \"See Atlassian documentation for details.\"\n        ),\n    )\n    pat: SecretStr = Field(\n        default=SecretStr(\"\"),\n        title=\"Personal Access Token (PAT)\",\n        description=(\n            \"Personal Access Token for authentication. \"\n            \"Set this if you use a PAT instead of username+API token. \"\n            \"See your Atlassian instance documentation for how to create a PAT.\"\n        ),\n    )\n    cloud_id: str = Field(\n        default=\"\",\n        title=\"Cloud ID\",\n        description=(\n            \"Atlassian Cloud ID for this instance. When set, API calls are routed through \"\n            \"the Atlassian API gateway (https://api.atlassian.com/ex/confluence/{cloud_id}), \"\n            \"which enables the use of scoped API tokens. \"\n            \"For Atlassian Cloud instances this is fetched and stored automatically. \"\n            \"To find your Cloud ID manually, see \"\n            \"https://support.atlassian.com/jira/kb/retrieve-my-atlassian-sites-cloud-id/.\"\n        ),\n    )\n\n    @field_validator(\"username\", \"api_token\", \"pat\", mode=\"before\")\n    @classmethod\n    def _single_line(cls, v: object) -> object:\n        raw = v.get_secret_value() if isinstance(v, SecretStr) else v\n        if isinstance(raw, str):\n            return raw.replace(\"\\r\", \"\").replace(\"\\n\", \"\")\n        return v\n\n    @field_serializer(\"username\", \"api_token\", \"pat\", when_used=\"json\")\n    def dump_secret(self, v: SecretStr) -> str:\n        return v.get_secret_value()\n\n\nclass AuthConfig(BaseModel):\n    \"\"\"Authentication configuration for Confluence and Jira.\n\n    Credentials are stored in dicts keyed by the instance base URL\n    (e.g. ``\"https://company.atlassian.net\"``).  No \"active\" pointer is kept —\n    the right instance is selected by matching the URL of the page or space\n    being exported.\n    \"\"\"\n\n    confluence: dict[str, ApiDetails] = Field(\n        default_factory=dict,\n        title=\"Confluence Accounts\",\n        description=(\n            \"Confluence authentication credentials keyed by instance base URL. \"\n            \"Example key: 'https://company.atlassian.net'\"\n        ),\n    )\n    jira: dict[str, ApiDetails] = Field(\n        default_factory=dict,\n        title=\"Jira Accounts\",\n        description=(\n            \"Jira authentication credentials keyed by instance base URL. \"\n            \"Example key: 'https://company.atlassian.net'\"\n        ),\n    )\n\n    @model_validator(mode=\"before\")\n    @classmethod\n    def _migrate(cls, data: object) -> object:  # noqa: C901, PLR0912\n        \"\"\"Migrate legacy config formats to the current URL-keyed dict format.\n\n        Also normalises all instance URL keys (strips trailing slashes) so that\n        entries written with and without a trailing slash are treated as identical.\n        \"\"\"\n        if not isinstance(data, dict):\n            return data\n        for service in (\"confluence\", \"jira\"):\n            val = data.get(service)\n            if not isinstance(val, dict):\n                continue\n            # Legacy v1: single ApiDetails with a 'url' field at the top level\n            # e.g. {\"url\": \"https://...\", \"username\": \"...\", ...}\n            if \"url\" in val and not _looks_like_url_keyed(val):\n                url = val.pop(\"url\", \"\") or \"\"\n                # Remove stale active_* fields that were in the same dict\n                val.pop(\"active_confluence\", None)\n                val.pop(\"active_jira\", None)\n                data[service] = {url.rstrip(\"/\"): val} if url else {}\n            # Legacy v2: named-key dict from the previous multi-instance refactor.\n            # e.g. {\"default\": {\"url\": \"https://...\", ...}, \"active_confluence\": \"default\"}\n            elif not _looks_like_url_keyed(val):\n                migrated: dict = {}\n                for k, v in val.items():\n                    if k in (\"active_confluence\", \"active_jira\"):\n                        continue\n                    if isinstance(v, dict):\n                        inner_url = v.pop(\"url\", \"\") or \"\"\n                        if inner_url:\n                            migrated[inner_url.rstrip(\"/\")] = v\n                        elif v:\n                            migrated[k] = v  # keep as-is if no URL\n                if migrated:\n                    data[service] = migrated\n            else:\n                # Current URL-keyed format: normalise any trailing slashes on existing keys\n                normalised: dict = {}\n                for k, v in val.items():\n                    normalised[k.rstrip(\"/\")] = v\n                data[service] = normalised\n        # Drop top-level active_* fields that were stored in auth\n        data.pop(\"active_confluence\", None)\n        data.pop(\"active_jira\", None)\n        return data\n\n    def get_instance(self, url: str) -> ApiDetails | None:\n        \"\"\"Return the Confluence ApiDetails whose key matches *url* (exact or host match).\"\"\"\n        url = normalize_instance_url(url)\n        return self.confluence.get(url) or self._match_by_host(self.confluence, url)\n\n    def get_jira_instance(self, url: str) -> ApiDetails | None:\n        \"\"\"Return the Jira ApiDetails whose key matches *url* (exact or host match).\"\"\"\n        url = normalize_instance_url(url)\n        return self.jira.get(url) or self._match_by_host(self.jira, url)\n\n    def default_confluence_url(self) -> str | None:\n        \"\"\"Return the URL of the only configured Confluence instance, or None if 0 or 2+.\"\"\"\n        return next(iter(self.confluence)) if len(self.confluence) == 1 else None\n\n    def default_jira_url(self) -> str | None:\n        \"\"\"Return the URL of the only configured Jira instance, or None if 0 or 2+.\"\"\"\n        return next(iter(self.jira)) if len(self.jira) == 1 else None\n\n    @staticmethod\n    def _match_by_host(instances: dict[str, ApiDetails], url: str) -> ApiDetails | None:\n        import urllib.parse\n\n        parsed = urllib.parse.urlparse(url)\n        host = parsed.hostname or url\n        # Gateway URLs must match exactly — multiple tenants share api.atlassian.com.\n        if host == \"api.atlassian.com\":\n            return None\n        for key, details in instances.items():\n            key_parsed = urllib.parse.urlparse(key)\n            # Skip gateway-style keys when doing hostname-only matching\n            if key_parsed.hostname == \"api.atlassian.com\":\n                continue\n            if key_parsed.hostname != host or key_parsed.port != parsed.port:\n                continue\n            # Key stored without a context path matches any context path on the same host\n            # (e.g. stored as \"https://host\", URL is \"https://host/confluence/spaces/...\")\n            if not key_parsed.path.strip(\"/\"):\n                return details\n            # Key stored with a context path must be a prefix of the lookup URL's path\n            # (e.g. stored as \"https://host/confluence\", URL is \"https://host/confluence/spaces/...\")\n            if parsed.path.startswith(key_parsed.path):\n                return details\n        return None\n\n\ndef _looks_like_url_keyed(d: dict) -> bool:\n    \"\"\"Return True if the dict looks like it's already keyed by URLs (not by field names).\"\"\"\n    return any(k.startswith((\"http://\", \"https://\")) for k in d)\n\n\ndef normalize_instance_url(url: str) -> str:\n    \"\"\"Strip trailing slashes from an instance URL for consistent key storage.\"\"\"\n    return url.rstrip(\"/\")\n\n\nclass ExportConfig(BaseModel):\n    \"\"\"Export settings for markdown and attachments.\"\"\"\n\n    log_level: Literal[\"DEBUG\", \"INFO\", \"WARNING\", \"ERROR\"] = Field(\n        default=\"INFO\",\n        title=\"Log Level\",\n        description=(\n            \"Controls how much output the exporter prints. \"\n            \"DEBUG shows every step, INFO shows key milestones, \"\n            \"WARNING shows only warnings and errors, ERROR shows only errors. \"\n            \"In CI environments (CI=true / NO_COLOR set) rich formatting is suppressed \"\n            \"automatically.\"\n        ),\n    )\n    save_log_to_file: bool = Field(\n        default=False,\n        title=\"Save Log To File\",\n        description=(\n            \"Also write log records to a file alongside the console output. \"\n            \"The file is named 'cme.log' and lives next to the config file \"\n            \"(see 'cme config path'). Useful for capturing long DEBUG runs.\"\n        ),\n    )\n    output_path: Path = Field(\n        default=Path(),\n        title=\"Output Path\",\n        description=(\"Directory where exported pages and attachments will be saved.\"),\n        examples=[\n            \"`.`: Output will be saved relative to the current working directory.\",\n            (\n                \"`./confluence_export`: Output will be saved in a folder `confluence_export` \"\n                \"relative to the current working directory.\"\n            ),\n            \"`/path/to/export`: Output will be saved in the specified absolute path.\",\n        ],\n    )\n    page_href: Literal[\"absolute\", \"relative\", \"wiki\"] = Field(\n        default=\"relative\",\n        title=\"Page Href Style\",\n        description=(\n            \"How to generate page href paths. Options: absolute, relative, wiki.\\n\"\n            \"  - `relative` links are relative to the page\\n\"\n            \"  - `absolute` links start from the configured output path\\n\"\n            \"  - `wiki` generates Obsidian-style [[Page Title]] wiki links\"\n        ),\n    )\n    page_path: str = Field(\n        default=\"{space_name}/{homepage_title}/{ancestor_titles}/{page_title}.md\",\n        title=\"Page Path Template\",\n        description=(\n            \"Template for exported page file paths.\\n\"\n            \"Available variables:\\n\"\n            \"  - {space_key}: The key of the Confluence space.\\n\"\n            \"  - {space_name}: The name of the Confluence space.\\n\"\n            \"  - {homepage_id}: The ID of the homepage of the Confluence space.\\n\"\n            \"  - {homepage_title}: The title of the homepage of the Confluence space.\\n\"\n            \"  - {ancestor_ids}: A slash-separated list of ancestor page IDs.\\n\"\n            \"  - {ancestor_titles}: A slash-separated list of ancestor page titles.\\n\"\n            \"  - {page_id}: The unique ID of the Confluence page.\\n\"\n            \"  - {page_title}: The title of the Confluence page.\\n\"\n        ),\n        examples=[\"{space_name}/{page_title}.md\"],\n    )\n    attachment_href: Literal[\"absolute\", \"relative\", \"wiki\"] = Field(\n        default=\"relative\",\n        title=\"Attachment Href Style\",\n        description=(\n            \"How to generate attachment href paths. Options: absolute, relative, wiki.\\n\"\n            \"  - `relative` links are relative to the page\\n\"\n            \"  - `absolute` links start from the configured output path\\n\"\n            \"  - `wiki` generates Obsidian-style ![[Attachment Name]] wiki links\"\n        ),\n    )\n    attachment_path: str = Field(\n        default=\"{space_name}/attachments/{attachment_file_id}{attachment_extension}\",\n        title=\"Attachment Path Template\",\n        description=(\n            \"Template for exported attachment file paths.\\n\"\n            \"Available variables:\\n\"\n            \"  - {space_key}: The key of the Confluence space.\\n\"\n            \"  - {space_name}: The name of the Confluence space.\\n\"\n            \"  - {homepage_id}: The ID of the homepage of the Confluence space.\\n\"\n            \"  - {homepage_title}: The title of the homepage of the Confluence space.\\n\"\n            \"  - {ancestor_ids}: A slash-separated list of ancestor page IDs.\\n\"\n            \"  - {ancestor_titles}: A slash-separated list of ancestor page titles.\\n\"\n            \"  - {attachment_id}: The unique ID of the attachment.\\n\"\n            \"  - {attachment_title}: The title of the attachment (without file extension).\\n\"\n            \"  - {attachment_file_id}: The file ID of the attachment. Falls back to \"\n            \"{attachment_id} on Confluence Data Center / Server, where the API does \"\n            \"not provide a file ID.\\n\"\n            \"  - {attachment_extension}: The file extension of the attachment,\\n\"\n            \"including the leading dot.\"\n        ),\n        examples=[\"{space_name}/attachments/{attachment_file_id}{attachment_extension}\"],\n    )\n\n    @field_validator(\"attachment_path\", mode=\"before\")\n    @classmethod\n    def _migrate_attachment_path(cls, v: object) -> object:\n        \"\"\"Migrate templates that used {attachment_title} as the full filename.\n\n        Before this change, {attachment_title} included the file extension.\n        Templates that relied on that (i.e. no explicit {attachment_extension})\n        are silently updated so file extensions are preserved.\n        \"\"\"\n        if (\n            isinstance(v, str)\n            and \"{attachment_title}\" in v\n            and \"{attachment_extension}\" not in v\n        ):\n            return v.replace(\"{attachment_title}\", \"{attachment_title}{attachment_extension}\")\n        return v\n\n    attachments_export: Literal[\"referenced\", \"all\", \"disabled\"] = Field(\n        default=\"referenced\",\n        title=\"Attachments Export\",\n        description=(\n            \"Which attachments to download to disk:\\n\"\n            \"  referenced: only attachments referenced from the page body (default)\\n\"\n            \"  all: every attachment on the page (slower, more disk and bandwidth)\\n\"\n            \"  disabled: skip the download entirely - no files written, no lockfile\\n\"\n            \"    entries, no lockfile lookup. Attachment metadata is still fetched\\n\"\n            \"    from the Confluence API so image and file links in the page body\\n\"\n            \"    continue to resolve, but the referenced files will not exist locally.\"\n        ),\n    )\n    image_captions: bool = Field(\n        default=False,\n        title=\"Image Captions\",\n        description=(\n            \"Whether to export Confluence image captions in the exported Markdown.\\n\"\n            \"When enabled, the storage format of each page is fetched via an additional \"\n            \"API expansion to extract caption text from `ac:image` elements.\\n\"\n            \"Captions are rendered as an italic line directly below the image:\\n\"\n            \"  ![](image.png)\\n\"\n            \"  *Caption text*\"\n        ),\n    )\n    page_breadcrumbs: bool = Field(\n        default=True,\n        title=\"Page Breadcrumbs\",\n        description=\"Whether to include breadcrumb links at the top of the page.\",\n    )\n    page_properties_format: Literal[\n        \"frontmatter\",\n        \"table\",\n        \"frontmatter_and_table\",\n        \"dataview-inline-field\",\n        \"meta-bind-view-fields\",\n    ] = Field(\n        default=\"frontmatter_and_table\",\n        title=\"Page Properties Format\",\n        description=(\n            \"How to render Confluence Page Properties macros (Page Properties macro).\\n\"\n            \"  frontmatter: extract to YAML front matter only (table removed from content)\\n\"\n            \"  table: keep as markdown table only (no metadata)\\n\"\n            \"  frontmatter_and_table: front matter + keep original table in content (default)\\n\"\n            \"  dataview-inline-field: replace table with Dataview Key:: Value inline fields\\n\"\n            \"  meta-bind-view-fields: front matter + Meta Bind VIEW fields inline (requires plugin)\"\n        ),\n    )\n    page_properties_report_format: Literal[\"frozen\", \"dataview\"] = Field(\n        default=\"frozen\",\n        title=\"Page Properties Report Format\",\n        description=(\n            \"How to render Confluence Page Properties Report macros.\\n\"\n            \"  frozen: export the rendered table as a static markdown table (default)\\n\"\n            \"  dataview: translate the CQL query to an Obsidian Dataview DQL code block;\\n\"\n            \"    requires the Dataview plugin and all referenced child pages to be exported\\n\"\n            \"    with their page properties as frontmatter; falls back to frozen on failure\"\n        ),\n    )\n    confluence_url_in_frontmatter: Literal[\"none\", \"webui\", \"tinyui\", \"both\"] = Field(\n        default=\"none\",\n        title=\"Confluence URL in Front Matter\",\n        description=(\n            \"Whether to include the original Confluence page URL in YAML front matter.\\n\"\n            \"  none: do not include (default)\\n\"\n            \"  webui: include human-readable URL as `confluence_webui_url`\\n\"\n            \"  tinyui: include stable short permalink as `confluence_tinyui_url`\\n\"\n            \"  both: include both fields\\n\"\n            \"If a Page Properties macro already defines one of these keys, \"\n            \"the macro value takes precedence.\"\n        ),\n    )\n    page_metadata_in_frontmatter: bool = Field(\n        default=False,\n        title=\"Page Metadata in Front Matter\",\n        description=(\n            \"If True, add eight Confluence page metadata fields to the YAML \"\n            \"front matter of each exported page: confluence_page_id, \"\n            \"confluence_space_key, confluence_type (page or blogpost), \"\n            \"confluence_created (ISO 8601, original creation timestamp), \"\n            \"confluence_created_by (display name of the original author), \"\n            \"confluence_last_modified (ISO 8601, value of the most recent \"\n            \"version including minor edits), confluence_last_modified_by \"\n            \"(display name), confluence_version (integer). Existing keys \"\n            \"with the same name on the page (e.g. via a Page Properties \"\n            \"macro) take precedence.\"\n        ),\n    )\n\n    @model_validator(mode=\"before\")\n    @classmethod\n    def _migrate_page_properties(cls, data: object) -> object:\n        \"\"\"Migrate legacy page_properties_as_front_matter bool to page_properties_format.\"\"\"\n        if not isinstance(data, dict):\n            return data\n        old_val = data.pop(\"page_properties_as_front_matter\", None)\n        if old_val is not None and \"page_properties_format\" not in data:\n            if str(old_val).lower() in (\"false\", \"0\"):\n                data[\"page_properties_format\"] = \"table\"\n            else:\n                data[\"page_properties_format\"] = \"frontmatter\"\n        return data\n\n    @model_validator(mode=\"before\")\n    @classmethod\n    def _migrate_attachments_export(cls, data: object) -> object:\n        \"\"\"Migrate legacy attachment_export_all bool to attachments_export literal.\"\"\"\n        if not isinstance(data, dict):\n            return data\n        old_val = data.pop(\"attachment_export_all\", None)\n        if old_val is not None and \"attachments_export\" not in data:\n            data[\"attachments_export\"] = (\n                \"all\" if str(old_val).lower() in (\"true\", \"1\") else \"referenced\"\n            )\n        return data\n\n    @model_validator(mode=\"before\")\n    @classmethod\n    def _migrate_inline_comments(cls, data: object) -> object:\n        \"\"\"Migrate legacy inline_comments bool to comments_export literal.\"\"\"\n        if not isinstance(data, dict):\n            return data\n        old_val = data.pop(\"inline_comments\", None)\n        if old_val is not None and \"comments_export\" not in data:\n            data[\"comments_export\"] = (\n                \"inline\" if str(old_val).lower() in (\"true\", \"1\") else \"none\"\n            )\n        return data\n\n    filename_encoding: str = Field(\n        default='\"<\":\"_\",\">\":\"_\",\":\":\"_\",\"\\\\\"\":\"_\",\"/\":\"_\",\"\\\\\\\\\":\"_\",\"|\":\"_\",\"?\":\"_\",\"*\":\"_\",\"\\\\u0000\":\"_\",\"[\":\"_\",\"]\":\"_\",\"\\'\":\"_\",\"’\":\"_\",\"´\":\"_\",\"`\":\"_\"',  # noqa: RUF001\n        title=\"Filename Encoding\",\n        description=(\n            \"List character-to-replacement pairs, separated by commas. \"\n            'Each pair is written as \"character\":\"replacement\". '\n            \"Leave empty to disable all character replacements.\"\n        ),\n        examples=[\n            '\" \":\"-\",\"-\":\"%2D\"',  # Replace spaces with dash and dashes with %2D\n            '\"=\":\" equals \"',  # Replace equals sign with \" equals \"\n        ],\n    )\n    filename_length: int = Field(\n        default=255,\n        title=\"Filename Length\",\n        description=\"Maximum length of the filename.\",\n    )\n    filename_lowercase: bool = Field(\n        default=False,\n        title=\"Enforce lowercase paths\",\n        description=(\n            \"Make all paths/files lowercase.\\nBy default the original casing will be retained.\\n\"\n        ),\n    )\n    include_document_title: bool = Field(\n        default=True,\n        title=\"Include Document Title\",\n        description=(\n            \"Whether to include the document title in the exported markdown file. \"\n            \"If enabled, the title will be added as a top-level heading.\"\n        ),\n    )\n    include_toc: bool = Field(\n        default=True,\n        title=\"Export Table of Contents\",\n        description=(\n            \"Whether to export the Confluence Table of Contents macro. \"\n            \"When enabled (default), the TOC is converted to markdown. \"\n            \"When disabled, the TOC macro is removed from the output.\"\n        ),\n    )\n    include_macro: Literal[\"inline\", \"transclusion\"] = Field(\n        default=\"inline\",\n        title=\"Include Macro Rendering\",\n        description=(\n            \"How to render Confluence `include` and `excerpt-include` macros.\\n\"\n            \"  inline: expand the referenced page content inline (default)\\n\"\n            \"  transclusion: emit an Obsidian-style `![[Page Title]]` embed link;\\n\"\n            \"    the referenced page must also be exported for the link to resolve\"\n        ),\n    )\n    enable_jira_enrichment: bool = Field(\n        default=True,\n        title=\"Enable Jira Enrichment\",\n        description=(\n            \"Whether to fetch Jira issue data to enrich Confluence pages. \"\n            \"When enabled, Jira issue links will include the issue summary. \"\n            \"When disabled, only the issue key and link will be included. \"\n            \"Requires Jira auth to be configured.\"\n        ),\n    )\n    comments_export: Literal[\"none\", \"inline\", \"footer\", \"all\"] = Field(\n        default=\"none\",\n        title=\"Export Comments\",\n        description=(\n            \"Which comments to export to a sidecar '.comments.md' file placed \"\n            \"next to the exported page file. \"\n            \"'none' — no sidecar. \"\n            \"'inline' — open inline comments only (annotated text shown as a \"\n            \"blockquote, then author/date/body). \"\n            \"'footer' — open page-level (footer) comments only. \"\n            \"'all' — both, in a single sidecar with two sections \"\n            \"('## Inline comments' first, then '## Page comments'). \"\n            \"Resolved comments are skipped. Replies are listed flat below \"\n            \"the parent comment. Disabled by default — adds one to two extra \"\n            \"API calls per page when enabled.\"\n        ),\n    )\n    convert_status_badges: bool = Field(\n        default=True,\n        title=\"Convert Status Badges\",\n        description=(\n            \"Whether to convert Confluence status badge macros \"\n            \"(<span class=\\\"status-macro ...\\\"/>) \"\n            \"to HTML <mark> elements coloured with the badge's background colour. \"\n            \"When disabled, only the badge label text is kept.\"\n        ),\n    )\n    convert_text_highlights: bool = Field(\n        default=True,\n        title=\"Convert Text Highlights\",\n        description=(\n            \"Whether to convert Confluence text highlights \"\n            \"(<span style=\\\"background-color: rgb(...);\\\"/>) \"\n            \"to HTML <mark> elements with a hex color. \"\n            \"When disabled, the highlight span is stripped and only the text is kept.\"\n        ),\n    )\n    convert_font_colors: bool = Field(\n        default=True,\n        title=\"Convert Font Colors\",\n        description=(\n            \"Whether to convert Confluence font colors \"\n            \"(<span data-colorid=\\\"...\\\"/> or <span style=\\\"color: rgb(...);\\\"/>) \"\n            \"to HTML <font> elements with a hex color. \"\n            \"When disabled, the color span is stripped and only the text is kept.\"\n        ),\n    )\n    skip_unchanged: bool = Field(\n        default=True,\n        title=\"Skip Unchanged Pages\",\n        description=(\n            \"Skip exporting pages that have not changed since last export.\"\n            \" Uses a lockfile to track page versions.\"\n        ),\n    )\n    cleanup_stale: bool = Field(\n        default=True,\n        title=\"Cleanup Stale Files\",\n        description=(\n            \"After export, delete local files for pages that have been removed \"\n            \"from Confluence or whose export path has changed.\"\n        ),\n    )\n    lockfile_name: str = Field(\n        default=\"confluence-lock.json\",\n        title=\"Lock File Name\",\n        description=\"Name of the lock file used to track exported pages.\",\n    )\n    existence_check_batch_size: int = Field(\n        default=250,\n        title=\"Existence Check Batch Size\",\n        description=(\n            \"Number of page IDs per batch when verifying page existence during cleanup. \"\n            \"For self-hosted Confluence (CQL), this is internally capped at 25.\"\n        ),\n    )\n\n\nclass ConfigModel(BaseModel):\n    \"\"\"Top-level application configuration model (used for persistence only).\"\"\"\n\n    export: ExportConfig = Field(default_factory=ExportConfig, title=\"Export Settings\")\n    connection_config: ConnectionConfig = Field(\n        default_factory=ConnectionConfig, title=\"Connection Configuration\"\n    )\n    auth: AuthConfig = Field(default_factory=AuthConfig, title=\"Authentication\")\n\n\nclass _JsonConfigSource(PydanticBaseSettingsSource):\n    \"\"\"Settings source that reads from the JSON config file (lower priority than ENV vars).\"\"\"\n\n    def get_field_value(self, field: Any, field_name: str) -> Any:  # noqa: ANN401\n        return None, field_name, False\n\n    def field_is_complex(self, field: Any) -> bool:  # noqa: ANN401\n        return True\n\n    def __call__(self) -> dict[str, Any]:\n        if APP_CONFIG_PATH.exists():\n            try:\n                raw = json.loads(APP_CONFIG_PATH.read_text(encoding=\"utf-8\"))\n                return ConfigModel(**raw).model_dump()\n            except Exception:  # noqa: BLE001\n                return ConfigModel().model_dump()\n        return ConfigModel().model_dump()\n\n\nclass AppSettings(BaseSettings):\n    \"\"\"Effective application settings: ENV vars take precedence over the config file.\n\n    ENV vars use the prefix ``CME_`` and double-underscore (``__``) as the nested field\n    delimiter, matching the dot-notation config keys but uppercased.  For example::\n\n        CME_EXPORT__LOG_LEVEL=DEBUG\n        CME_EXPORT__OUTPUT_PATH=/tmp/export\n        CME_CONNECTION_CONFIG__MAX_WORKERS=5\n        CME_CONNECTION_CONFIG__VERIFY_SSL=false\n    \"\"\"\n\n    model_config = SettingsConfigDict(\n        env_prefix=\"CME_\",\n        env_nested_delimiter=\"__\",\n        extra=\"ignore\",\n        populate_by_name=True,\n    )\n\n    export: ExportConfig = Field(default_factory=ExportConfig, title=\"Export Settings\")\n    connection_config: ConnectionConfig = Field(\n        default_factory=ConnectionConfig, title=\"Connection Configuration\"\n    )\n    auth: AuthConfig = Field(default_factory=AuthConfig, title=\"Authentication\")\n\n    @classmethod\n    def settings_customise_sources(\n        cls,\n        settings_cls: type[BaseSettings],\n        init_settings: PydanticBaseSettingsSource,\n        env_settings: PydanticBaseSettingsSource,\n        dotenv_settings: PydanticBaseSettingsSource,  # noqa: ARG003\n        file_secret_settings: PydanticBaseSettingsSource,  # noqa: ARG003\n    ) -> tuple[PydanticBaseSettingsSource, ...]:\n        \"\"\"ENV vars override JSON file config; init values override both.\"\"\"\n        return (init_settings, env_settings, _JsonConfigSource(settings_cls))\n\n\ndef load_app_data() -> dict[str, dict]:\n    \"\"\"Load application data from the config file, returning a validated dict.\"\"\"\n    data: dict = {}\n    if APP_CONFIG_PATH.exists():\n        with contextlib.suppress(json.JSONDecodeError, ValueError):\n            data = json.loads(APP_CONFIG_PATH.read_text(encoding=\"utf-8\"))\n    try:\n        return ConfigModel(**data).model_dump()\n    except ValidationError:\n        return ConfigModel().model_dump()\n\n\ndef save_app_data(config_model: ConfigModel) -> None:\n    \"\"\"Save application data to the config file using Pydantic serialization.\"\"\"\n    # Use Pydantic's model_dump_json which properly handles SecretStr serialization\n    json_str = config_model.model_dump_json(indent=2)\n    APP_CONFIG_PATH.write_text(json_str, encoding=\"utf-8\")\n\n\ndef get_settings() -> AppSettings:\n    \"\"\"Get the effective application settings (ENV vars override stored config).\"\"\"\n    return AppSettings()\n\n\ndef _set_by_path(obj: dict, path: str, value: object) -> None:\n    \"\"\"Set a value in a nested dict using dot notation path.\"\"\"\n    keys = path.split(\".\")\n    current = obj\n    for k in keys[:-1]:\n        if k not in current or not isinstance(current[k], dict):\n            current[k] = {}\n        current = current[k]\n    current[keys[-1]] = value\n\n\ndef _set_by_keys(obj: dict, keys: list[str], value: object) -> None:\n    \"\"\"Set a value in a nested dict using an explicit list of key components.\"\"\"\n    current = obj\n    for k in keys[:-1]:\n        if k not in current or not isinstance(current[k], dict):\n            current[k] = {}\n        current = current[k]\n    current[keys[-1]] = value\n\n\ndef set_setting(path: str, value: object) -> None:\n    \"\"\"Set a setting by dot-path and save to config file.\"\"\"\n    data = load_app_data()\n    _set_by_path(data, path, value)\n    try:\n        settings = ConfigModel.model_validate(data)\n    except ValidationError as e:\n        raise ValueError(str(e)) from e\n    save_app_data(settings)\n\n\ndef set_setting_with_keys(keys: list[str], value: object) -> None:\n    \"\"\"Set a setting by an explicit list of path components and save to config file.\n\n    Use this instead of ``set_setting`` when any path component contains dots\n    (e.g. a URL used as a dict key: ``[\"auth\", \"confluence\", \"https://x.y\", \"username\"]``).\n    \"\"\"\n    data = load_app_data()\n    _set_by_keys(data, keys, value)\n    try:\n        settings = ConfigModel.model_validate(data)\n    except ValidationError as e:\n        raise ValueError(str(e)) from e\n    save_app_data(settings)\n\n\ndef get_default_value_by_path(path: str | None = None) -> object:\n    \"\"\"Get the default value for a given config path, or the whole config if path is None.\"\"\"\n    model = ConfigModel()\n    if not path:\n        return model.model_dump()\n    keys = path.split(\".\")\n    current = model\n    for k in keys:\n        if hasattr(current, k):\n            current = getattr(current, k)\n        elif isinstance(current, dict) and k in current:\n            current = current[k]\n        else:\n            msg = f\"Invalid config path: {path}\"\n            raise KeyError(msg)\n    if isinstance(current, BaseModel):\n        return current.model_dump()\n    return current\n\n\ndef reset_to_defaults(path: str | None = None) -> None:\n    \"\"\"Reset the whole config, a section, or a single option to its default value.\n\n    If path is None, reset the entire config. Otherwise, reset the specified path.\n    \"\"\"\n    if path is None:\n        save_app_data(ConfigModel())\n        return\n    data = load_app_data()\n    default_value = get_default_value_by_path(path)\n    _set_by_path(data, path, default_value)\n    settings = ConfigModel.model_validate(data)\n    save_app_data(settings)\n"
  },
  {
    "path": "confluence_markdown_exporter/utils/config_interactive.py",
    "content": "from pathlib import Path\nfrom typing import Literal\nfrom typing import get_args\nfrom typing import get_origin\n\nimport jmespath\nimport questionary\nfrom pydantic import BaseModel\nfrom pydantic import SecretStr\nfrom pydantic import ValidationError\nfrom questionary import Choice\nfrom questionary import Style\n\nfrom confluence_markdown_exporter.api_clients import ensure_service_gateway_url\nfrom confluence_markdown_exporter.utils.app_data_store import ConfigModel\nfrom confluence_markdown_exporter.utils.app_data_store import get_app_config_path\nfrom confluence_markdown_exporter.utils.app_data_store import get_settings\nfrom confluence_markdown_exporter.utils.app_data_store import reset_to_defaults\nfrom confluence_markdown_exporter.utils.app_data_store import save_app_data\nfrom confluence_markdown_exporter.utils.app_data_store import set_setting\nfrom confluence_markdown_exporter.utils.app_data_store import set_setting_with_keys\n\ncustom_style = Style(\n    [\n        (\"key\", \"fg:#00b8d4 bold\"),  # cyan bold for key\n        (\"value\", \"fg:#888888 italic\"),  # gray italic for value\n        (\"pointer\", \"fg:#00b8d4 bold\"),\n        (\"highlighted\", \"fg:#00b8d4 bold\"),\n    ]\n)\n\n\ndef _get_field_type(model: type[BaseModel], key: str) -> type | None:\n    # Handles both Pydantic v1 and v2\n    if hasattr(model, \"model_fields\"):  # v2\n        return model.model_fields[key].annotation\n    return model.__annotations__[key]\n\n\ndef _get_submodel(model: type[BaseModel], key: str) -> type[BaseModel] | None:\n    if hasattr(model, \"model_fields\"):\n        sub = model.model_fields[key].annotation\n    else:\n        sub = model.__annotations__[key]\n    # Only return submodel if it's a subclass of BaseModel\n    if isinstance(sub, type):\n        try:\n            if issubclass(sub, BaseModel):\n                return sub\n        except TypeError:\n            # sub is not a class or not suitable for issubclass\n            return None\n    return None\n\n\ndef _get_field_metadata(model: type[BaseModel], key: str) -> dict:\n    # Support jmespath-style dot-separated paths for nested fields\n    if \".\" in key:\n        keys = key.split(\".\")\n        key = keys[-1]\n\n    # Returns dict with title, description, examples for a field\n    if hasattr(model, \"model_fields\"):  # Pydantic v2\n        field = model.model_fields[key]\n        return {\n            \"title\": getattr(field, \"title\", None),\n            \"description\": getattr(field, \"description\", None),\n            \"examples\": getattr(field, \"examples\", None),\n        }\n    # Pydantic v1 fallback\n    field = model.model_fields[key]\n    return {\n        \"title\": getattr(field, \"title\", None),\n        \"description\": getattr(field, \"description\", None),\n        \"examples\": getattr(field, \"example\", None),\n    }\n\n\ndef _format_prompt_message(key_name: str, model: type[BaseModel]) -> str:\n    meta = _get_field_metadata(model, key_name)\n    lines = []\n    # Title\n    if meta[\"title\"]:\n        lines.append(f\"{meta['title']}\\n\")\n    else:\n        lines.append(f\"{key_name}\\n\")\n\n    # Description\n    if meta[\"description\"]:\n        lines.append(meta[\"description\"])\n\n    # Examples\n    if meta[\"examples\"]:\n        ex = meta[\"examples\"]\n        if isinstance(ex, list | tuple) and ex:\n            lines.append(\"\\nExamples:\")\n            lines.extend(f\"  • {example}\" for example in ex)\n    # Instruction\n    lines.append(f\"\\nChange {meta['title']} to:\")\n    return \"\\n\".join(lines)\n\n\ndef _validate_int(val: str) -> bool | str:\n    return val.isdigit() or \"Must be an integer\"\n\n\ndef _validate_pydantic(val: object, model: type[BaseModel], key_name: str) -> bool | str:\n    try:\n        data = model().model_dump()\n        data[key_name] = val\n        model(**data)\n    except ValidationError as e:\n        return str(e.errors()[0][\"msg\"])\n    else:\n        return True\n\n\ndef _prompt_literal(prompt_message: str, field_type: type, current_value: object) -> object:\n    options = list(get_args(field_type))\n    return questionary.select(\n        prompt_message,\n        choices=[str(opt) for opt in options],\n        default=str(current_value),\n        style=custom_style,\n    ).ask()\n\n\ndef _prompt_bool(prompt_message: str, current_value: object) -> object:\n    return questionary.confirm(\n        prompt_message, default=bool(current_value), style=custom_style\n    ).ask()\n\n\ndef _prompt_path(\n    prompt_message: str,\n    current_value: object,\n    model: type[BaseModel],\n    key_name: str,\n) -> object:\n    return questionary.path(\n        prompt_message,\n        default=str(current_value),\n        validate=lambda val: _validate_pydantic(val, model, key_name),\n        style=custom_style,\n    ).ask()\n\n\ndef _prompt_int(prompt_message: str, current_value: object) -> object:\n    answer = questionary.text(\n        prompt_message,\n        default=str(current_value),\n        validate=_validate_int,\n        style=custom_style,\n    ).ask()\n    if answer is not None:\n        try:\n            return int(answer)\n        except ValueError:\n            questionary.print(\"Invalid integer value.\")\n    return None\n\n\ndef _prompt_list(prompt_message: str, current_value: object) -> object:\n    default_val = \"\"\n    val_type = str\n    if isinstance(current_value, list):\n        default_val = \",\".join(map(str, current_value))\n        if len(current_value) > 0:\n            val_type = type(current_value[0])\n    answer = questionary.text(\n        prompt_message + \" (comma-separated)\",\n        default=default_val,\n        style=custom_style,\n    ).ask()\n    if answer is not None:\n        answer = answer.strip().lstrip(\"[\").rstrip(\"]\").strip(\",\").replace(\" \", \"\")\n        try:\n            return [val_type(x.strip()) for x in answer.split(\",\") if x.strip()]\n        except ValueError:\n            questionary.print(\"Input should be a list (e.g. 1,2,3 or [1,2,3]).\")\n    return None\n\n\ndef _prompt_str(\n    prompt_message: str,\n    current_value: object,\n    model: type[BaseModel],\n    key_name: str,\n) -> object:\n    return questionary.text(\n        prompt_message,\n        default=str(current_value),\n        validate=lambda val: _validate_pydantic(val, model, key_name),\n        style=custom_style,\n    ).ask()\n\n\ndef get_model_by_path(model: type[BaseModel], path: str) -> type[BaseModel]:\n    \"\"\"Traverse a Pydantic model class using a dot-separated path and return the submodel class.\"\"\"\n    keys = path.split(\".\")\n    for key in keys:\n        # Try direct submodel first\n        sub = _get_submodel(model, key)\n        if sub is not None:\n            model = sub\n            continue\n        # Try dict[str, SomeModel] — the key may be a field name or an instance name\n        if hasattr(model, \"model_fields\") and key in model.model_fields:\n            dict_sub = _get_dict_value_model(model, key)\n            if dict_sub is not None:\n                model = dict_sub\n                continue\n        # key is an instance name inside a dict[str, SomeModel] — model stays the same\n    return model\n\n\ndef _get_dict_value_model(model: type[BaseModel], key: str) -> type[BaseModel] | None:\n    \"\"\"If the field annotation is dict[str, SomeModel], return SomeModel; else None.\"\"\"\n    if hasattr(model, \"model_fields\"):\n        annotation = model.model_fields[key].annotation\n    else:\n        annotation = model.__annotations__.get(key)\n    if annotation is None:\n        return None\n    origin = get_origin(annotation)\n    if origin is dict:\n        args = get_args(annotation)\n        if len(args) == 2 and isinstance(args[1], type):  # noqa: PLR2004\n            try:\n                if issubclass(args[1], BaseModel):\n                    return args[1]\n            except TypeError:\n                pass\n    return None\n\n\ndef _edit_instance_fields(  # noqa: C901, PLR0912\n    instance_key: str,\n    instance_data: dict,\n    item_model: type[BaseModel],\n    parent_path_parts: list[str],\n) -> str | None:\n    \"\"\"Edit the fields of a single named instance using set_setting_with_keys.\n\n    This avoids the dot-split path system so URL keys (which contain dots)\n    work correctly.\n\n    Returns ``\"__remove__\"`` if the user chose to remove this instance, else ``None``.\n    \"\"\"\n    selected_field: str | None = None\n    while True:\n        choices = []\n        for k, v in instance_data.items():\n            if v is None:\n                continue\n            try:\n                meta = _get_field_metadata(item_model, k)\n                display_title = meta[\"title\"] if meta and meta[\"title\"] else k\n            except (KeyError, AttributeError):\n                display_title = k\n            display_val = \"Not set\" if isinstance(v, str | SecretStr) and str(v) == \"\" else v\n            choices.append(\n                Choice(\n                    title=[\n                        (\"class:key\", str(display_title)),\n                        (\"class:value\", f\"  {display_val}\"),\n                    ],\n                    value=k,\n                )\n            )\n        choices.append(Choice(title=\"[Remove]\", value=\"__remove__\"))\n        choices.append(Choice(title=\"[Back]\", value=\"__back__\"))\n        field_key = questionary.select(\n            f\"Edit credentials for '{instance_key}':\",\n            choices=choices,\n            style=custom_style,\n            default=selected_field,\n        ).ask()\n        if field_key == \"__back__\" or field_key is None:\n            return None\n        if field_key == \"__remove__\":\n            confirm = questionary.confirm(\n                f\"Remove instance '{instance_key}'?\", default=False, style=custom_style\n            ).ask()\n            if confirm:\n                return \"__remove__\"\n            continue\n        selected_field = field_key\n        current_val = instance_data.get(field_key)\n        while True:\n            new_val = _prompt_for_new_value(field_key, current_val, item_model)\n            if new_val is not None:\n                try:\n                    set_setting_with_keys([*parent_path_parts, instance_key, field_key], new_val)\n                    instance_data[field_key] = new_val\n                    questionary.print(f\"Updated {field_key}.\")\n                    # Offer cross-service sync for auth credential fields\n                    if len(parent_path_parts) >= 2 and parent_path_parts[0] == \"auth\":  # noqa: PLR2004\n                        _maybe_sync_auth_change(\n                            parent_path_parts[1], instance_key, field_key, new_val, current_val\n                        )\n                    break\n                except (ValueError, TypeError) as e:\n                    questionary.print(f\"Error: {e}\")\n                    retry = questionary.confirm(\"Try again?\", style=custom_style).ask()\n                    if not retry:\n                        break\n            else:\n                break\n\n\n_SERVICE_PAIRS = {\"confluence\": \"jira\", \"jira\": \"confluence\"}\n\n\ndef _maybe_sync_new_instance(instance_url: str, parent_path_parts: list[str]) -> None:\n    \"\"\"After configuring a new instance, offer to copy its credentials to the paired service.\n\n    Only applicable when the parent path is ``auth.confluence`` or ``auth.jira``.\n    \"\"\"\n    if len(parent_path_parts) < 2 or parent_path_parts[0] != \"auth\":  # noqa: PLR2004\n        return\n    service = parent_path_parts[1]\n    other_service = _SERVICE_PAIRS.get(service)\n    if not other_service:\n        return\n\n    from confluence_markdown_exporter.api_clients import ensure_service_gateway_url\n\n    target_url = ensure_service_gateway_url(instance_url, other_service)\n    should_sync = questionary.confirm(\n        f\"Also save the same credentials for {other_service.capitalize()} at '{target_url}'?\",\n        default=True,\n        style=custom_style,\n    ).ask()\n    if not should_sync:\n        return\n\n    settings = get_settings().model_dump()\n    source: dict = settings\n    for k in parent_path_parts:\n        source = source[k]\n    entry = source.get(instance_url)\n    if entry:\n        set_setting_with_keys([\"auth\", other_service, target_url], entry)\n        questionary.print(f\"auth.{other_service}.{target_url} updated to match.\")\n\n\ndef _edit_instance_dict_loop(  # noqa: C901, PLR0912, PLR0915\n    instances: dict,\n    item_model: type[BaseModel],\n    parent_key: str,\n    new_instance_url: str | None = None,\n) -> None:\n    \"\"\"Interactive loop for managing a dict[str, BaseModel] (URL-keyed instances).\n\n    When *new_instance_url* is provided the loop skips the selection list and jumps\n    directly to editing that specific URL (creating a blank entry first if needed).\n    This is used when an export command detects missing auth for a known URL.\n    \"\"\"\n    parent_path_parts = parent_key.split(\".\")\n\n    # If a specific URL was requested, jump straight to its editor and then return.\n    if new_instance_url:\n        new_instance_url = new_instance_url.strip().rstrip(\"/\")\n        if new_instance_url not in instances:\n            blank = item_model()\n            set_setting_with_keys([*parent_path_parts, new_instance_url], blank.model_dump())\n            instances[new_instance_url] = blank.model_dump()\n        current_val = instances.get(new_instance_url, {})\n        if not isinstance(current_val, dict):\n            current_val = current_val.model_dump()  # type: ignore[union-attr]\n        result = _edit_instance_fields(new_instance_url, current_val, item_model, parent_path_parts)\n        if result == \"__remove__\":\n            instances.pop(new_instance_url, None)\n            current = get_settings().model_dump()\n            sub: dict = current\n            for k in parent_path_parts:\n                sub = sub[k]\n            sub.pop(new_instance_url, None)\n            save_app_data(ConfigModel.model_validate(current))\n        else:\n            _maybe_sync_new_instance(new_instance_url, parent_path_parts)\n        return\n\n    while True:\n        choices = [\n            Choice(title=[(\"class:key\", instance_url)], value=(\"edit\", instance_url))\n            for instance_url in instances\n        ]\n        choices.append(Choice(title=\"[Add instance]\", value=(\"add\", None)))\n        choices.append(Choice(title=\"[Back]\", value=(\"back\", None)))\n\n        action, instance_url = questionary.select(\n            f\"Manage instances for '{parent_key}':\",\n            choices=choices,\n            style=custom_style,\n        ).ask() or (\"back\", None)\n\n        if action == \"back\" or action is None:\n            return\n\n        if action == \"add\":\n            new_url = questionary.text(\n                \"Enter the base URL for the new instance (e.g. https://company.atlassian.net):\",\n                validate=lambda v: (\n                    \"URL cannot be empty\"\n                    if not v.strip()\n                    else \"Instance already exists\"\n                    if v.strip() in instances\n                    else True\n                ),\n                style=custom_style,\n            ).ask()\n            if new_url:\n                new_url = new_url.strip().rstrip(\"/\")\n                new_instance = item_model()\n                set_setting_with_keys([*parent_path_parts, new_url], new_instance.model_dump())\n                instances[new_url] = new_instance.model_dump()\n            continue\n\n        if action == \"edit\" and instance_url:\n            current_val = instances.get(instance_url, {})\n            if not isinstance(current_val, dict):\n                current_val = current_val.model_dump()  # type: ignore[union-attr]\n            result = _edit_instance_fields(\n                instance_url,\n                current_val,\n                item_model,\n                parent_path_parts,\n            )\n            if result == \"__remove__\":\n                instances.pop(instance_url, None)\n                current = get_settings().model_dump()\n                sub: dict = current\n                for k in parent_path_parts:\n                    sub = sub[k]\n                sub.pop(instance_url, None)\n                save_app_data(ConfigModel.model_validate(current))\n                continue\n            # Refresh from disk\n            updated = get_settings().model_dump()\n            sub = updated\n            for k in parent_path_parts:\n                sub = sub[k]\n            instances[instance_url] = sub.get(instance_url, current_val)\n\n\ndef _main_config_menu(settings: dict, default: tuple[str, bool] | None = None) -> tuple:\n    choices = []\n    for k, v in settings.items():\n        meta = _get_field_metadata(ConfigModel, k)\n        display_title = meta[\"title\"] if meta and meta[\"title\"] else k\n        if isinstance(v, dict):\n            choices.append(\n                Choice(\n                    title=[\n                        (\"class:key\", str(display_title)),\n                        (\"class:value\", \"  [submenu]\"),\n                    ],\n                    value=(k, True),\n                )\n            )\n        else:\n            display_val = \"Not set\" if isinstance(v, str | SecretStr) and str(v) == \"\" else v\n            choices.append(\n                Choice(\n                    title=[\n                        (\"class:key\", str(display_title)),\n                        (\"class:value\", f\"  {display_val}\"),\n                    ],\n                    value=(k, False),\n                )\n            )\n    choices.append(Choice(title=\"[Reset config to defaults]\", value=(\"__reset__\", False)))\n    choices.append(Choice(title=\"[Exit]\", value=(\"__exit__\", False)))\n    # Find the matching Choice value for default\n    default_value = None\n    if default is not None:\n        for c in choices:\n            if hasattr(c, \"value\") and c.value == default:\n                default_value = c.value\n                break\n    return questionary.select(\n        f\"Config file location: {get_app_config_path()}\\n\\nSelect a config to change (or reset):\",\n        choices=choices,\n        style=custom_style,\n        default=default_value,\n    ).ask() or (None, False)\n\n\ndef _prompt_for_new_value(  # noqa: PLR0911\n    key_name: str,\n    current_value: object,\n    model: type[BaseModel],\n) -> object:\n    field_type = _get_field_type(model, key_name)\n    origin = get_origin(field_type)\n    prompt_message = _format_prompt_message(key_name, model)\n    if field_type is None:\n        field_type = str  # Default to string if no type found\n    if origin is Literal:\n        return _prompt_literal(prompt_message, field_type, current_value)\n    if field_type is bool:\n        return _prompt_bool(prompt_message, current_value)\n    if field_type is Path:\n        return _prompt_path(prompt_message, current_value, model, key_name)\n    if field_type is int:\n        return _prompt_int(prompt_message, current_value)\n    if field_type is list or origin is list:\n        return _prompt_list(prompt_message, current_value)\n    if isinstance(current_value, SecretStr):\n        return _prompt_str(prompt_message, current_value.get_secret_value(), model, key_name)\n    return _prompt_str(prompt_message, current_value, model, key_name)\n\n\ndef _maybe_sync_auth_change(\n    service: str,\n    instance_url: str,\n    key: str,\n    value_cast: object,\n    previous_value: object,\n) -> None:\n    \"\"\"After changing an auth credential, offer to sync it to the paired service instance.\n\n    Args:\n        instance_url: The URL key of the instance being edited (may contain dots).\n        service: ``\"confluence\"`` or ``\"jira\"``.\n        key: The field name that changed (``\"username\"``, ``\"api_token\"``, or ``\"pat\"``).\n        value_cast: The new value.\n        previous_value: The old value (used to skip the prompt when was empty before).\n    \"\"\"\n    if service == \"confluence\":\n        other_service = \"Jira\"\n        other_service_key = \"jira\"\n    elif service == \"jira\":\n        other_service = \"Confluence\"\n        other_service_key = \"confluence\"\n    else:\n        return\n\n    # Only ask when replacing an existing (non-empty) value\n    if isinstance(previous_value, SecretStr):\n        if not previous_value.get_secret_value():\n            return\n    elif not previous_value:\n        return\n\n    instance_url = ensure_service_gateway_url(instance_url, other_service_key)\n    should_sync = questionary.confirm(\n        f\"Also apply this {key} change to the {other_service} instance '{instance_url}'?\",\n        default=True,\n        style=custom_style,\n    ).ask()\n    if should_sync:\n        try:\n            set_setting_with_keys([\"auth\", other_service_key, instance_url, key], value_cast)\n            questionary.print(f\"auth.{other_service_key}.{instance_url}.{key} updated to match.\")\n        except (ValueError, TypeError) as e:\n            questionary.print(f\"Could not sync to {other_service}: {e}\")\n\n\ndef _reset_and_reload(parent_key: str | None, display_title: str | None = None) -> None:\n    \"\"\"Reset config (whole or section) and reload config_dict from disk, with confirmation.\"\"\"\n    if parent_key is None:\n        confirm_msg = \"Are you sure you want to reset all config to defaults?\"\n    else:\n        confirm_msg = f\"Are you sure you want to reset section '{display_title}' to defaults?\"\n    confirm = questionary.confirm(confirm_msg, style=custom_style).ask()\n    if not confirm:\n        return\n    reset_to_defaults(parent_key or None)\n    updated = get_settings().model_dump()\n    if parent_key:\n        # Traverse to the correct nested dict for jmespath/dot-paths\n        keys = parent_key.split(\".\")\n        sub = updated\n        for k in keys:\n            sub = sub[k]\n        # Optionally, update sub in place if needed (here, just to trigger reload/print)\n    else:\n        for k in list(updated.keys()):\n            updated[k] = updated[k]\n    if display_title:\n        questionary.print(f\"Section '{display_title}' reset to defaults.\")\n    else:\n        questionary.print(\"Config reset to defaults.\")\n\n\ndef _get_choices(config_dict: dict, model: type[BaseModel]) -> list:\n    choices = []\n    for k, v in config_dict.items():\n        if v is None:\n            continue\n        meta = _get_field_metadata(model, k)\n        display_title = meta[\"title\"] if meta and meta[\"title\"] else k\n        if isinstance(v, dict):\n            choices.append(\n                Choice(\n                    title=[\n                        (\"class:key\", str(display_title)),\n                        (\"class:value\", \"  [submenu]\"),\n                    ],\n                    value=k,\n                )\n            )\n        else:\n            display_val = \"Not set\" if isinstance(v, str | SecretStr) and str(v) == \"\" else v\n            choices.append(\n                Choice(\n                    title=[\n                        (\"class:key\", str(display_title)),\n                        (\"class:value\", f\"  {display_val}\"),\n                    ],\n                    value=k,\n                )\n            )\n    choices.append(Choice(title=\"[Reset this group to defaults]\", value=\"__reset_section__\"))\n    choices.append(Choice(title=\"[Back]\", value=\"__back__\"))\n    return choices\n\n\ndef _edit_dict_config_loop(  # noqa: C901, PLR0912, PLR0915\n    config_dict: dict,\n    model: type[BaseModel],\n    parent_key: str,\n    parent_model: type[BaseModel],\n    last_selected: str | None = None,\n) -> str | None:\n    selected_key = last_selected\n    while True:\n        choices = _get_choices(config_dict, model)\n        meta = None\n        if hasattr(parent_model, \"model_fields\") and parent_key:\n            meta = _get_field_metadata(parent_model, parent_key)\n        display_title = meta[\"title\"] if meta and meta[\"title\"] else parent_key\n        key = questionary.select(\n            f\"Edit options for '{display_title}':\",\n            choices=choices,\n            style=custom_style,\n            default=selected_key,\n        ).ask()\n        if key == \"__back__\" or key is None:\n            return selected_key\n        if key == \"__reset_section__\":\n            _reset_and_reload(parent_key, display_title)\n            # Reload the updated config_dict for this section from disk\n            updated = get_settings().model_dump()\n            if parent_key:\n                # Traverse to the correct nested dict for jmespath/dot-paths\n                keys = parent_key.split(\".\")\n                sub = updated\n                for k in keys:\n                    sub = sub[k]\n                config_dict.clear()\n                config_dict.update(sub)\n            else:\n                config_dict.clear()\n                config_dict.update(updated)\n            selected_key = None\n            continue\n        current_value = config_dict[key] if key else None\n        # Check for dict[str, BaseModel] (named instances, e.g. auth.confluence)\n        dict_value_model = _get_dict_value_model(model, key)\n        if isinstance(current_value, dict) and dict_value_model is not None:\n            _edit_instance_dict_loop(\n                current_value,\n                dict_value_model,\n                f\"{parent_key}.{key}\" if parent_key else key,\n            )\n            selected_key = key\n            # Might have updated other service auth config\n            # Reload the updated config_dict for this section from disk\n            updated = get_settings().model_dump()\n            if parent_key:\n                # Traverse to the correct nested dict for jmespath/dot-paths\n                keys = parent_key.split(\".\")\n                sub = updated\n                for k in keys:\n                    sub = sub[k]\n                config_dict.clear()\n                config_dict.update(sub)\n            else:\n                config_dict.clear()\n                config_dict.update(updated)\n            continue\n        submodel = _get_submodel(model, key)\n        if isinstance(current_value, dict) and submodel is not None:\n            # Always set selected_key to the submenu key after returning\n            _edit_dict_config_loop(\n                current_value,\n                submodel,\n                f\"{parent_key}.{key}\" if parent_key else key,\n                model,\n                last_selected=None,\n            )\n            selected_key = key\n        else:\n            while True:\n                value_cast = _prompt_for_new_value(key, current_value, model)\n                if value_cast is not None:\n                    try:\n                        set_setting(f\"{parent_key}.{key}\" if parent_key else key, value_cast)\n                        config_dict[key] = value_cast\n                        questionary.print(f\"{parent_key}.{key} updated to {value_cast}.\")\n                        selected_key = key\n                        break\n                    except (ValueError, TypeError) as e:\n                        questionary.print(f\"Error: {e}\")\n                        retry = questionary.confirm(\"Try again?\", style=custom_style).ask()\n                        if not retry:\n                            break\n                else:\n                    break\n            # After editing, keep cursor at this entry\n            selected_key = key\n\n\ndef _edit_dict_config(\n    config_dict: dict,\n    model: type[BaseModel],\n    parent_key: str,\n    parent_model: type[BaseModel],\n    last_selected: str | None = None,\n) -> str | None:\n    return _edit_dict_config_loop(config_dict, model, parent_key, parent_model, last_selected)\n\n\ndef main_config_menu_loop(  # noqa: C901, PLR0912\n    jump_to: str | None = None,\n    new_instance_url: str | None = None,\n) -> None:\n    settings = get_settings().model_dump()\n    if jump_to:\n        submenu = jmespath.search(jump_to, settings)\n        preselect: str | None = None\n        if not isinstance(submenu, dict):\n            # jump_to points to a leaf value — open its parent section with cursor on that item\n            leaf_key = jump_to.rsplit(\".\", 1)[-1]\n            jump_to = jump_to.rsplit(\".\", 1)[0] if \".\" in jump_to else jump_to\n            submenu = jmespath.search(jump_to, settings)\n            preselect = leaf_key\n        parent_path = jump_to.rsplit(\".\", 1)[0] if \".\" in jump_to else None\n        parent_model = get_model_by_path(ConfigModel, parent_path) if parent_path else ConfigModel\n        # If jump_to resolves to a dict[str, BaseModel] field (URL-keyed instances such as\n        # auth.confluence), delegate directly to the instance-dict editor so that\n        # URL keys are never mistaken for Pydantic field names.\n        last_segment = jump_to.rsplit(\".\", 1)[-1] if \".\" in jump_to else jump_to\n        dict_value_model = _get_dict_value_model(parent_model, last_segment)\n        if dict_value_model is not None and isinstance(submenu, dict):\n            _edit_instance_dict_loop(\n                submenu, dict_value_model, jump_to, new_instance_url=new_instance_url\n            )\n            return\n        submodel = get_model_by_path(ConfigModel, jump_to)\n        _edit_dict_config(submenu, submodel, jump_to, parent_model, last_selected=preselect)\n        return\n    last_selected = None\n    while True:\n        settings = get_settings().model_dump()\n        key, is_dict = _main_config_menu(settings, default=last_selected)\n        if key == \"__reset__\":\n            _reset_and_reload(None)\n            last_selected = None\n            continue\n        if key == \"__exit__\" or key is None:\n            break\n        last_selected = (key, is_dict)\n        current_value = settings[key]\n        if is_dict:\n            submodel = _get_submodel(ConfigModel, key)\n            if submodel is not None:\n                returned_key = _edit_dict_config(\n                    current_value, submodel, key, ConfigModel, last_selected=None\n                )\n                last_selected = (key, is_dict) if returned_key is None else (returned_key, True)\n        else:\n            while True:\n                value_cast = _prompt_for_new_value(key, current_value, ConfigModel)\n                if value_cast is None or value_cast == current_value:\n                    # User cancelled or made no change: do not update config\n                    break\n                try:\n                    set_setting(key, value_cast)\n                    questionary.print(f\"{key} updated to {value_cast}.\")\n                    last_selected = (key, is_dict)\n                    break\n                except (ValueError, TypeError) as e:\n                    questionary.print(f\"Error: {e}\")\n                    retry = questionary.confirm(\"Try again?\", style=custom_style).ask()\n                    if not retry:\n                        break\n"
  },
  {
    "path": "confluence_markdown_exporter/utils/drawio_converter.py",
    "content": "\"\"\"Utility module for parsing DrawIO files and extracting mermaid diagrams.\"\"\"\n\nimport html\nimport json\nimport logging\nfrom pathlib import Path\nfrom typing import cast\n\nfrom bs4 import BeautifulSoup\n\nlogger = logging.getLogger(__name__)\n\n\ndef load_drawio_file(file_path: str | Path) -> str | None:\n    \"\"\"Load and parse a DrawIO XML file.\n\n    Args:\n        file_path: Path to the DrawIO file (.drawio)\n\n    Returns:\n        The XML content as a string, or None if file doesn't exist\n    \"\"\"\n    file_path = Path(file_path)\n    if not file_path.exists():\n        return None\n\n    return file_path.read_text(encoding=\"utf-8\")\n\n\ndef extract_mermaid_data(xml_content: str) -> str | None:\n    \"\"\"Extract mermaid data from DrawIO XML.\n\n    Args:\n        xml_content: The XML content as a string.\n\n    Returns:\n        The extracted mermaid data string or None if not found.\n    \"\"\"\n    try:\n        soup = BeautifulSoup(xml_content, \"xml\")\n        # Search for UserObject tag (XML parser preserves case)\n        user_object = soup.find(\"UserObject\")\n        if user_object is None:\n            return None\n        try:\n            attrs = cast(\n                \"dict[str, str]\",\n                user_object.attrs,  # type: ignore[attr-defined]\n            )\n            # XML parser preserves attribute case as mermaidData\n            mermaid_data_attr = attrs.get(\"mermaidData\")\n            if mermaid_data_attr is None:\n                return None\n            # Unescape HTML entities if present\n            return html.unescape(mermaid_data_attr)\n        except AttributeError:\n            return None\n    except Exception:  # pylint: disable=broad-except\n        logger.exception(\"Error extracting mermaid data from DrawIO XML\")\n        return None\n\n\ndef parse_mermaid_json(mermaid_data: str) -> str | None:\n    \"\"\"Parse mermaid data from JSON format and extract the diagram definition.\n\n    The mermaid data is often stored as JSON with a \"data\" field containing\n    the actual mermaid diagram as a string.\n\n    Args:\n        mermaid_data: The raw mermaid data string (may be JSON-formatted)\n\n    Returns:\n        The mermaid diagram string, or the input if already in plain format\n    \"\"\"\n    try:\n        # Try to parse as JSON\n        parsed = json.loads(mermaid_data)\n        if isinstance(parsed, dict) and \"data\" in parsed:\n            return parsed[\"data\"]\n    except (json.JSONDecodeError, TypeError):\n        # If not JSON, return as-is (already a plain diagram string)\n        pass\n\n    return mermaid_data\n\n\ndef format_mermaid_markdown(mermaid_diagram: str) -> str:\n    \"\"\"Format mermaid diagram as a markdown code fence.\n\n    Args:\n        mermaid_diagram: The mermaid diagram definition\n\n    Returns:\n        Formatted markdown code fence containing the mermaid diagram\n    \"\"\"\n    return f\"```mermaid\\n{mermaid_diagram}\\n```\"\n\n\ndef load_and_parse_drawio(file_path: str | Path) -> str | None:\n    \"\"\"Load a DrawIO file and extract mermaid diagram as markdown.\n\n    This is the main entry point that orchestrates the full process:\n    1. Load the DrawIO XML file\n    2. Extract mermaidData from UserObject\n    3. Parse JSON format if needed\n    4. Format as markdown code fence\n\n    Args:\n        file_path: Path to the DrawIO file (.drawio)\n\n    Returns:\n        Formatted markdown code fence with mermaid diagram, or None if not found/error\n    \"\"\"\n    # Load the DrawIO file\n    xml_content = load_drawio_file(file_path)\n    if xml_content is None:\n        return None\n\n    # Extract mermaid data from XML\n    mermaid_data = extract_mermaid_data(xml_content)\n    if mermaid_data is None:\n        return None\n\n    # Parse mermaid data (handle JSON format)\n    mermaid_diagram = parse_mermaid_json(mermaid_data)\n    if mermaid_diagram is None:\n        return None\n\n    # Format as markdown\n    result = format_mermaid_markdown(mermaid_diagram)\n    logger.debug(\"Extracted mermaid diagram from %s\", file_path)\n    return result\n"
  },
  {
    "path": "confluence_markdown_exporter/utils/export.py",
    "content": "import json\nimport logging\nimport re\nfrom pathlib import Path\n\nfrom confluence_markdown_exporter.utils.app_data_store import get_settings\n\nlogger = logging.getLogger(__name__)\n\nsettings = get_settings()\nexport_options = settings.export\n\n\ndef parse_encode_setting(encode_setting: str) -> dict[str, str]:\n    \"\"\"Parse encoding setting containing character mapping.\n\n    Args:\n        encode_setting: JSON object content without braces\n            '\"char1\":\"replacement1\",\"char2\":\"replacement2\"'\n\n    Returns:\n        Dictionary mapping characters to their replacements\n\n    Examples:\n        \"\" -> {}\n        '\" \":\"%2D\",\"-\":\"%2D\"' -> {\" \": \"%2D\", \"-\": \"%2D\"}\n        '\" \":\"dash\",\"-\":\"%2D\"' -> {\" \": \"dash\", \"-\": \"%2D\"}\n        '\"=\":\" equals \"' -> {\"=\": \" equals \"}\n\n    Note:\n        Uses JSON format for mapping to handle all characters unambiguously.\n        Curly braces are added automatically before parsing.\n    \"\"\"\n    if not encode_setting:\n        return {}\n\n    # Add curly braces to make it valid JSON\n    json_str = f\"{{{encode_setting}}}\"\n\n    # Use JSON parsing for robust and unambiguous parsing\n    try:\n        mapping = json.loads(json_str)\n        if isinstance(mapping, dict):\n            return mapping\n    except (json.JSONDecodeError, TypeError):\n        # Fallback: if parsing fails, return empty mapping\n        pass\n\n    return {}\n\n\ndef save_file(file_path: Path, content: str | bytes) -> None:\n    \"\"\"Save content to a file, creating parent directories as needed.\"\"\"\n    file_path.parent.mkdir(parents=True, exist_ok=True)\n    if isinstance(content, bytes):\n        with file_path.open(\"wb\") as file:\n            file.write(content)\n    elif isinstance(content, str):\n        with file_path.open(\"w\", encoding=\"utf-8\") as file:\n            file.write(content)\n    else:\n        msg = \"Content must be either a string or bytes.\"\n        raise TypeError(msg)\n    logger.debug(\"Saved file %s (%d bytes)\", file_path, len(content))\n\n\ndef sanitize_filename(filename: str) -> str:\n    \"\"\"Sanitize a filename for cross-platform compatibility.\n\n    Replaces characters based on encoding mapping,\n    trims trailing spaces and dots, and prevents reserved names.\n\n    Args:\n        filename: The original filename.\n\n    Returns:\n        A sanitized filename string.\n    \"\"\"\n    sanitized = filename\n\n    # Strip control characters (ASCII 0x00-0x1F, 0x7F) invalid on Windows/Linux\n    sanitized = re.sub(r\"[\\x00-\\x1f\\x7f]\", \"\", sanitized)\n\n    if export_options.filename_encoding:\n        encode_map = parse_encode_setting(export_options.filename_encoding)\n\n        # Create pattern from all characters that have mappings\n        if encode_map:\n            chars_to_encode = \"\".join(encode_map.keys())\n            encode_re = escape_character_class(chars_to_encode)\n            encode_pattern = re.compile(f\"[{encode_re}]\")\n\n            def map_char(m: re.Match[str]) -> str:\n                char = m.group(0)\n                return encode_map[char]\n\n            sanitized = re.sub(encode_pattern, map_char, sanitized)\n\n    # Trim spaces and dots from the end\n    sanitized = sanitized.rstrip(\" .\")\n\n    # Reserved Windows names (case-insensitive)\n    reserved = {\n        \"CON\",\n        \"PRN\",\n        \"AUX\",\n        \"NUL\",\n        *(f\"COM{i}\" for i in range(1, 10)),\n        *(f\"LPT{i}\" for i in range(1, 10)),\n    }\n\n    name = Path(sanitized).stem.upper()\n    if name in reserved:\n        sanitized = f\"{sanitized}_\"\n\n    if export_options.filename_lowercase:\n        sanitized = sanitized.lower()\n\n    # Limit length to specificed number of characters\n    return sanitized[: export_options.filename_length]\n\n\ndef sanitize_key(s: str, connector: str = \"_\") -> str:\n    \"\"\"Convert an input string to a valid Python/YAML-compatible key.\n\n    - Lowercase the string.\n    - Replace non-alphanumeric characters with underscores.\n    - Collapse multiple underscores into one.\n    - Trim leading/trailing underscores.\n    - Prefix with 'key_' if the first character is not a letter or underscore.\n    \"\"\"\n    s = s.lower()\n    s = re.sub(f\"[^a-z0-9{connector}]\", connector, s)\n    s = re.sub(f\"{connector}+\", connector, s)\n    s = s.strip(connector)\n    if not re.match(r\"^[a-z]\", s):\n        s = f\"key{connector}{s}\"\n    return s\n\n\ndef github_heading_slug(text: str) -> str:\n    \"\"\"Generate a GitHub-compatible heading anchor slug.\n\n    Matches the github-slugger algorithm used by GitHub to render heading anchors,\n    so that generated TOC links resolve correctly in GitHub-rendered Markdown.\n    \"\"\"\n    text = text.lower().strip()\n    text = re.sub(r\"[^\\w\\s-]\", \"\", text)  # drop punctuation; keep letters, digits, spaces, hyphens\n    text = re.sub(r\"[\\s_]+\", \"-\", text)   # whitespace/underscores → hyphens\n    return re.sub(r\"-{2,}\", \"-\", text)    # collapse runs of hyphens (e.g. \"- word\" → \"-word\")\n\n\ndef escape_character_class(s: str) -> str:\n    \"\"\"Escape characters for use in a regex character class.\n\n    Args:\n        s: The string containing characters to escape.\n\n    Returns:\n        The input string with special regex character class characters escaped.\n    \"\"\"\n    # Escape backslash first, then other special characters for character classes\n    return s.replace(\"\\\\\", r\"\\\\\").replace(\"-\", r\"\\-\").replace(\"]\", r\"\\]\").replace(\"^\", r\"\\^\")\n"
  },
  {
    "path": "confluence_markdown_exporter/utils/lockfile.py",
    "content": "\"\"\"Lock file handling for tracking exported Confluence pages.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nimport tempfile\nimport threading\nfrom datetime import datetime\nfrom datetime import timezone\nfrom pathlib import Path\nfrom typing import TYPE_CHECKING\nfrom typing import ClassVar\n\nfrom pydantic import BaseModel\nfrom pydantic import Field\nfrom pydantic import ValidationError\n\nfrom confluence_markdown_exporter.utils.page_registry import PageTitleRegistry\nfrom confluence_markdown_exporter.utils.rich_console import get_stats\n\nif TYPE_CHECKING:\n    from confluence_markdown_exporter.confluence import Descendant\n    from confluence_markdown_exporter.confluence import Page\n\nlogger = logging.getLogger(__name__)\n\nLOCKFILE_VERSION = 2\n\n\nclass AttachmentEntry(BaseModel):\n    \"\"\"Entry for a single attachment tracked in the lock file.\"\"\"\n\n    version: int\n    path: str\n\n\nclass PageEntry(BaseModel):\n    \"\"\"Entry for a single page in the lock file.\"\"\"\n\n    title: str\n    version: int\n    export_path: str\n    attachments: dict[str, AttachmentEntry] = Field(default_factory=dict)\n\n\nclass SpaceEntry(BaseModel):\n    \"\"\"Lock file entry for a Confluence space.\"\"\"\n\n    pages: dict[str, PageEntry] = Field(default_factory=dict)\n\n\nclass OrgEntry(BaseModel):\n    \"\"\"Lock file entry for a Confluence organisation (base URL).\"\"\"\n\n    spaces: dict[str, SpaceEntry] = Field(default_factory=dict)\n\n\nclass ConfluenceLock(BaseModel):\n    \"\"\"Lock file tracking exported Confluence data.\"\"\"\n\n    lockfile_version: int = Field(default=LOCKFILE_VERSION)\n    last_export: str = Field(default=\"\")\n    orgs: dict[str, OrgEntry] = Field(default_factory=dict)\n\n    @classmethod\n    def load(cls, lockfile_path: Path) -> ConfluenceLock:\n        \"\"\"Load lock file from disk, or return empty if not exists or outdated.\"\"\"\n        if lockfile_path.exists():\n            try:\n                content = lockfile_path.read_text(encoding=\"utf-8\")\n                data = json.loads(content)\n                if data.get(\"lockfile_version\", 1) < LOCKFILE_VERSION:\n                    logger.info(\n                        \"Lock file format is outdated (v%s → v%s). Starting fresh.\",\n                        data.get(\"lockfile_version\", 1),\n                        LOCKFILE_VERSION,\n                    )\n                    return cls()\n                return cls.model_validate(data)\n            except (ValidationError, json.JSONDecodeError):\n                logger.warning(\"Failed to parse lock file: %s. Starting fresh.\", lockfile_path)\n        return cls()\n\n    def all_pages(self) -> dict[str, PageEntry]:\n        \"\"\"Return all page entries as a flat dict keyed by page ID.\"\"\"\n        result: dict[str, PageEntry] = {}\n        for org in self.orgs.values():\n            for space in org.spaces.values():\n                result.update(space.pages)\n        return result\n\n    def get_page(self, page_id: str) -> PageEntry | None:\n        \"\"\"Return the PageEntry for *page_id*, searching all orgs and spaces.\"\"\"\n        for org in self.orgs.values():\n            for space in org.spaces.values():\n                if page_id in space.pages:\n                    return space.pages[page_id]\n        return None\n\n    def remove_page(self, page_id: str) -> None:\n        \"\"\"Remove *page_id* from whichever org/space entry holds it.\"\"\"\n        for org in self.orgs.values():\n            for space in org.spaces.values():\n                space.pages.pop(page_id, None)\n\n    def add_page(\n        self,\n        page: Page,\n        attachment_entries: dict[str, AttachmentEntry] | None = None,\n    ) -> None:\n        \"\"\"Add or update a page entry, placed under its org and space.\"\"\"\n        if page.version is None:\n            logger.warning(\"Page %s has no version info. Skipping lock entry.\", page.id)\n            return\n\n        org_url = page.base_url\n        space_key = page.space.key\n\n        if org_url not in self.orgs:\n            self.orgs[org_url] = OrgEntry()\n        if space_key not in self.orgs[org_url].spaces:\n            self.orgs[org_url].spaces[space_key] = SpaceEntry()\n\n        self.orgs[org_url].spaces[space_key].pages[str(page.id)] = PageEntry(\n            title=page.title,\n            version=page.version.number,\n            export_path=str(page.export_path),\n            attachments=attachment_entries or {},\n        )\n\n    def save(  # noqa: C901\n        self, lockfile_path: Path, *, delete_ids: set[str] | None = None\n    ) -> None:\n        \"\"\"Save lock file to disk.\n\n        To handle concurrent writes, this method reads the existing lock file\n        and merges it with the current state before saving.\n        \"\"\"\n        lockfile_path.parent.mkdir(parents=True, exist_ok=True)\n\n        # Read existing lock file and merge to handle concurrent writes\n        existing = ConfluenceLock.load(lockfile_path)\n        for org_url, org_entry in self.orgs.items():\n            if org_url not in existing.orgs:\n                existing.orgs[org_url] = OrgEntry()\n            for space_key, space_entry in org_entry.spaces.items():\n                if space_key not in existing.orgs[org_url].spaces:\n                    existing.orgs[org_url].spaces[space_key] = SpaceEntry()\n                existing.orgs[org_url].spaces[space_key].pages.update(space_entry.pages)\n\n        if delete_ids:\n            for page_id in delete_ids:\n                existing.remove_page(page_id)\n\n        # Sort for deterministic output\n        for org in existing.orgs.values():\n            for space in org.spaces.values():\n                space.pages = dict(sorted(space.pages.items()))\n            org.spaces = dict(sorted(org.spaces.items()))\n        existing.orgs = dict(sorted(existing.orgs.items()))\n\n        existing.last_export = datetime.now(timezone.utc).isoformat()\n\n        json_str = json.dumps(existing.model_dump(), indent=2, ensure_ascii=False)\n        tmp_path = None\n        try:\n            with tempfile.NamedTemporaryFile(\n                mode=\"w\",\n                dir=lockfile_path.parent,\n                suffix=\".tmp\",\n                delete=False,\n                encoding=\"utf-8\",\n            ) as fd:\n                tmp_path = Path(fd.name)\n                fd.write(json_str)\n            try:\n                tmp_path.replace(lockfile_path)\n            except PermissionError:\n                # Windows: MoveFileExW(MOVEFILE_REPLACE_EXISTING) can fail when\n                # security software holds the destination. Fall back to non-atomic\n                # unlink + rename.\n                lockfile_path.unlink(missing_ok=True)\n                tmp_path.rename(lockfile_path)\n        except BaseException:\n            if tmp_path is not None:\n                tmp_path.unlink(missing_ok=True)\n            raise\n\n        # Update self to reflect merged state\n        self.orgs = existing.orgs\n        self.last_export = existing.last_export\n\n\nclass LockfileManager:\n    \"\"\"Manager for lock file operations during export.\"\"\"\n\n    _lockfile_path: ClassVar[Path | None] = None\n    _lock: ClassVar[ConfluenceLock | None] = None\n    _output_path: ClassVar[Path | None] = None\n    _all_entries_snapshot: ClassVar[dict[str, PageEntry]] = {}\n    _seen_page_ids: ClassVar[set[str]] = set()\n    _thread_lock: ClassVar[threading.Lock] = threading.Lock()\n\n    @classmethod\n    def init(cls) -> None:\n        \"\"\"Initialize the lockfile manager if skip_unchanged is enabled.\"\"\"\n        from confluence_markdown_exporter.utils.app_data_store import get_settings\n\n        settings = get_settings()\n        if not settings.export.skip_unchanged:\n            return\n\n        cls._output_path = settings.export.output_path\n        cls._lockfile_path = cls._output_path / settings.export.lockfile_name\n        cls._lock = ConfluenceLock.load(cls._lockfile_path)\n        cls._all_entries_snapshot = dict(cls._lock.all_pages())\n        cls._seen_page_ids = set()\n        PageTitleRegistry.reset()\n        for pid, entry in cls._all_entries_snapshot.items():\n            try:\n                PageTitleRegistry.register(int(pid), entry.title)\n            except (TypeError, ValueError):\n                continue\n        logger.debug(\n            \"Lockfile initialized: %s (%d tracked page(s))\",\n            cls._lockfile_path,\n            len(cls._all_entries_snapshot),\n        )\n\n    @classmethod\n    def get_page_attachment_entries(cls, page_id: str) -> dict[str, AttachmentEntry]:\n        \"\"\"Return attachment entries for *page_id* from the lock file, or empty dict.\"\"\"\n        if cls._lock is None:\n            return {}\n        entry = cls._lock.get_page(page_id)\n        return entry.attachments if entry else {}\n\n    @classmethod\n    def record_page(\n        cls,\n        page: Page,\n        attachment_entries: dict[str, AttachmentEntry] | None = None,\n    ) -> None:\n        \"\"\"Record a page export to the lock file.\"\"\"\n        if cls._lock is None or cls._lockfile_path is None:\n            return\n\n        with cls._thread_lock:\n            cls._lock.add_page(page, attachment_entries)\n            cls._lock.save(cls._lockfile_path)\n            cls._seen_page_ids.add(str(page.id))\n        PageTitleRegistry.register(int(page.id), page.title)\n\n    @classmethod\n    def mark_seen(cls, page_ids: list[int]) -> None:\n        \"\"\"Mark page IDs as seen in the current export run.\n\n        This avoids unnecessary API existence checks during cleanup for pages\n        that were encountered but skipped (e.g. unchanged pages).\n        \"\"\"\n        cls._seen_page_ids.update(str(pid) for pid in page_ids)\n\n    @classmethod\n    def should_export(cls, page: Page | Descendant) -> bool:\n        \"\"\"Check if a page should be exported based on lockfile state.\n\n        Returns True if the page should be exported (not in lockfile or changed).\n        \"\"\"\n        if cls._lock is None:\n            return True\n\n        page_id = str(page.id)\n        entry = cls._lock.get_page(page_id)\n        if entry is None:\n            logger.debug(\"Page id=%s not in lockfile — will export\", page_id)\n            return True\n\n        if page.version is None:\n            logger.debug(\"Page id=%s has no version info — will export\", page_id)\n            return True\n\n        # Re-export if the output file is missing from disk\n        if cls._output_path is not None and not (cls._output_path / entry.export_path).exists():\n            logger.debug(\"Page id=%s output file missing — will re-export\", page_id)\n            return True\n\n        # Export if version or export_path has changed\n        if entry.version != page.version.number or entry.export_path != str(page.export_path):\n            logger.debug(\n                \"Page id=%s changed (v%s -> v%s) — will export\",\n                page_id,\n                entry.version,\n                page.version.number,\n            )\n            return True\n\n        logger.debug(\"Page id=%s unchanged (v%s) — skipping\", page_id, entry.version)\n        return False\n\n    @classmethod\n    def unseen_ids(cls) -> set[str]:\n        \"\"\"Return lockfile page IDs not encountered during the current export run.\"\"\"\n        if cls._lock is None:\n            return set()\n        return set(cls._lock.all_pages().keys()) - cls._seen_page_ids\n\n    @classmethod\n    def remove_pages(cls, deleted_ids: set[str]) -> None:\n        \"\"\"Remove files and lockfile entries for moved or deleted pages.\n\n        Args:\n            deleted_ids: Page IDs confirmed as deleted from Confluence.\n        \"\"\"\n        if cls._lock is None or cls._lockfile_path is None or cls._output_path is None:\n            return\n\n        result_delete_ids: set[str] = set()\n\n        # Handle moved pages: delete old file when export_path changed\n        for page_id in cls._seen_page_ids:\n            if page_id in cls._all_entries_snapshot:\n                old_entry = cls._all_entries_snapshot[page_id]\n                new_entry = cls._lock.get_page(page_id)\n                if new_entry and old_entry.export_path != new_entry.export_path:\n                    (cls._output_path / old_entry.export_path).unlink(missing_ok=True)\n                    logger.info(\"Deleted old path for moved page: %s\", old_entry.export_path)\n\n        # Remove files and lockfile entries for pages deleted from Confluence\n        for page_id in deleted_ids:\n            entry = cls._lock.get_page(page_id)\n            if entry:\n                (cls._output_path / entry.export_path).unlink(missing_ok=True)\n                logger.info(\"Deleted removed page: %s\", entry.export_path)\n                result_delete_ids.add(page_id)\n\n        if result_delete_ids:\n            with cls._thread_lock:\n                cls._lock.save(cls._lockfile_path, delete_ids=result_delete_ids)\n\n        stats = get_stats()\n        for _ in result_delete_ids:\n            stats.inc_removed()\n"
  },
  {
    "path": "confluence_markdown_exporter/utils/measure_time.py",
    "content": "import logging\nimport time\nfrom collections.abc import Callable\nfrom collections.abc import Generator\nfrom contextlib import contextmanager\nfrom datetime import datetime\nfrom typing import ParamSpec\nfrom typing import TypeVar\n\nfrom dateutil.relativedelta import relativedelta\nfrom rich.rule import Rule\n\nfrom confluence_markdown_exporter.utils.rich_console import console\n\nT = TypeVar(\"T\")\nP = ParamSpec(\"P\")\n\nlogger = logging.getLogger(__name__)\n\n\ndef _format_duration(delta: relativedelta) -> str:\n    \"\"\"Return a human-readable duration string from a relativedelta.\n\n    Args:\n        delta: The duration as a relativedelta.\n\n    Returns:\n        A formatted string like \"2m 3s\" or \"45s\".\n    \"\"\"\n    parts = []\n    if delta.hours:\n        parts.append(f\"{delta.hours}h\")\n    if delta.minutes:\n        parts.append(f\"{delta.minutes}m\")\n    seconds = delta.seconds + round(delta.microseconds / 1_000_000)\n    if seconds or not parts:\n        parts.append(f\"{seconds}s\")\n    return \" \".join(parts)\n\n\ndef measure_time(func: Callable[P, T]) -> Callable[P, T]:\n    \"\"\"Decorator to measure and print the execution time of a function.\"\"\"\n\n    def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:\n        start_time = time.time()\n        result = func(*args, **kwargs)\n        end_time = time.time()\n        elapsed_time = end_time - start_time\n        logger.info(f\"Function '{func.__name__}' took {elapsed_time:.4f} seconds to execute.\")\n        return result\n\n    return wrapper\n\n\n@contextmanager\ndef measure(step: str) -> Generator[None, None, None]:\n    \"\"\"Measure and display the execution time of the encapsulated block.\n\n    Prints a rich rule banner at start and a summary line at end.\n\n    Args:\n        step: The step name shown in the banner.\n\n    Raises:\n        e: Reraised exception from execution.\n    \"\"\"\n    start_time = datetime.now()\n    console.print(Rule(f\"[highlight]{step}[/highlight]\", style=\"dim\"))\n    logger.debug(\"Started at %s\", start_time.strftime(\"%Y-%m-%d %H:%M:%S\"))\n    state = \"stopped\"\n    try:\n        yield\n        state = \"ended\"\n    except Exception:\n        state = \"failed\"\n        raise\n    finally:\n        end_time = datetime.now()\n        duration = relativedelta(end_time, start_time)\n        duration_str = _format_duration(duration)\n        if state == \"ended\":\n            console.print(\n                f\"[success]✓[/success] [dim]{step}[/dim] \"\n                f\"completed in [highlight]{duration_str}[/highlight]\"\n            )\n        elif state == \"failed\":\n            console.print(\n                f\"[error]✗[/error] [dim]{step}[/dim] \"\n                f\"failed after [highlight]{duration_str}[/highlight]\"\n            )\n        else:\n            console.print(\n                f\"[warning]![/warning] [dim]{step}[/dim] \"\n                f\"stopped after [highlight]{duration_str}[/highlight]\"\n            )\n"
  },
  {
    "path": "confluence_markdown_exporter/utils/page_registry.py",
    "content": "\"\"\"Cross-space page title registry for link disambiguation.\n\nConfluence enforces page-title uniqueness per space, not across spaces.\nWhen pages from multiple spaces are exported into the same vault, two\npages can share a title — Obsidian's wiki link ``[[Title]]`` then\nresolves ambiguously. This registry tracks known page titles so the\nMarkdown converter can emit a path-qualified wiki link\n(``[[path/to/file|Title]]``) when a collision is detected.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport threading\nfrom typing import ClassVar\n\n\nclass PageTitleRegistry:\n    \"\"\"Track page-id -> title mappings to detect cross-page title collisions.\n\n    Populated from the lockfile snapshot at run start and from each\n    page list before export workers begin so collisions are known\n    before any link rendering.\n    \"\"\"\n\n    _entries: ClassVar[dict[int, str]] = {}\n    _title_counts: ClassVar[dict[str, int]] = {}\n    _lock: ClassVar[threading.Lock] = threading.Lock()\n\n    @classmethod\n    def reset(cls) -> None:\n        with cls._lock:\n            cls._entries.clear()\n            cls._title_counts.clear()\n\n    @classmethod\n    def register(cls, page_id: int, title: str) -> None:\n        if not page_id or not title:\n            return\n        with cls._lock:\n            old = cls._entries.get(page_id)\n            if old == title:\n                return\n            if old is not None:\n                cls._title_counts[old] -= 1\n                if cls._title_counts[old] <= 0:\n                    cls._title_counts.pop(old, None)\n            cls._entries[page_id] = title\n            cls._title_counts[title] = cls._title_counts.get(title, 0) + 1\n\n    @classmethod\n    def is_ambiguous(cls, title: str) -> bool:\n        return cls._title_counts.get(title, 0) > 1\n\n    @classmethod\n    def title_count(cls, title: str) -> int:\n        return cls._title_counts.get(title, 0)\n"
  },
  {
    "path": "confluence_markdown_exporter/utils/rich_console.py",
    "content": "\"\"\"Shared rich console, logging setup, and export statistics tracking.\"\"\"\n\nimport logging\nimport threading\nfrom dataclasses import dataclass\nfrom dataclasses import field\nfrom os import getenv\nfrom pathlib import Path\n\nfrom rich.console import Console\nfrom rich.logging import RichHandler\nfrom rich.style import Style\nfrom rich.theme import Theme\n\n_CME_THEME = Theme(\n    {\n        \"none\": Style.null(),\n        \"reset\": Style(\n            color=\"default\",\n            bgcolor=\"default\",\n            dim=False,\n            bold=False,\n            italic=False,\n            underline=False,\n            blink=False,\n            blink2=False,\n            reverse=False,\n            conceal=False,\n            strike=False,\n        ),\n        \"dim\": Style(dim=True),\n        \"bright\": Style(dim=False),\n        \"bold\": Style(bold=True),\n        \"strong\": Style(bold=True),\n        \"code\": Style(color=\"cyan\"),\n        \"italic\": Style(italic=True),\n        \"emphasize\": Style(italic=True),\n        \"underline\": Style(underline=True),\n        \"blink\": Style(blink=True),\n        \"blink2\": Style(blink2=True),\n        \"reverse\": Style(reverse=True),\n        \"strike\": Style(strike=True),\n        \"black\": Style(color=\"black\"),\n        \"red\": Style(color=\"red\"),\n        \"green\": Style(color=\"green\"),\n        \"yellow\": Style(color=\"yellow\"),\n        \"magenta\": Style(color=\"magenta\"),\n        \"cyan\": Style(color=\"cyan\"),\n        \"white\": Style(color=\"white\"),\n        \"inspect.attr\": Style(color=\"yellow\", italic=True),\n        \"inspect.attr.dunder\": Style(color=\"yellow\", italic=True, dim=True),\n        \"inspect.callable\": Style(bold=True, color=\"red\"),\n        \"inspect.async_def\": Style(italic=True, color=\"bright_cyan\"),\n        \"inspect.def\": Style(italic=True, color=\"bright_cyan\"),\n        \"inspect.class\": Style(italic=True, color=\"bright_cyan\"),\n        \"inspect.error\": Style(bold=True, color=\"red\"),\n        \"inspect.equals\": Style(),\n        \"inspect.help\": Style(color=\"cyan\"),\n        \"inspect.doc\": Style(dim=True),\n        \"inspect.value.border\": Style(color=\"green\"),\n        \"live.ellipsis\": Style(bold=True, color=\"red\"),\n        \"layout.tree.row\": Style(dim=False, color=\"red\"),\n        \"layout.tree.column\": Style(dim=False, color=\"blue\"),\n        \"logging.keyword\": Style(bold=True, color=\"yellow\"),\n        \"logging.level.notset\": Style(dim=True),\n        \"logging.level.debug\": Style(color=\"green\"),\n        \"logging.level.info\": Style(color=\"blue\"),\n        \"logging.level.warning\": Style(color=\"yellow\"),\n        \"logging.level.error\": Style(color=\"red\", bold=True),\n        \"logging.level.critical\": Style(color=\"red\", bold=True, reverse=True),\n        \"log.level\": Style.null(),\n        \"log.time\": Style(color=\"cyan\", dim=True),\n        \"log.message\": Style.null(),\n        \"log.path\": Style(dim=True),\n        \"repr.ellipsis\": Style(color=\"yellow\"),\n        \"repr.indent\": Style(color=\"green\", dim=True),\n        \"repr.error\": Style(color=\"red\", bold=True),\n        \"repr.str\": Style(color=\"green\", italic=False, bold=False),\n        \"repr.brace\": Style(bold=True),\n        \"repr.comma\": Style(bold=True),\n        \"repr.ipv4\": Style(bold=True, color=\"bright_green\"),\n        \"repr.ipv6\": Style(bold=True, color=\"bright_green\"),\n        \"repr.eui48\": Style(bold=True, color=\"bright_green\"),\n        \"repr.eui64\": Style(bold=True, color=\"bright_green\"),\n        \"repr.tag_start\": Style(bold=True),\n        \"repr.tag_name\": Style(color=\"bright_magenta\", bold=True),\n        \"repr.tag_contents\": Style(color=\"default\"),\n        \"repr.tag_end\": Style(bold=True),\n        \"repr.attrib_name\": Style(color=\"yellow\", italic=False),\n        \"repr.attrib_equal\": Style(bold=True),\n        \"repr.attrib_value\": Style(color=\"magenta\", italic=False),\n        \"repr.number\": Style(color=\"cyan\", bold=True, italic=False),\n        \"repr.number_complex\": Style(color=\"cyan\", bold=True, italic=False),  # same\n        \"repr.bool_true\": Style(color=\"bright_green\", italic=True),\n        \"repr.bool_false\": Style(color=\"bright_red\", italic=True),\n        \"repr.none\": Style(color=\"magenta\", italic=True),\n        \"repr.url\": Style(underline=True, color=\"bright_blue\", italic=False, bold=False),\n        \"repr.uuid\": Style(color=\"bright_yellow\", bold=False),\n        \"repr.call\": Style(color=\"magenta\", bold=True),\n        \"repr.path\": Style(color=\"magenta\"),\n        \"repr.filename\": Style(color=\"bright_magenta\"),\n        \"rule.line\": Style(color=\"bright_green\"),\n        \"rule.text\": Style.null(),\n        \"json.brace\": Style(bold=True),\n        \"json.bool_true\": Style(color=\"bright_green\", italic=True),\n        \"json.bool_false\": Style(color=\"bright_red\", italic=True),\n        \"json.null\": Style(color=\"magenta\", italic=True),\n        \"json.number\": Style(color=\"cyan\", bold=True, italic=False),\n        \"json.str\": Style(color=\"green\", italic=False, bold=False),\n        \"json.key\": Style(color=\"blue\", bold=True),\n        \"prompt\": Style.null(),\n        \"prompt.choices\": Style(color=\"magenta\", bold=True),\n        \"prompt.default\": Style(color=\"cyan\", bold=True),\n        \"prompt.invalid\": Style(color=\"red\"),\n        \"prompt.invalid.choice\": Style(color=\"red\"),\n        \"pretty\": Style.null(),\n        \"scope.border\": Style(color=\"blue\"),\n        \"scope.key\": Style(color=\"yellow\", italic=True),\n        \"scope.key.special\": Style(color=\"yellow\", italic=True, dim=True),\n        \"scope.equals\": Style(color=\"red\"),\n        \"table.header\": Style(bold=True),\n        \"table.footer\": Style(bold=True),\n        \"table.cell\": Style.null(),\n        \"table.title\": Style(italic=True),\n        \"table.caption\": Style(italic=True, dim=True),\n        \"traceback.error\": Style(color=\"red\", italic=True),\n        \"traceback.border.syntax_error\": Style(color=\"bright_red\"),\n        \"traceback.border\": Style(color=\"red\"),\n        \"traceback.text\": Style.null(),\n        \"traceback.title\": Style(color=\"red\", bold=True),\n        \"traceback.exc_type\": Style(color=\"bright_red\", bold=True),\n        \"traceback.exc_value\": Style.null(),\n        \"traceback.offset\": Style(color=\"bright_red\", bold=True),\n        \"traceback.error_range\": Style(underline=True, bold=True),\n        \"traceback.note\": Style(color=\"green\", bold=True),\n        \"traceback.group.border\": Style(color=\"magenta\"),\n        \"bar.back\": Style(color=\"grey23\"),\n        \"bar.complete\": Style(color=\"rgb(249,38,114)\"),\n        \"bar.finished\": Style(color=\"rgb(114,156,31)\"),\n        \"bar.pulse\": Style(color=\"rgb(249,38,114)\"),\n        \"progress.description\": Style.null(),\n        \"progress.filesize\": Style(color=\"green\"),\n        \"progress.filesize.total\": Style(color=\"green\"),\n        \"progress.download\": Style(color=\"green\"),\n        \"progress.elapsed\": Style(color=\"yellow\"),\n        \"progress.percentage\": Style(color=\"magenta\"),\n        \"progress.remaining\": Style(color=\"cyan\"),\n        \"progress.data.speed\": Style(color=\"red\"),\n        \"progress.spinner\": Style(color=\"green\"),\n        \"status.spinner\": Style(color=\"green\"),\n        \"tree\": Style(),\n        \"tree.line\": Style(),\n        \"markdown.paragraph\": Style(),\n        \"markdown.text\": Style(),\n        \"markdown.em\": Style(italic=True),\n        \"markdown.emph\": Style(italic=True),  # For commonmark backwards compatibility\n        \"markdown.strong\": Style(bold=True),\n        \"markdown.code\": Style(color=\"cyan\"),\n        \"markdown.code_block\": Style(color=\"cyan\"),\n        \"markdown.block_quote\": Style(color=\"magenta\"),\n        \"markdown.list\": Style(color=\"cyan\"),\n        \"markdown.item\": Style(),\n        \"markdown.item.bullet\": Style(color=\"yellow\", bold=True),\n        \"markdown.item.number\": Style(color=\"yellow\", bold=True),\n        \"markdown.hr\": Style(color=\"yellow\"),\n        \"markdown.h1.border\": Style(),\n        \"markdown.h1\": Style(bold=True),\n        \"markdown.h2\": Style(bold=True, underline=True),\n        \"markdown.h3\": Style(bold=True),\n        \"markdown.h4\": Style(bold=True, dim=True),\n        \"markdown.h5\": Style(underline=True),\n        \"markdown.h6\": Style(italic=True),\n        \"markdown.h7\": Style(italic=True, dim=True),\n        \"markdown.link\": Style(color=\"bright_blue\"),\n        \"markdown.link_url\": Style(color=\"blue\", underline=True),\n        \"markdown.s\": Style(strike=True),\n        \"iso8601.date\": Style(color=\"blue\"),\n        \"iso8601.time\": Style(color=\"magenta\"),\n        \"iso8601.timezone\": Style(color=\"yellow\"),\n    }\n)\n\nTERMINAL_WIDTH = getenv(\"TERMINAL_WIDTH\")\nMAX_WIDTH = int(TERMINAL_WIDTH) if TERMINAL_WIDTH else None\nFORCE_TERMINAL = (\n    False\n    if getenv(\"NO_COLOR\") or getenv(\"CI\")\n    else True\n    if getenv(\"FORCE_COLOR\") or getenv(\"PY_COLORS\") or getenv(\"GITHUB_ACTIONS\")\n    else None\n)\n\n\ndef get_rich_console(*, stderr: bool = False) -> Console:\n    return Console(\n        theme=_CME_THEME,\n        highlight=False,\n        # In CI, disable live rendering (no ANSI escapes, no overwriting lines, no colors)\n        force_terminal=FORCE_TERMINAL,\n        width=MAX_WIDTH,\n        stderr=stderr,\n    )\n\n\nconsole: Console = get_rich_console()\n\n\ndef setup_logging(log_level: str = \"INFO\", log_file: Path | None = None) -> None:\n    \"\"\"Configure the root logger to use rich output.\n\n    Args:\n        log_level: One of DEBUG, INFO, WARNING, ERROR.\n        log_file: Optional path to also write log records to. The file uses\n            a plain (non-rich) format so it is grep-friendly. Parent\n            directories are created if missing.\n    \"\"\"\n    level = getattr(logging, log_level.upper(), logging.INFO)\n    handler = RichHandler(\n        console=console,\n        rich_tracebacks=True,\n        show_path=log_level == \"DEBUG\",\n        markup=False,\n        log_time_format=\"[%X]\",\n    )\n    handler.setLevel(level)\n    root = logging.getLogger()\n    root.setLevel(level)\n    # Remove any existing handlers so we don't double-log\n    root.handlers.clear()\n    root.addHandler(handler)\n    if log_file is not None:\n        log_file.parent.mkdir(parents=True, exist_ok=True)\n        file_handler = logging.FileHandler(log_file, encoding=\"utf-8\")\n        file_handler.setLevel(level)\n        file_handler.setFormatter(\n            logging.Formatter(\"%(asctime)s %(levelname)s %(name)s: %(message)s\")\n        )\n        root.addHandler(file_handler)\n\n\n@dataclass\nclass ExportStats:\n    \"\"\"Thread-safe counters for a single export run.\"\"\"\n\n    total: int = 0\n    exported: int = 0\n    skipped: int = 0\n    failed: int = 0\n    removed: int = 0\n    attachments_exported: int = 0\n    attachments_skipped: int = 0\n    attachments_failed: int = 0\n    attachments_removed: int = 0\n    _lock: threading.Lock = field(default_factory=threading.Lock, repr=False, compare=False)\n\n    def inc_exported(self) -> None:\n        \"\"\"Increment the exported counter by 1.\"\"\"\n        with self._lock:\n            self.exported += 1\n\n    def inc_skipped(self) -> None:\n        \"\"\"Increment the skipped counter by 1.\"\"\"\n        with self._lock:\n            self.skipped += 1\n\n    def inc_failed(self) -> None:\n        \"\"\"Increment the failed counter by 1.\"\"\"\n        with self._lock:\n            self.failed += 1\n\n    def inc_removed(self) -> None:\n        \"\"\"Increment the pages removed counter by 1.\"\"\"\n        with self._lock:\n            self.removed += 1\n\n    def inc_attachments_exported(self) -> None:\n        \"\"\"Increment the attachments exported counter by 1.\"\"\"\n        with self._lock:\n            self.attachments_exported += 1\n\n    def inc_attachments_skipped(self) -> None:\n        \"\"\"Increment the attachments skipped counter by 1.\"\"\"\n        with self._lock:\n            self.attachments_skipped += 1\n\n    def inc_attachments_failed(self) -> None:\n        \"\"\"Increment the attachments failed counter by 1.\"\"\"\n        with self._lock:\n            self.attachments_failed += 1\n\n    def inc_attachments_removed(self) -> None:\n        \"\"\"Increment the attachments removed counter by 1.\"\"\"\n        with self._lock:\n            self.attachments_removed += 1\n\n\n# Module-level stats instance reset at the start of each export run\n_stats: ExportStats = ExportStats()\n\n\ndef reset_stats(total: int = 0) -> ExportStats:\n    \"\"\"Reset and return the global export stats for a new run.\n\n    Args:\n        total: Total number of pages in the export scope (including skipped).\n\n    Returns:\n        The fresh ExportStats instance.\n    \"\"\"\n    global _stats  # noqa: PLW0603\n    _stats = ExportStats(total=total)\n    return _stats\n\n\ndef get_stats() -> ExportStats:\n    \"\"\"Return the current global export stats.\"\"\"\n    return _stats\n"
  },
  {
    "path": "confluence_markdown_exporter/utils/table_converter.py",
    "content": "import re\nfrom typing import cast\n\nfrom bs4 import BeautifulSoup\nfrom bs4 import Tag\nfrom markdownify import MarkdownConverter\nfrom tabulate import tabulate\n\n_LEADING_BR_OR_WS = re.compile(r\"^(?:\\s|<br\\s*/?>)+\")\n_TRAILING_BR_OR_WS = re.compile(r\"(?:\\s|<br\\s*/?>)+$\")\n\n\ndef _get_int_attr(cell: Tag, attr: str, default: str = \"1\") -> int:\n    val = cell.get(attr, default)\n    if isinstance(val, list):\n        val = val[0] if val else default\n    try:\n        return int(str(val))\n    except (ValueError, TypeError):\n        return int(default)\n\n\ndef pad(rows: list[list[Tag]]) -> list[list[Tag]]:\n    \"\"\"Pad table rows to handle rowspan and colspan for markdown conversion.\"\"\"\n    padded: list[list[Tag]] = []\n    occ: dict[tuple[int, int], Tag] = {}\n    for r, row in enumerate(rows):\n        if not row:\n            continue\n        cur: list[Tag] = []\n        c = 0\n        for cell in row:\n            while (r, c) in occ:\n                cur.append(occ.pop((r, c)))\n                c += 1\n            rs = _get_int_attr(cell, \"rowspan\", \"1\")\n            cs = _get_int_attr(cell, \"colspan\", \"1\")\n            cur.append(cell)\n            # Append extra cells for colspan\n            if cs > 1:\n                cur.extend(make_empty_cell() for _ in range(1, cs))\n            # Mark future cells for rowspan and colspan\n            for i in range(rs):\n                for j in range(cs):\n                    if i or j:\n                        occ[(r + i, c + j)] = make_empty_cell()\n            c += cs\n        while (r, c) in occ:\n            cur.append(occ.pop((r, c)))\n            c += 1\n        padded.append(cur)\n    return padded\n\n\ndef make_empty_cell() -> Tag:\n    \"\"\"Return an empty <td> Tag.\"\"\"\n    return Tag(name=\"td\")\n\n\ndef _normalize_table_cell_text(text: str) -> str:\n    text = text.replace(\"|\", \"\\\\|\").replace(\"\\n\", \"<br/>\")\n    text = _LEADING_BR_OR_WS.sub(\"\", text)\n    return _TRAILING_BR_OR_WS.sub(\"\", text)\n\n\nclass TableConverter(MarkdownConverter):\n    \"\"\"Custom MarkdownConverter for converting HTML tables to markdown tables.\"\"\"\n\n    def convert_table(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:\n        rows = [\n            cast(\"list[Tag]\", tr.find_all([\"td\", \"th\"]))\n            for tr in cast(\"list[Tag]\", el.find_all(\"tr\"))\n            if tr\n        ]\n\n        if not rows:\n            return \"\"\n\n        padded_rows = pad(rows)\n        converted = [[self.convert(str(cell)) for cell in row] for row in padded_rows]\n\n        has_header = all(cell.name == \"th\" for cell in rows[0])\n        if has_header:\n            return tabulate(converted[1:], headers=converted[0], tablefmt=\"pipe\")\n\n        return tabulate(converted, headers=[\"\"] * len(converted[0]), tablefmt=\"pipe\")\n\n    def convert_th(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:\n        \"\"\"This method is empty because we want a No-Op for the <th> tag.\"\"\"\n        return _normalize_table_cell_text(text)\n\n    def convert_tr(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:\n        \"\"\"This method is empty because we want a No-Op for the <tr> tag.\"\"\"\n        return text\n\n    def convert_td(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:\n        \"\"\"This method is empty because we want a No-Op for the <td> tag.\"\"\"\n        return _normalize_table_cell_text(text)\n\n    def convert_thead(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:\n        \"\"\"This method is empty because we want a No-Op for the <thead> tag.\"\"\"\n        return text\n\n    def convert_tbody(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:\n        \"\"\"This method is empty because we want a No-Op for the <tbody> tag.\"\"\"\n        return text\n\n    ParentTags = list[str] | set[str]\n\n    @staticmethod\n    def _normalize_parent_tags(\n        parent_tags: \"TableConverter.ParentTags | bool\",\n    ) -> \"TableConverter.ParentTags\":\n        # markdownify 1.x passes set[str]; older versions passed bool (convert_as_inline)\n        return parent_tags if isinstance(parent_tags, list | set) else set()\n\n    def convert_ol(\n        self, el: BeautifulSoup, text: str, parent_tags: \"TableConverter.ParentTags | bool\"\n    ) -> str:\n        tags = self._normalize_parent_tags(parent_tags)\n        if \"td\" in tags:\n            lines = text.splitlines()\n            if not lines:\n                return \"\"\n            start = int(el.get(\"start\") or 1)\n            numbered = [\n                f\"{start + i}. {item}\".rstrip() if item.strip() else str(start + i)\n                for i, item in enumerate(lines)\n            ]\n            return \"<br>\".join(n for n in numbered if n)\n        return super().convert_ol(el, text, tags)\n\n    def convert_li(\n        self, el: BeautifulSoup, text: str, parent_tags: \"TableConverter.ParentTags | bool\"\n    ) -> str:\n        tags = self._normalize_parent_tags(parent_tags)\n        if \"td\" in tags:\n            return text.strip().removesuffix(\"<br/>\") + \"\\n\"\n        return MarkdownConverter.convert_li(self, el, text, tags)  # type: ignore[attr-defined]\n\n    def convert_ul(\n        self, el: BeautifulSoup, text: str, parent_tags: \"TableConverter.ParentTags | bool\"\n    ) -> str:\n        tags = self._normalize_parent_tags(parent_tags)\n        if \"td\" in tags:\n            items = [item for item in text.splitlines() if item.strip()]\n            if not items:\n                return \"\"\n            if len(items) == 1:\n                return items[0]\n            return \"- \" + \"<br>- \".join(items)\n        return super().convert_ul(el, text, tags)\n\n    def convert_p(\n        self, el: BeautifulSoup, text: str, parent_tags: \"TableConverter.ParentTags | bool\"\n    ) -> str:\n        tags = self._normalize_parent_tags(parent_tags)\n        md = super().convert_p(el, text, tags)\n        if \"td\" in tags:\n            md = md.replace(\"\\n\", \"\") + \"<br/>\"\n        return md\n"
  },
  {
    "path": "confluence_markdown_exporter/utils/type_converter.py",
    "content": "def str_to_bool(value: str) -> bool:\n    \"\"\"Convert a string to boolean.\"\"\"\n    true_set = {\"true\", \"1\", \"yes\", \"on\"}\n    false_set = {\"false\", \"0\", \"no\", \"off\"}\n\n    val = value.strip().lower()\n    if val in true_set:\n        return True\n    if val in false_set:\n        return False\n    msg = f\"Invalid boolean string: '{value}'\"\n    raise ValueError(msg)\n"
  },
  {
    "path": "docs/compatibility.md",
    "content": "---\nid: compatibility\ntitle: Compatibility\nsidebar_position: 5\n---\n\n# Compatibility\n\nThis package is not tested extensively. Please check all output and report any issue on the [issue tracker](https://github.com/Spenhouet/confluence-markdown-exporter/issues).\n\nIt has generally been tested on:\n\n- **Confluence Cloud** 1000.0.0-b5426ab8524f (2025-05-28)\n- **Confluence Server** 8.5.20\n\nIf you successfully use the exporter with a different Confluence version, feel free to open a PR adding it to this list.\n"
  },
  {
    "path": "docs/configuration/authentication.md",
    "content": "---\nid: authentication\ntitle: Authentication\nsidebar_position: 3\n---\n\n# Authentication\n\n:::note\nAuth credentials use URL-keyed nested dicts (e.g. `auth.confluence[\"https://company.atlassian.net\"]`) and cannot be mapped to flat ENV var names. Use `cme config edit auth.confluence` or `cme config set` for auth configuration.\n:::\n\nThe fastest way to set credentials is the interactive menu:\n\n```sh\ncme config edit auth.confluence\ncme config edit auth.jira\n```\n\n## Confluence\n\n### auth.confluence.url\n\nConfluence instance URL.\n\n- Default: `\"\"`\n\n### auth.confluence.username\n\nConfluence username/email.\n\n- Default: `\"\"`\n\n### auth.confluence.api_token\n\nConfluence API token.\n\n- Default: `\"\"`\n\n### auth.confluence.pat\n\nConfluence Personal Access Token.\n\n- Default: `\"\"`\n\n### auth.confluence.cloud_id\n\nAtlassian Cloud ID for the Confluence instance. When set, API calls are routed through the Atlassian API gateway (`https://api.atlassian.com/ex/confluence/{cloud_id}`), which enables the use of [scoped API tokens](https://support.atlassian.com/atlassian-account/docs/manage-api-tokens-for-your-atlassian-account/).\n\nFor Atlassian Cloud instances (`.atlassian.net`) this is fetched and stored **automatically** on first connection. You can also set it manually. See [How to retrieve your Atlassian Cloud ID](https://support.atlassian.com/jira/kb/retrieve-my-atlassian-sites-cloud-id/).\n\n- Default: `\"\"`\n\n## Jira\n\n### auth.jira.url\n\nJira instance URL.\n\n- Default: `\"\"`\n\n### auth.jira.username\n\nJira username/email.\n\n- Default: `\"\"`\n\n### auth.jira.api_token\n\nJira API token.\n\n- Default: `\"\"`\n\n### auth.jira.pat\n\nJira Personal Access Token.\n\n- Default: `\"\"`\n\n### auth.jira.cloud_id\n\nAtlassian Cloud ID for the Jira instance. Works identically to `auth.confluence.cloud_id` above, routing API calls through `https://api.atlassian.com/ex/jira/{cloud_id}`.\n\nFor Atlassian Cloud instances this is fetched and stored **automatically** on first connection.\n\n- Default: `\"\"`\n\n## Generating API tokens\n\nAPI tokens that are associated with Atlassian Cloud accounts can be generated [in your 'Account Settings'](https://id.atlassian.com/manage-profile/security/api-tokens) (in Jira/Confluence: profile picture in upper-right corner → _Account Settings_ → _Security_ → _Create and Manage API tokens_).\n\nScoped API tokens **require 'classic' scopes**; these scopes have been tested (giving full read-only access):\n\n```text\nread:confluence-content.all\nread:account\nread:confluence-content.permission\nread:confluence-content.summary\nread:confluence-groups\nread:confluence-props\nread:confluence-space.summary\nread:confluence-user\nread:me\nreadonly:content.attachment:confluence\nsearch:confluence\n```\n"
  },
  {
    "path": "docs/configuration/ci.md",
    "content": "---\nid: ci\ntitle: Running in CI\nsidebar_label: CI / non-interactive\nsidebar_position: 5\n---\n\n# Running in CI / non-interactive environments\n\nThe exporter automatically detects CI environments and suppresses rich terminal formatting (colors, spinner animations, progress bar redraws) so that log output is clean and readable in CI logs.\n\nDetection is based on two standard environment variables:\n\n| Variable     | Effect                                                                    |\n| ------------ | ------------------------------------------------------------------------- |\n| `CI=true`    | Disables ANSI color codes and live terminal output                        |\n| `NO_COLOR=1` | Same effect (follows the [no-color.org](https://no-color.org) convention) |\n\nMost CI platforms (GitHub Actions, GitLab CI, CircleCI, Jenkins, etc.) set `CI=true` automatically.\n\n## Controlling log verbosity\n\nYou can control output verbosity via the `CME_EXPORT__LOG_LEVEL` env var or the [`export.log_level`](./options.md#exportlog_level) config option:\n\n```sh\n# Enable verbose debug logging for a single run (not persisted):\nCME_EXPORT__LOG_LEVEL=DEBUG cme pages <page-url>\n\n# Reduce verbosity permanently:\ncme config set export.log_level=WARNING\n\n# Or for the current session only:\nCME_EXPORT__LOG_LEVEL=WARNING cme pages <page-url>\n```\n\nThis is useful for using different log levels for different environments or for scripting.\n\n## Tips for CI pipelines\n\n- Use a dedicated config file via [`CME_CONFIG_PATH`](./index.md#custom-config-file-location) so CI runs don't share state with developer machines.\n- Provide credentials via secrets and set them with `cme config set` at the start of the run, or use ENV var overrides for non-auth options.\n- Pin the version using the version-specific installer URL; see [Installation](../installation.md#pinning-a-specific-version).\n"
  },
  {
    "path": "docs/configuration/index.md",
    "content": "---\nid: index\ntitle: Configuration\nslug: /configuration/\nsidebar_position: 1\n---\n\n# Configuration\n\nAll configuration and authentication is stored in a single JSON file managed by the application. You do not need to manually edit this file; use the `cme config` commands instead.\n\n## Config commands\n\n| Command                         | Description                                    |\n| ------------------------------- | ---------------------------------------------- |\n| `cme config`                    | Open the interactive configuration menu        |\n| `cme config list`               | Print the full configuration as YAML           |\n| `cme config get <key>`          | Print the value of a single config key         |\n| `cme config set <key=value>...` | Set one or more config values                  |\n| `cme config edit <key>`         | Open the interactive editor for a specific key |\n| `cme config path`               | Print the path to the config file              |\n| `cme config reset`              | Reset all configuration to defaults            |\n\n### Interactive menu\n\n```sh\ncme config\n```\n\nOpens a full interactive menu where you can:\n\n- See all config options and their current values\n- Select any option to change it (including authentication)\n- Navigate into nested sections (e.g. `auth.confluence`)\n- Reset all config to defaults\n\n### List current configuration\n\n```sh\ncme config list           # YAML (default)\ncme config list -o json   # JSON\n```\n\nPrints the entire current configuration. Output format defaults to YAML; use `-o json` for JSON.\n\n### Get a single value\n\n```sh\ncme config get export.log_level\ncme config get connection_config.max_workers\n```\n\nPrints the current value of the specified key. Nested sections are printed as YAML.\n\n### Set values\n\n```sh\ncme config set export.log_level=DEBUG\ncme config set export.output_path=/tmp/export\ncme config set export.skip_unchanged=false\n```\n\nSets one or more `key=value` pairs directly. Values are parsed as JSON where possible (so `true`, `false`, and numbers work as expected), falling back to a plain string.\n\n:::note\nFor auth keys that contain a URL (e.g. `auth.confluence.https://...`), use `cme config edit auth.confluence` instead, which handles URL-based keys correctly.\n:::\n\n### Edit a specific key interactively\n\n```sh\ncme config edit auth.confluence\ncme config edit export.log_level\n```\n\nOpens the interactive editor directly at the specified config section, skipping the top-level menu.\n\n### Show config file path\n\n```sh\ncme config path\n```\n\nPrints the absolute path to the configuration file. Useful when `CME_CONFIG_PATH` is set or when locating the file for backup/inspection.\n\n### Reset to defaults\n\n```sh\ncme config reset\ncme config reset --yes   # skip confirmation\n```\n\nResets the entire configuration to factory defaults after confirmation.\n\n## ENV var overrides\n\nAll options can be set via the config file (using `cme config set`) or overridden for the current session via environment variables.\n\nENV vars **take precedence** over stored config and are **not** persisted. ENV var names use the `CME_` prefix and `__` (double underscore) as the nested delimiter, matching the key in uppercase. Example: `export.log_level` → `CME_EXPORT__LOG_LEVEL`.\n\n:::note\nAuth credentials use URL-keyed nested dicts (e.g. `auth.confluence[\"https://company.atlassian.net\"]`) and cannot be mapped to flat ENV var names. Use `cme config edit auth.confluence` or `cme config set` for auth configuration.\n:::\n\n## Custom config file location\n\nBy default, configuration is stored in a platform-specific application directory. You can override the config file location by setting the `CME_CONFIG_PATH` environment variable to the desired file path:\n\n```sh\nexport CME_CONFIG_PATH=/path/to/your/custom_config.json\n```\n\nIf set, the application will read and write config from this file instead.\n\n## Next\n\n- [Full option reference →](./options.md)\n- [Authentication →](./authentication.md)\n- [Target-system presets (Obsidian, ADO, …) →](./target-systems.md)\n- [Running in CI / non-interactive environments →](./ci.md)\n"
  },
  {
    "path": "docs/configuration/options.md",
    "content": "---\nid: options\ntitle: Configuration options\nsidebar_label: Options reference\nsidebar_position: 2\n---\n\n# Configuration options\n\nReference for every supported option. All options can be set via `cme config set <key>=<value>` or overridden per-session through the listed environment variable.\n\n## export.\\*\n\n### export.log_level\n\nControls output verbosity: `DEBUG` (every step), `INFO` (key milestones), `WARNING` (warnings/errors only), `ERROR` (errors only).\n\n- Default: `INFO`\n- ENV Var: `CME_EXPORT__LOG_LEVEL`\n\n### export.output_path\n\nThe directory where all exported files and folders will be written. Used as the base for relative and absolute links.\n\n- Default: `./` (current working directory)\n- ENV Var: `CME_EXPORT__OUTPUT_PATH`\n\n### export.page_href\n\nHow to generate links to pages in Markdown. Options: `relative` (default), `absolute`, or `wiki`.\n\n- Default: `relative`\n- ENV Var: `CME_EXPORT__PAGE_HREF`\n\n| Value      | Output                                 |\n| ---------- | -------------------------------------- |\n| `relative` | `[Page Title](../path/to/page.md)`     |\n| `absolute` | `[Page Title](/space/path/to/page.md)` |\n| `wiki`     | `[[Page Title]]`                       |\n\n### export.page_path\n\nPath template for exported pages.\n\n- Default: `{space_name}/{homepage_title}/{ancestor_titles}/{page_title}.md`\n- ENV Var: `CME_EXPORT__PAGE_PATH`\n\n### export.attachment_href\n\nHow to generate links to attachments in Markdown. Options: `relative` (default), `absolute`, or `wiki`.\n\n- Default: `relative`\n- ENV Var: `CME_EXPORT__ATTACHMENT_HREF`\n\n| Value      | Output                                                                             |\n| ---------- | ---------------------------------------------------------------------------------- |\n| `relative` | `[file.pdf](../path/to/file.pdf)` / `![alt](../path/to/image.png)`                 |\n| `absolute` | `[file.pdf](/space/attachments/file.pdf)` / `![alt](/space/attachments/image.png)` |\n| `wiki`     | `[[file.pdf\\|File Title]]` / `![[image.png]]`                                      |\n\n### export.attachment_path\n\nPath template for attachments.\n\n- Default: `{space_name}/attachments/{attachment_file_id}{attachment_extension}`\n- ENV Var: `CME_EXPORT__ATTACHMENT_PATH`\n\nOn Confluence Data Center / Server, where the API does not provide `fileId`, `{attachment_file_id}` falls back to the content id, so the default template still produces unique filenames.\n\n### export.attachments_export\n\nWhich attachments to download to disk.\n\n| Value        | Behaviour                                                                                                                                                                     |\n| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `referenced` | Only attachments whose ID/filename appears in the page body (default).                                                                                                        |\n| `all`        | Every attachment on the page. Large or numerous attachments increase export time.                                                                                             |\n| `disabled`   | Skip downloads entirely: no files written, no lockfile entries, no lookup. Body image and file links still point at `attachment_path`, but the files will not exist locally. |\n\n- Default: `referenced`\n- ENV Var: `CME_EXPORT__ATTACHMENTS_EXPORT`\n\n### export.image_captions\n\nWhether to export Confluence image captions in the exported Markdown. When enabled, the storage format of each page is fetched (via an additional API body expansion) and `ac:image` captions are extracted and rendered as an italic line directly below the image:\n\n```markdown\n![](image.png)\n_Caption text_\n```\n\nWhen disabled, no caption is added.\n\n- Default: `False`\n- ENV Var: `CME_EXPORT__IMAGE_CAPTIONS`\n\n### export.page_breadcrumbs\n\nWhether to include breadcrumb links at the top of the page.\n\n- Default: `True`\n- ENV Var: `CME_EXPORT__PAGE_BREADCRUMBS`\n\n### export.page_properties_format\n\nControls how Confluence Page Properties macros (key-value tables) are rendered. Duplicate property keys are automatically disambiguated by appending a counter (e.g. `status`, `status_2`, `status_3`).\n\n| Value                   | Description                                                                                                                                  |\n| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |\n| `frontmatter`           | Extract to YAML front matter; table is removed from the page body                                                                            |\n| `table`                 | Keep as a regular markdown table; no metadata is written                                                                                     |\n| `frontmatter_and_table` | Write to YAML front matter **and** keep the original table in the body (default)                                                             |\n| `dataview-inline-field` | Replace the table with [Dataview](https://blacksmithgu.github.io/obsidian-dataview/) `Key:: Value` inline fields                             |\n| `meta-bind-view-fields` | Write YAML front matter and a table using [Meta Bind](https://www.moritzjung.dev/obsidian-meta-bind-plugin-docs/) `VIEW[{key}][text]` fields |\n\n:::info Migration\nThe legacy `page_properties_as_front_matter=true/false` is still accepted and maps to `frontmatter` / `table` respectively.\n:::\n\n- Default: `frontmatter_and_table`\n- ENV Var: `CME_EXPORT__PAGE_PROPERTIES_FORMAT`\n\n### export.page_properties_report_format\n\nControls how Confluence Page Properties Report macros (dynamic cross-page property tables) are rendered.\n\n| Value      | Description                                                                                                                                                                                                                                                                                                |\n| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `frozen`   | Export the rendered table as a static markdown table snapshot (default)                                                                                                                                                                                                                                    |\n| `dataview` | Translate the CQL query to an [Obsidian Dataview](https://blacksmithgu.github.io/obsidian-dataview/) DQL code block; requires the Dataview plugin and all referenced child pages to be exported with their page properties as front matter; falls back to a frozen table if the query cannot be translated |\n\n- Default: `frozen`\n- ENV Var: `CME_EXPORT__PAGE_PROPERTIES_REPORT_FORMAT`\n\n### export.confluence_url_in_frontmatter\n\nWhether to include the original Confluence page URL in the YAML front matter of the exported file.\n\n| Value    | Description                                                                                               |\n| -------- | --------------------------------------------------------------------------------------------------------- |\n| `none`   | Do not include any URL (default)                                                                          |\n| `webui`  | Include `confluence_webui_url` (human-readable URL; may change when the page is renamed or moved)         |\n| `tinyui` | Include `confluence_tinyui_url` (stable short permalink based on the page ID; survives renames and moves) |\n| `both`   | Include both fields                                                                                       |\n\nIf a Page Properties macro on the page already defines `confluence_webui_url` or `confluence_tinyui_url`, the value from the macro takes precedence over the URL extracted from the API.\n\n- Default: `none`\n- ENV Var: `CME_EXPORT__CONFLUENCE_URL_IN_FRONTMATTER`\n\n### export.page_metadata_in_frontmatter\n\nAdd eight Confluence page metadata fields to the YAML front matter of each exported page.\n\n| Field | Source |\n| ----- | ------ |\n| `confluence_page_id` | Page ID (string) |\n| `confluence_space_key` | Space key |\n| `confluence_type` | Content type (`page` or `blogpost`) |\n| `confluence_created` | ISO 8601 timestamp of when the page was first created (`history.createdDate`) |\n| `confluence_created_by` | Display name of the original author (`history.createdBy.displayName`) |\n| `confluence_last_modified` | ISO 8601 timestamp of the most recent version (`version.when`), including minor edits |\n| `confluence_last_modified_by` | Display name of the last editor |\n| `confluence_version` | Version number (integer) |\n\nFields with empty or zero values are omitted. If a Page Properties macro on the page already defines a key with the same name, the macro value takes precedence.\n\n`confluence_page_id` is intentionally written as a quoted string (e.g. `'629839369'`) rather than an integer. Confluence Cloud page IDs can exceed JavaScript's safe-integer range (`2^53 − 1`), so JS-based static site generators (Hugo, Astro, …) parsing the front matter would silently truncate them. `confluence_created` and `confluence_last_modified` are also quoted because PyYAML wraps ISO-8601 timestamps with timezone offsets to prevent loaders from coercing the value into a `datetime` object.\n\nExample front matter with both `confluence_url_in_frontmatter: webui` and `page_metadata_in_frontmatter: true`:\n\n```yaml\n---\ntags:\n  - team-foo\nconfluence_webui_url: https://.../wiki/spaces/.../pages/123/Title\nconfluence_page_id: '123'\nconfluence_space_key: TEAM\nconfluence_type: page\nconfluence_created: \"2024-08-15T08:34:12.000+02:00\"\nconfluence_created_by: Sam Creator\nconfluence_last_modified: \"2026-04-12T10:34:00.000+02:00\"\nconfluence_last_modified_by: Alex Johnson\nconfluence_version: 7\n---\n```\n\n- Default: `false`\n- ENV Var: `CME_EXPORT__PAGE_METADATA_IN_FRONTMATTER`\n\n### export.filename_encoding\n\nCharacter mapping for filename encoding.\n\n- Default: Default mappings for forbidden characters.\n- ENV Var: `CME_EXPORT__FILENAME_ENCODING`\n\n### export.filename_length\n\nMaximum length of filenames.\n\n- Default: `255`\n- ENV Var: `CME_EXPORT__FILENAME_LENGTH`\n\n### export.filename_lowercase\n\nMake all exported paths and filenames lowercase. By default the original casing from Confluence is retained.\n\n- Default: `False`\n- ENV Var: `CME_EXPORT__FILENAME_LOWERCASE`\n\n### export.include_document_title\n\nWhether to include the document title in the exported markdown file. If enabled, the title will be added as a top-level heading.\n\n- Default: `True`\n- ENV Var: `CME_EXPORT__INCLUDE_DOCUMENT_TITLE`\n\n### export.include_toc\n\nWhether to export the Confluence Table of Contents macro. When enabled, the TOC is converted to markdown. When disabled, the TOC macro is removed from the output.\n\n- Default: `True`\n- ENV Var: `CME_EXPORT__INCLUDE_TOC`\n\n### export.include_macro\n\nControls how Confluence `include` and `excerpt-include` macros are rendered. The `include` macro embeds the full content of another page; `excerpt-include` embeds a named excerpt from another page.\n\n| Value          | Behaviour                                                                                                                                                                     |\n| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `inline`       | Expand the referenced page content inline at the point of inclusion (default). The body already rendered by Confluence is used, so no extra API calls are required.           |\n| `transclusion` | Emit an Obsidian-style `![[Page Title]]` embed link. Obsidian renders the link as an inline preview of the target note. The referenced page must also be exported to resolve. |\n\n- Default: `inline`\n- ENV Var: `CME_EXPORT__INCLUDE_MACRO`\n\n### export.enable_jira_enrichment\n\nFetch Jira issue data to enrich Confluence pages. When enabled, Jira issue links include the issue summary. Requires Jira auth to be configured.\n\n- Default: `True`\n- ENV Var: `CME_EXPORT__ENABLE_JIRA_ENRICHMENT`\n\n### export.comments_export\n\nWhich comments to export to a sidecar `.comments.md` file placed next to the exported page file, using the same path stem.\n\n| Value      | Behaviour                                                                                |\n| ---------- | ---------------------------------------------------------------------------------------- |\n| `none`     | No sidecar (default).                                                                    |\n| `inline`   | Open inline comments only (annotated text as blockquote, then author / date / body).     |\n| `footer`   | Open page-level (footer) comments only.                                                  |\n| `all`      | Both, in a single sidecar with `## Inline comments` first, then `## Page comments`.      |\n\nOnly open comments are included; resolved comments are skipped. Replies are listed flat below their parent comment. Disabled by default; enabling adds one to two extra API calls per page.\n\nSidecar example for `comments_export = \"all\"`:\n\n```markdown\n---\nconfluence_page_id: '123'\nconfluence_page_title: \"Example Page\"\nconfluence_webui_url: \"https://example.atlassian.net/wiki/spaces/TEAM/pages/123\"\n---\n\n## Inline comments\n\n### marked excerpt\n> marked excerpt\n\n**Alice** · 2026-04-01\n\nLooks good to me.\n\n## Page comments\n\n### Discussion about the rollout\n\n**Bob** · 2026-04-02\n\nAre we shipping this Friday?\n```\n\nThe legacy boolean key `inline_comments` is migrated automatically: `true` becomes `\"inline\"`, `false` becomes `\"none\"`.\n\n- Default: `none`\n- ENV Var: `CME_EXPORT__COMMENTS_EXPORT`\n\n### export.convert_status_badges\n\nWhether to convert Confluence status badge macros to HTML `<mark>` elements coloured with the badge's background colour. Each lozenge variant maps to an Atlassian design-system pastel:\n\n| Lozenge        | Colour          | Hex       |\n| -------------- | --------------- | --------- |\n| Gray (default) | Gray            | `#dfe1e6` |\n| Blue           | Blue            | `#cce0ff` |\n| Green          | Green           | `#baf3db` |\n| Yellow         | Yellow / Orange | `#f8e6a0` |\n| Red            | Red             | `#ffd5d2` |\n| Purple         | Purple / Violet | `#dfd8fd` |\n\nWhen disabled, only the badge label text is kept.\n\n- Default: `True`\n- ENV Var: `CME_EXPORT__CONVERT_STATUS_BADGES`\n\n### export.convert_text_highlights\n\nWhether to convert Confluence text highlights (`<span style=\"background-color: rgb(...);\">`) to HTML `<mark>` elements with a hex color value. When disabled, the highlight span is stripped and only the plain text is kept.\n\n- Default: `True`\n- ENV Var: `CME_EXPORT__CONVERT_TEXT_HIGHLIGHTS`\n\n### export.convert_font_colors\n\nWhether to convert Confluence font colors to HTML `<font>` elements with a hex color value. Handles both inline-style spans (`<span style=\"color: rgb(...);\">`) and CSS-class-based spans (`<span data-colorid=\"...\">`) used in the Confluence export view. When disabled, the color span is stripped and only the plain text is kept.\n\n- Default: `True`\n- ENV Var: `CME_EXPORT__CONVERT_FONT_COLORS`\n\n### export.skip_unchanged\n\nSkip exporting pages that have not changed since last export. Uses a lockfile to track page versions.\n\n- Default: `True`\n- ENV Var: `CME_EXPORT__SKIP_UNCHANGED`\n\n### export.cleanup_stale\n\nAfter export, delete local files for pages removed from Confluence or whose export path has changed.\n\n- Default: `True`\n- ENV Var: `CME_EXPORT__CLEANUP_STALE`\n\n### export.lockfile_name\n\nName of the lock file used to track exported pages.\n\n- Default: `confluence-lock.json`\n- ENV Var: `CME_EXPORT__LOCKFILE_NAME`\n\n### export.existence_check_batch_size\n\nNumber of page IDs per batch when checking page existence during cleanup. Capped at 25 for self-hosted (CQL).\n\n- Default: `250`\n- ENV Var: `CME_EXPORT__EXISTENCE_CHECK_BATCH_SIZE`\n\n## connection_config.\\*\n\n### connection_config.backoff_and_retry\n\nEnable or disable automatic retry with exponential backoff on network errors.\n\n- Default: `True`\n- ENV Var: `CME_CONNECTION_CONFIG__BACKOFF_AND_RETRY`\n\n### connection_config.backoff_factor\n\nMultiplier for exponential backoff between retries. For example, `2` means each retry waits twice as long as the previous.\n\n- Default: `2`\n- ENV Var: `CME_CONNECTION_CONFIG__BACKOFF_FACTOR`\n\n### connection_config.max_backoff_seconds\n\nMaximum seconds to wait between retries.\n\n- Default: `60`\n- ENV Var: `CME_CONNECTION_CONFIG__MAX_BACKOFF_SECONDS`\n\n### connection_config.max_backoff_retries\n\nMaximum number of retry attempts before giving up.\n\n- Default: `5`\n- ENV Var: `CME_CONNECTION_CONFIG__MAX_BACKOFF_RETRIES`\n\n### connection_config.retry_status_codes\n\nHTTP status codes that trigger a retry.\n\n- Default: `[413, 429, 502, 503, 504]`\n- ENV Var: `CME_CONNECTION_CONFIG__RETRY_STATUS_CODES`\n\n### connection_config.timeout\n\nTimeout in seconds for API requests. Prevents hanging on slow or unresponsive servers.\n\n- Default: `30`\n- ENV Var: `CME_CONNECTION_CONFIG__TIMEOUT`\n\n### connection_config.verify_ssl\n\nWhether to verify SSL certificates for HTTPS requests. Set to `False` only if you are sure about the security of your connection.\n\n- Default: `True`\n- ENV Var: `CME_CONNECTION_CONFIG__VERIFY_SSL`\n\n### connection_config.use_v2_api\n\nEnable Confluence REST API v2 endpoints. Supported on Atlassian Cloud and Data Center 8+. Disable for self-hosted Server instances.\n\n- Default: `False`\n- ENV Var: `CME_CONNECTION_CONFIG__USE_V2_API`\n\n### connection_config.max_workers\n\nMaximum number of parallel workers for page export. Set to `1` for serial/debug mode. Higher values improve performance but may hit API rate limits.\n\n- Default: `20`\n- ENV Var: `CME_CONNECTION_CONFIG__MAX_WORKERS`\n"
  },
  {
    "path": "docs/contributing.md",
    "content": "---\nid: contributing\ntitle: Contributing\nsidebar_position: 7\n---\n\n# Contributing\n\nIf you would like to contribute to `confluence-markdown-exporter`, please read the [contribution guideline](https://github.com/Spenhouet/confluence-markdown-exporter/blob/main/CONTRIBUTING.md) in the repository.\n\n## Reporting issues\n\nUse the [GitHub issue tracker](https://github.com/Spenhouet/confluence-markdown-exporter/issues). When reporting, include:\n\n1. Your Confluence flavour and version (Cloud, Server, Data Center)\n2. The exact command you ran\n3. The full output with `cme config set export.log_level=DEBUG` enabled\n4. A minimal page (if possible) reproducing the issue\n\n## Docs site\n\nThe documentation site is built with [Docusaurus](https://docusaurus.io/) and deployed to GitHub Pages.\n\n- Sources live under `docs/` in the repository as plain Markdown / MDX.\n- Local preview: `npm ci && npm start` (serves `http://localhost:3000/confluence-markdown-exporter/`).\n- Production build with all versions: `npm run build:versioned` then `npm run serve`.\n\n### Versioning\n\nVersioning is **driven by git release tags**. There are no `versioned_docs/` folders committed to the repo. At build time, `scripts/build-versions.mjs`:\n\n1. Lists git tags matching `^\\d+\\.\\d+\\.\\d+$` (the project's release pattern).\n2. Filters to tags whose tree already contains a Docusaurus `docs/` + `sidebars.ts`.\n3. Snapshots each eligible tag into `versioned_docs/version-<tag>/` by checking out that tag's docs and running `docusaurus docs:version`.\n4. Builds with the newest tag set as the default version; HEAD becomes the `Next 🚧` (unreleased) version.\n\nThat means: cutting a new release tag automatically produces a new docs version on the next site build. Old versions cannot be edited after-the-fact; they are sourced directly from their git tag.\n\n## License\n\nThis tool is an open source project released under the [MIT License](https://github.com/Spenhouet/confluence-markdown-exporter/blob/main/LICENSE).\n"
  },
  {
    "path": "docs/docker.md",
    "content": "---\nid: docker\ntitle: Docker\nsidebar_position: 5\n---\n\n# Docker\n\nPrebuilt images are published to Docker Hub at [`spenhouet/confluence-markdown-exporter`](https://hub.docker.com/r/spenhouet/confluence-markdown-exporter).\n\nThe Docker image is intended for **non-interactive / CI use**: you supply a pre-defined config (either as a mounted JSON file or as environment variables), and the container runs a single export command and exits.\n\n:::note\nThe interactive `cme config` menu is **not** supported in this mode. Edit the JSON config file directly or change the env vars instead.\n:::\n\n## Available tags\n\n- `latest`: the most recent release\n- `<version>` (e.g. `5.1.0`): pinned release version\n- `<major>` / `<major>.<minor>` (e.g. `5`, `5.1`): rolling tags following the latest release within that range\n\n## Quick start\n\n```bash\ndocker pull spenhouet/confluence-markdown-exporter:latest\ndocker run --rm spenhouet/confluence-markdown-exporter --help\n```\n\nThe image pins `export.output_path` to `/data/output` (via the `CME_EXPORT__OUTPUT_PATH` env var baked into the image), overriding whatever value the mounted config file has. Bind-mount your host export directory there and exported files appear in it.\n\n## Providing configuration\n\nThe image reads its config from `/data/config/app_data.json` (set via `CME_CONFIG_PATH`). Generate this file once on a workstation by running `cme config` locally, then check it in to your CI repository or your secret store and mount it into the container, using the same pattern as a Kubernetes ConfigMap volume:\n\n```bash\ndocker run --rm \\\n  -v \"$PWD/app_data.json:/data/config/app_data.json:ro\" \\\n  -v \"$PWD/output:/data/output\" \\\n  spenhouet/confluence-markdown-exporter \\\n  pages <page-url>\n```\n\nThe mounted file must be readable by UID `1000` (the non-root `cme` user inside the image). For a config file managed in a CI runner this is usually already the case; if not, `chmod 644 app_data.json` is enough.\n\nIn Docker Compose, the [`configs:`](https://docs.docker.com/reference/compose-file/configs/) top-level key expresses the same mount declaratively:\n\n```yaml\nservices:\n  cme:\n    image: spenhouet/confluence-markdown-exporter:latest\n    command: [\"pages\", \"<page-url>\"]\n    configs:\n      - source: cme_config\n        target: /data/config/app_data.json\n    volumes:\n      - ./output:/data/output\n\nconfigs:\n  cme_config:\n    file: ./app_data.json\n```\n\n## Overriding scalar settings via environment variables\n\nScalar settings can be overridden at runtime with environment variables using the `CME_` prefix and `__` as the nested delimiter:\n\n```bash\ndocker run --rm \\\n  -e CME_EXPORT__LOG_LEVEL=DEBUG \\\n  -e CME_CONNECTION_CONFIG__MAX_WORKERS=5 \\\n  -v \"$PWD/app_data.json:/data/config/app_data.json:ro\" \\\n  -v \"$PWD/output:/data/output\" \\\n  spenhouet/confluence-markdown-exporter \\\n  pages <page-url>\n```\n\nSee the full [options reference](./configuration/options.md) for every supported `CME_*` env var.\n\n## Auth credentials in environment variables\n\n:::warning\nThe `auth.confluence` and `auth.jira` settings are dicts keyed by the instance base URL. That URL key cannot be expressed inside an environment variable name.\n:::\n\nIf you must inject auth credentials via env vars (e.g. to keep secrets out of the JSON file), supply the whole sub-dict as a single JSON-encoded value:\n\n```bash\ndocker run --rm \\\n  -v \"$PWD/app_data.json:/data/config/app_data.json:ro\" \\\n  -e CME_AUTH__CONFLUENCE=\"{\\\"https://company.atlassian.net\\\":{\\\"username\\\":\\\"$CONFLUENCE_USER\\\",\\\"api_token\\\":\\\"$CONFLUENCE_API_TOKEN\\\"}}\" \\\n  -v \"$PWD/output:/data/output\" \\\n  spenhouet/confluence-markdown-exporter \\\n  pages <page-url>\n```\n\nFor most CI setups it is simpler to template the JSON file from the CI secret store before running the container.\n\n## See also\n\n- [Authentication](./configuration/authentication.md): full credential setup and scoped-token notes\n- [CI / non-interactive](./configuration/ci.md): `CI=true`, `NO_COLOR`, log-level control\n- [Installation](./installation.md): pip / uv / curl / PowerShell installers\n"
  },
  {
    "path": "docs/features.md",
    "content": "---\nid: features\ntitle: Features\nsidebar_position: 3\n---\n\n# Features\n\nExports individual pages, pages with descendants, or entire spaces via the Atlassian API. Skips unchanged pages by default, re-exporting only what has changed since the last run.\n\n## Supported Confluence features\n\n### Content & formatting\n\n- **Rich text**: headings, paragraphs, bold, italic, underline, lists, tables, links, images, attachments, and image captions\n- **Code blocks**: language-aware fenced code blocks\n- **Task lists**: checkboxes with completion state\n- **Text highlights & font colours**: preserved with inline HTML colour styling\n- **Status badges**: converted to coloured inline highlights\n- **Info / note / tip / warning panels**: converted to Markdown alert blocks (`[!NOTE]`, `[!TIP]`, …)\n- **Comments**: open inline and/or page-level (footer) comments exported as sidecar files next to each page\n- **Include / excerpt-include macros**: embedded pages either inlined or exported as Obsidian transclusion links (`![[Page Title]]`)\n\n### Page metadata\n\n- **Page properties**: Page Properties macro exported as YAML front matter, [Dataview](https://blacksmithgu.github.io/obsidian-dataview/) inline fields, or [Meta Bind](https://www.moritzjung.dev/obsidian-meta-bind-plugin-docs/) VIEW fields; duplicate keys are disambiguated automatically (configurable via [`export.page_properties_format`](./configuration/options.md#exportpage_properties_format))\n- **Page Properties Report**: dynamic cross-page property tables exported as a static snapshot or a live [Dataview](https://blacksmithgu.github.io/obsidian-dataview/) DQL query (configurable via [`export.page_properties_report_format`](./configuration/options.md#exportpage_properties_report_format))\n- **Page labels**: exported as `tags` in YAML front matter\n\n### Diagrams & add-ons\n\n- **[draw.io](https://marketplace.atlassian.com/apps/1210933/draw-io-diagrams-uml-bpmn-aws-erd-flowcharts)**: diagram files saved as attachments; embedded Mermaid diagrams extracted as fenced Mermaid blocks\n- **[PlantUML](https://marketplace.atlassian.com/apps/1222993/flowchart-plantuml-diagrams-for-confluence)**: exported as fenced PlantUML code blocks\n- **[Markdown Extensions](https://marketplace.atlassian.com/apps/1215703/markdown-extensions-for-confluence)**: pass-through of raw Markdown macro content\n"
  },
  {
    "path": "docs/installation.md",
    "content": "---\nid: installation\ntitle: Installation\nsidebar_position: 1\n---\n\nimport Tabs from '@theme/Tabs';\nimport TabItem from '@theme/TabItem';\nimport { VerifyTabs } from '@site/src/components/quickstart';\n\n# Installation\n\nPick the install method that fits your environment. All methods produce the same `cme` / `confluence-markdown-exporter` CLI.\n\n<Tabs groupId=\"install-method\" queryString>\n\n<TabItem value=\"linux\" label=\"Linux\">\n\n```bash\ncurl -LsSf uvx.sh/confluence-markdown-exporter/install.sh | sh\n```\n\nUses [uv](https://docs.astral.sh/uv/) under the hood to create an isolated, self-updating environment. No need to manage a virtualenv yourself.\n\n</TabItem>\n\n<TabItem value=\"macos\" label=\"macOS\">\n\n```bash\ncurl -LsSf uvx.sh/confluence-markdown-exporter/install.sh | sh\n```\n\nUses [uv](https://docs.astral.sh/uv/) under the hood to create an isolated, self-updating environment. No need to manage a virtualenv yourself.\n\n</TabItem>\n\n<TabItem value=\"windows\" label=\"Windows\">\n\n```powershell\npowershell -ExecutionPolicy ByPass -c \"irm https://uvx.sh/confluence-markdown-exporter/install.ps1 | iex\"\n```\n\nUses [uv](https://docs.astral.sh/uv/) under the hood. Run from PowerShell.\n\n</TabItem>\n\n<TabItem value=\"pip\" label=\"pip\">\n\n```bash\npip install confluence-markdown-exporter\n```\n\nInstalls from PyPI into the active Python environment. Requires Python ≥ 3.10. If you don't already have a project virtualenv, prefer the **uv** or **Linux/macOS/Windows installer** tabs; they isolate the tool for you.\n\n</TabItem>\n\n<TabItem value=\"uv\" label=\"uv\">\n\n```bash\n# Install as an isolated, self-managed tool\nuv tool install confluence-markdown-exporter\n\n# …or run it once without installing\nuvx confluence-markdown-exporter --help\n```\n\n[`uv tool install`](https://docs.astral.sh/uv/concepts/tools/) puts the CLI on your PATH inside its own isolated environment. [`uvx`](https://docs.astral.sh/uv/guides/tools/) runs it ephemerally; handy for one-off exports or CI.\n\n</TabItem>\n\n<TabItem value=\"docker\" label=\"Docker\">\n\n```bash\ndocker pull spenhouet/confluence-markdown-exporter:latest\ndocker run --rm spenhouet/confluence-markdown-exporter --help\n```\n\nThe Docker image is intended for **non-interactive / CI use**: you supply a pre-defined config (mounted JSON file or env vars) and the container runs a single export command and exits. The interactive `cme config` menu is not available inside the container. Full setup (mounted config, Compose example, env-var auth) is on the [Docker page](./docker.md).\n\n</TabItem>\n\n</Tabs>\n\n## Pinning a specific version\n\n<Tabs groupId=\"install-method\" queryString>\n\n<TabItem value=\"linux\" label=\"Linux\">\n\n```bash\ncurl -LsSf uvx.sh/confluence-markdown-exporter/5.1.1/install.sh | sh\n```\n\n</TabItem>\n\n<TabItem value=\"macos\" label=\"macOS\">\n\n```bash\ncurl -LsSf uvx.sh/confluence-markdown-exporter/5.1.1/install.sh | sh\n```\n\n</TabItem>\n\n<TabItem value=\"windows\" label=\"Windows\">\n\n```powershell\npowershell -ExecutionPolicy ByPass -c \"irm https://uvx.sh/confluence-markdown-exporter/5.1.1/install.ps1 | iex\"\n```\n\n</TabItem>\n\n<TabItem value=\"pip\" label=\"pip\">\n\n```bash\npip install confluence-markdown-exporter==5.1.1\n```\n\n</TabItem>\n\n<TabItem value=\"uv\" label=\"uv\">\n\n```bash\nuv tool install confluence-markdown-exporter==5.1.1\n```\n\n</TabItem>\n\n<TabItem value=\"docker\" label=\"Docker\">\n\n```bash\ndocker pull spenhouet/confluence-markdown-exporter:5.1.1\n```\n\nPinned tags are kept available indefinitely; rolling tags (`latest`, `<major>`, `<major>.<minor>`) advance with each release. See [Docker → Available tags](./docker.md#available-tags).\n\n</TabItem>\n\n</Tabs>\n\n## Verify the install\n\n<VerifyTabs />\n\nYou should see the top-level commands: `pages`, `pages-with-descendants`, `spaces`, `orgs`, and `config`.\n\n## Next steps\n\n- [Authenticate and configure your first export →](./configuration/index.md#interactive-menu) (local install)\n- [Export pages or whole spaces →](./usage.md) (local install)\n- [Docker page](./docker.md): non-interactive setup (mounted config + env vars)\n"
  },
  {
    "path": "docs/intro.md",
    "content": "---\nid: intro\ntitle: Introduction\nsidebar_position: 1\n---\n\nimport Tabs from '@theme/Tabs';\nimport TabItem from '@theme/TabItem';\nimport { AuthenticateTabs, ExportTabs } from '@site/src/components/quickstart';\nimport Logo from '@site/static/img/logo.png';\n\n<div style={{textAlign: 'center', padding: '1rem 0 2rem'}}>\n  <img src={Logo} alt=\"confluence-markdown-exporter\" style={{maxWidth: '420px', width: '100%'}} />\n</div>\n\n> Export Confluence pages to Markdown for Obsidian, Gollum, Azure DevOps, Foam, Dendron and any other Markdown-based platform.\n\nExports individual pages, pages with descendants, or entire Confluence spaces via the Atlassian API into clean Markdown. Skips unchanged pages by default, re-exporting only what has changed since the last run.\n\n## What's in these docs\n\n- **[Installation](./installation.md)**: install and update the CLI in one command\n- **[Usage](./usage.md)**: export pages, descendants, spaces, or organisations\n- **[Features](./features.md)**: supported Confluence content, macros, and add-ons\n- **[Configuration](./configuration/index.md)**: every option with defaults and ENV vars\n- **[Target systems](./configuration/target-systems.md)**: Obsidian, Azure DevOps, …\n- **[Troubleshooting](./troubleshooting.md)**: known issues and how to report\n\n## Get started in 60 seconds\n\n### 1. Install\n\n<Tabs groupId=\"install-method\" queryString>\n\n<TabItem value=\"linux\" label=\"Linux\">\n\n```bash\ncurl -LsSf uvx.sh/confluence-markdown-exporter/install.sh | sh\n```\n\n</TabItem>\n\n<TabItem value=\"macos\" label=\"macOS\">\n\n```bash\ncurl -LsSf uvx.sh/confluence-markdown-exporter/install.sh | sh\n```\n\n</TabItem>\n\n<TabItem value=\"windows\" label=\"Windows\">\n\n```powershell\npowershell -ExecutionPolicy ByPass -c \"irm https://uvx.sh/confluence-markdown-exporter/install.ps1 | iex\"\n```\n\n</TabItem>\n\n<TabItem value=\"pip\" label=\"pip\">\n\n```bash\npip install confluence-markdown-exporter\n```\n\n</TabItem>\n\n<TabItem value=\"uv\" label=\"uv\">\n\n```bash\nuv tool install confluence-markdown-exporter\n# or, one-shot run without installing:\nuvx confluence-markdown-exporter --help\n```\n\n</TabItem>\n\n<TabItem value=\"docker\" label=\"Docker\">\n\n```bash\ndocker pull spenhouet/confluence-markdown-exporter:latest\ndocker run --rm spenhouet/confluence-markdown-exporter --help\n```\n\nThe Docker image is intended for non-interactive / CI use; see the [Docker page](./docker.md) for config-file mounts and environment variables.\n\n</TabItem>\n\n</Tabs>\n\n### 2. Authenticate\n\n<AuthenticateTabs />\n\n### 3. Export\n\n<ExportTabs />\n\nYour Markdown lands in the configured `export.output_path` (current directory by default).\n"
  },
  {
    "path": "docs/troubleshooting.md",
    "content": "---\nid: troubleshooting\ntitle: Troubleshooting\nsidebar_position: 6\n---\n\n# Troubleshooting\n\n## Known issues and limitations\n\n### Missing attachment file ID on Server\n\nFor some Confluence Server versions / configurations, the attachment file ID is not returned by the API ([#39](https://github.com/Spenhouet/confluence-markdown-exporter/issues/39)).\n\nIn that case, `{attachment_file_id}` automatically falls back to the content id, so the default [`export.attachment_path`](./configuration/options.md#exportattachment_path) template still produces unique filenames out of the box.\n\nIf you prefer human-readable filenames over numeric IDs, set `export.attachment_path` to use `{attachment_title}{attachment_extension}`, e.g.:\n\n```sh\ncme config set export.attachment_path='{space_name}/attachments/{attachment_title}{attachment_extension}'\n```\n\n### Connection issues behind proxy or VPN\n\nThere might be connection issues if your Confluence Server is behind a proxy or VPN ([#38](https://github.com/Spenhouet/confluence-markdown-exporter/issues/38)). If you experience issues, help to fix this is appreciated.\n\n## Reporting bugs\n\nOpen an issue on the [GitHub issue tracker](https://github.com/Spenhouet/confluence-markdown-exporter/issues) and include:\n\n1. Your Confluence flavour and version (Cloud, Server, Data Center)\n2. The exact command you ran\n3. The full output, ideally with `cme config set export.log_level=DEBUG` enabled\n4. A minimal example page (if possible) reproducing the issue\n"
  },
  {
    "path": "docs/usage.md",
    "content": "---\nid: usage\ntitle: Usage\nsidebar_position: 2\n---\n\n# Usage\n\nRun the exporter with the desired Confluence page URL or space URL. Execute the console application by typing `confluence-markdown-exporter` (or its shorter alias `cme`) followed by one of the commands `pages`, `pages-with-descendants`, `spaces`, `orgs`, or `config`. Add `--help` to any command for additional information.\n\nAll export commands accept one or more URLs as space-separated arguments. Each command also has a singular alias (`page`, `page-with-descendants`, `space`, `org`) that behaves identically.\n\n## Export pages\n\nExport one or more Confluence pages by URL:\n\n```sh\ncme pages <page-url>\ncme pages <page-url-1> <page-url-2> ...\n\n# Singular alias (identical behaviour):\ncme page <page-url>\n```\n\nSupported page URL formats:\n\n- Confluence Cloud: `https://company.atlassian.net/wiki/spaces/SPACEKEY/pages/123456789/Page+Title`\n- Confluence Cloud (API gateway): `https://api.atlassian.com/ex/confluence/CLOUDID/wiki/spaces/SPACEKEY/pages/123456789/Page+Title`\n- Confluence Server (long): `https://wiki.company.com/display/SPACEKEY/Page+Title`\n- Confluence Server (short): `https://wiki.company.com/SPACEKEY/Page+Title`\n- Confluence Server (param): `https://wiki.company.com/pages/viewpage.action?pageId=123456789`\n\n## Export pages with descendants\n\nExport one or more Confluence pages and all their descendant pages by URL:\n\n```sh\ncme pages-with-descendants <page-url>\ncme pages-with-descendants <page-url-1> <page-url-2> ...\n\n# Singular alias (identical behaviour):\ncme page-with-descendants <page-url>\n```\n\n## Export spaces\n\nExport all Confluence pages of one or more spaces by URL:\n\n```sh\ncme spaces <space-url>\ncme spaces <space-url-1> <space-url-2> ...\n\n# Singular alias (identical behaviour):\ncme space <space-url>\n```\n\nSupported space URL formats:\n\n- Confluence Cloud: `https://company.atlassian.net/wiki/spaces/SPACEKEY`\n- Confluence Cloud (API gateway): `https://api.atlassian.com/ex/confluence/CLOUDID/wiki/spaces/SPACEKEY`\n- Confluence Server (long): `https://wiki.company.com/display/SPACEKEY`\n- Confluence Server (short): `https://wiki.company.com/SPACEKEY`\n\n## Export all spaces of an organization\n\nExport all Confluence pages across all spaces of one or more organizations by URL:\n\n```sh\ncme orgs <base-url>\ncme orgs <base-url-1> <base-url-2> ...\n\n# Singular alias (identical behaviour):\ncme org <base-url>\n```\n\n## Output layout\n\nThe exported Markdown file(s) will be saved in the configured output directory (see [`export.output_path`](./configuration/options.md#exportoutput_path)) e.g.:\n\n```text\noutput_path/\n└── MYSPACE/\n   ├── MYSPACE.md\n   └── MYSPACE/\n      ├── My Confluence Page.md\n      └── My Confluence Page/\n            ├── My nested Confluence Page.md\n            └── Another one.md\n```\n"
  },
  {
    "path": "docusaurus.config.ts",
    "content": "import { themes as prismThemes } from \"prism-react-renderer\";\nimport type { Config } from \"@docusaurus/types\";\nimport type * as Preset from \"@docusaurus/preset-classic\";\n\nconst config: Config = {\n  title: \"Confluence Markdown Exporter\",\n  tagline:\n    \"Export Confluence pages to Markdown for Obsidian, Gollum, Azure DevOps, Foam, Dendron and more.\",\n  favicon: \"img/favicon.svg\",\n\n  url: \"https://spenhouet.github.io\",\n  baseUrl: \"/confluence-markdown-exporter/\",\n\n  organizationName: \"Spenhouet\",\n  projectName: \"confluence-markdown-exporter\",\n  trailingSlash: false,\n\n  onBrokenLinks: \"throw\",\n\n  i18n: {\n    defaultLocale: \"en\",\n    locales: [\"en\"],\n  },\n\n  presets: [\n    [\n      \"classic\",\n      {\n        docs: {\n          sidebarPath: \"./sidebars.ts\",\n          routeBasePath: \"/\",\n          editUrl:\n            \"https://github.com/Spenhouet/confluence-markdown-exporter/edit/main/\",\n          showLastUpdateAuthor: true,\n          showLastUpdateTime: true,\n          // Versioning is driven by git tags via scripts/build-versions.mjs.\n          // The script writes versioned_docs/, versioned_sidebars/, versions.json\n          // at build time and exports DOCS_LAST_VERSION pointing at the newest tag.\n          lastVersion: process.env.DOCS_LAST_VERSION || \"current\",\n          versions: {\n            current: {\n              label: process.env.DOCS_LAST_VERSION ? \"Next 🚧\" : \"Current\",\n              path: process.env.DOCS_LAST_VERSION ? \"next\" : \"\",\n              banner: process.env.DOCS_LAST_VERSION ? \"unreleased\" : \"none\",\n            },\n          },\n        },\n        blog: false,\n        theme: {\n          customCss: \"./src/css/custom.css\",\n        },\n        sitemap: {\n          changefreq: \"weekly\",\n          priority: 0.5,\n        },\n      } satisfies Preset.Options,\n    ],\n  ],\n\n  themeConfig: {\n    image: \"img/logo.png\",\n    colorMode: {\n      defaultMode: \"dark\",\n      respectPrefersColorScheme: true,\n    },\n    announcementBar: {\n      id: \"github_star\",\n      content:\n        '⭐ If you like <strong>confluence-markdown-exporter</strong>, star it on <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://github.com/Spenhouet/confluence-markdown-exporter\">GitHub</a>!',\n      backgroundColor: \"var(--ifm-color-primary-darker)\",\n      textColor: \"#ffffff\",\n      isCloseable: true,\n    },\n    navbar: {\n      title: \"Confluence Markdown Exporter\",\n      logo: {\n        alt: \"confluence-markdown-exporter logo\",\n        src: \"img/favicon.svg\",\n      },\n      items: [\n        {\n          type: \"docSidebar\",\n          sidebarId: \"docsSidebar\",\n          position: \"left\",\n          label: \"Docs\",\n        },\n        {\n          type: \"docsVersionDropdown\",\n          position: \"right\",\n          dropdownActiveClassDisabled: true,\n        },\n        {\n          href: \"https://pypi.org/project/confluence-markdown-exporter/\",\n          label: \"PyPI\",\n          position: \"right\",\n        },\n        {\n          href: \"https://github.com/Spenhouet/confluence-markdown-exporter\",\n          position: \"right\",\n          className: \"header-github-link\",\n          \"aria-label\": \"GitHub repository\",\n        },\n      ],\n    },\n    footer: {\n      style: \"dark\",\n      links: [\n        {\n          title: \"Docs\",\n          items: [\n            { label: \"Installation\", to: \"/installation\" },\n            { label: \"Usage\", to: \"/usage\" },\n            { label: \"Configuration\", to: \"/configuration/\" },\n            { label: \"Features\", to: \"/features\" },\n          ],\n        },\n        {\n          title: \"Community\",\n          items: [\n            {\n              label: \"Issues\",\n              href: \"https://github.com/Spenhouet/confluence-markdown-exporter/issues\",\n            },\n            {\n              label: \"Discussions\",\n              href: \"https://github.com/Spenhouet/confluence-markdown-exporter/discussions\",\n            },\n          ],\n        },\n        {\n          title: \"More\",\n          items: [\n            { label: \"Contributing\", to: \"/contributing\" },\n            {\n              label: \"GitHub\",\n              href: \"https://github.com/Spenhouet/confluence-markdown-exporter\",\n            },\n            {\n              label: \"PyPI\",\n              href: \"https://pypi.org/project/confluence-markdown-exporter/\",\n            },\n          ],\n        },\n      ],\n      copyright: `Copyright © ${new Date().getFullYear()} Sebastian Penhouet. Built with Docusaurus.`,\n    },\n    prism: {\n      theme: prismThemes.github,\n      darkTheme: prismThemes.dracula,\n      additionalLanguages: [\"bash\", \"powershell\", \"yaml\", \"json\", \"toml\", \"diff\"],\n    },\n    docs: {\n      sidebar: {\n        hideable: true,\n        autoCollapseCategories: false,\n      },\n    },\n    tableOfContents: {\n      minHeadingLevel: 2,\n      maxHeadingLevel: 4,\n    },\n  } satisfies Preset.ThemeConfig,\n\n  plugins: [\n    [\n      require.resolve(\"@easyops-cn/docusaurus-search-local\"),\n      {\n        hashed: true,\n        indexBlog: false,\n        docsRouteBasePath: \"/\",\n        highlightSearchTermsOnTargetPage: true,\n        explicitSearchResultPath: true,\n      },\n    ],\n  ],\n\n  markdown: {\n    mermaid: false,\n    hooks: {\n      onBrokenMarkdownLinks: \"warn\",\n    },\n  },\n};\n\nexport default config;\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"confluence-markdown-exporter-docs\",\n  \"version\": \"0.0.0\",\n  \"private\": true,\n  \"scripts\": {\n    \"docusaurus\": \"docusaurus\",\n    \"start\": \"docusaurus start\",\n    \"build\": \"docusaurus build\",\n    \"build:versioned\": \"node scripts/build-versions.mjs\",\n    \"swizzle\": \"docusaurus swizzle\",\n    \"deploy\": \"docusaurus deploy\",\n    \"clear\": \"docusaurus clear\",\n    \"serve\": \"docusaurus serve\",\n    \"write-translations\": \"docusaurus write-translations\",\n    \"write-heading-ids\": \"docusaurus write-heading-ids\",\n    \"typecheck\": \"tsc\"\n  },\n  \"dependencies\": {\n    \"@docusaurus/core\": \"^3.10.1\",\n    \"@docusaurus/preset-classic\": \"^3.10.1\",\n    \"@easyops-cn/docusaurus-search-local\": \"^0.46.1\",\n    \"@mdx-js/react\": \"^3.0.0\",\n    \"clsx\": \"^2.0.0\",\n    \"prism-react-renderer\": \"^2.3.0\",\n    \"react\": \"^18.0.0\",\n    \"react-dom\": \"^18.0.0\"\n  },\n  \"devDependencies\": {\n    \"@docusaurus/module-type-aliases\": \"^3.10.1\",\n    \"@docusaurus/tsconfig\": \"^3.10.1\",\n    \"@docusaurus/types\": \"^3.10.1\",\n    \"typescript\": \"~5.5.2\"\n  },\n  \"browserslist\": {\n    \"production\": [\n      \">0.5%\",\n      \"not dead\",\n      \"not op_mini all\"\n    ],\n    \"development\": [\n      \"last 3 chrome version\",\n      \"last 3 firefox version\",\n      \"last 5 safari version\"\n    ]\n  },\n  \"engines\": {\n    \"node\": \">=18.0\"\n  }\n}\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n\n[project]\nname = \"confluence-markdown-exporter\"\nversion = \"5.1.1\"\ndescription = \"A tool to export Confluence pages to Markdown\"\nkeywords = [\"confluence\", \"atlassian\", \"markdown\", \"export\", \"convertion\", \"download\"]\nreadme = \"README.md\"\nlicense = { text = \"MIT\" }\nauthors = [\n    { name = \"Sebastian Penhouet\" }\n]\nrequires-python = \">= 3.10\"\ndependencies = [\n    'atlassian-python-api',\n    'jmespath',\n    'markdownify',\n    'pydantic-settings',\n    'pyyaml',\n    'questionary',\n    'rich',\n    'tabulate',\n    'typer',\n    'python-dateutil',\n    \"lxml>=6.0.2\",\n]\n\n\n[dependency-groups]\ndev = [\n    \"pytest>=8.4.1\",\n    \"ruff>=0.11.13\",\n]\n\n[project.urls]\nHomepage = \"https://github.com/Spenhouet/confluence-markdown-exporter\"\nDocumentation = \"https://spenhouet.github.io/confluence-markdown-exporter/\"\nSource = \"https://github.com/Spenhouet/confluence-markdown-exporter\"\nTracker = \"https://github.com/Spenhouet/confluence-markdown-exporter/issues\"\n\n[project.scripts]\nconfluence-markdown-exporter = \"confluence_markdown_exporter.main:app\"\ncme = \"confluence_markdown_exporter.main:app\"\n\n[tool.hatch.build.targets.wheel]\npackages = [\"confluence_markdown_exporter\"]\n\n[tool.ruff]\n# Exclude a variety of commonly ignored directories. This means Ruff will not lint or format files with these names\nexclude = [\n  \".bzr\",\n  \".direnv\",\n  \".eggs\",\n  \".git\",\n  \".git-rewrite\",\n  \".hg\",\n  \".ipynb_checkpoints\",\n  \".mypy_cache\",\n  \".nox\",\n  \".pants.d\",\n  \".pyenv\",\n  \".pytest_cache\",\n  \".pytype\",\n  \".ruff_cache\",\n  \".svn\",\n  \".tox\",\n  \".venv\",\n  \".vscode\",\n  \"__pypackages__\",\n  \"_build\",\n  \"buck-out\",\n  \"build\",\n  \"dist\",\n  \"node_modules\",\n  \"site-packages\",\n  \"venv\",\n]\n\nindent-width = 4 # each indent is 4 spaces, equivalent to using \"tab\" \nline-length = 100 # max no of characters in a line. Black default is 88 characters\ntarget-version = \"py310\" # Assumes Python 3.10 and above\n\n[tool.ruff.lint]\nselect = [\n  \"A\", # flake8-builtins\n  \"B\", # flake8-bugbear\n  \"D\", # pydocstyle\n  \"E\", # pycodestyle errors\n  \"F\", # pyflakes\n  \"G\", # flake8-logging-format\n  \"I\", # isort\n  \"N\", # pep8-naming\n  \"S\", # flake8-bandit\n  \"W\", # pycodestyle warnings\n  \"C4\", # flake8-comprehensions\n  \"EM\", # flake8-errmsg\n  \"PD\", # pandas-vet\n  \"PL\", # Pylint\n  \"UP\", # pyupgrade - auto-upgrade syntax for current version of Python\n  \"ANN\", # flake8-annotations\n  \"BLE\", # flake8-blind-except\n  \"C90\", # McCabe complexity checker\n  \"ERA\", # eradicate - removes commented out code\n  \"FBT\", # flake8-boolean-trap\n  \"FLY\", # flynt\n  \"ICN\", # flake8-import-conventions\n  \"LOG\", # flake8-logger\n  \"NPY\", # numpy-specific rules\n  \"PGH\", # pygrep-hooks\n  \"PIE\", # flake8-pie\n  \"RET\", # flake8-return\n  \"RSE\", # flake8-raise\n  \"SIM\", # flake8-simplify\n  \"RUF\", # ruff-specific rules\n  \"TCH\", # flake8-type-checking\n  \"TID\", # flake8-tidy-imports\n  \"TRY\", # tryceratops\n  \"ASYNC\", # flake8-async\n  \"PT\", # flake8-pytest-style\n  \"FAST\", # FastAPI,\n  \"T20\", # flake8-print\n  \"ARG\", # flake8-unused-arguments\n  \"PTH\", # flake8-use-pathlib\n  \"PERF\", # Perflint\n  \"FURB\", # refurb\n]\n\nignore = [\n  \"W191\", # lint rule that may clash with Ruff Formatter: tab-indentation\n  \"E111\", # lint rule that may clash with Ruff Formatter: indentation-with-invalid-multiple\n  \"E114\", # lint rule that may clash with Ruff Formatter: indentation-with-invalid-multiple-comment\n  \"E117\", # lint rule that may clash with Ruff Formatter: over-indented\n  \"D206\", # lint rule that may clash with Ruff Formatter: indent-with-spaces\n  \"D300\", # lint rule that may clash with Ruff Formatter: triple-single-quotes      \n  \"D1\", # ignore this to match google docstring convention\n  \"G004\", # ignore this to allow f-strings in logging\n  \"UP015\", # ignore this to allow \"with open\" statements to have modes explicitly stated \n  \"SIM102\", # ignore this to avoid changing nested if statements to single if statements, potentially confusing\n  \"ERA001\", # ignore this to keep commented out lines while functionality is not implemented (configs/logos)\n  \"PERF203\", # ignore this as this often is intentional.\n  \"ARG002\", # Many methods in this project share the same signature, independent of variable usage.\n  \"PLC0415\", # Allow lacy loading of imports\n]\n\nfixable = [\"ALL\"] # Allow fix for all enabled rules (when using \"Fix all\" or when `--fix` is provided to ruff check in CLI)\nunfixable = [\"F401\"] # disable autofix for unused-imports\n\ndummy-variable-rgx = \"^(_+\\\\w*)$\" # Allow unused variables when underscore-prefixed\nflake8-bugbear.extend-immutable-calls = [\n  \"fastapi.Depends\",\n  \"fastapi.Query\",\n] # Allow default arguments like, e.g., `data: List[str] = fastapi.Query(None)`  \npycodestyle.max-doc-length = 100 # max line-length for docstrings\npydocstyle.convention = \"google\" # docstring convention. Options: \"google\", \"numpy\", or \"pep257\"\npylint.max-args = 10 # max no of args in a function\n\n[tool.ruff.lint.isort]\nknown-first-party = [\"airamed\", \"main\"]\nforce-single-line = true # force each import to be in its own line\n\n[tool.ruff.format]\ndocstring-code-format = true # Enable auto-formatting of code examples in docstrings. Markdown, reStructuredText code/literal blocks and doctests are all supported\ndocstring-code-line-length = \"dynamic\" # Set line length limit used when formatting code snippets in docstrings. This only has an effect when the `docstring-code-format` setting is enabled\nindent-style = \"space\" # indent with spaces, rather than \"tab\"\nline-ending = \"lf\" # options: \"auto\", \"lf\", \"cr-lf\", \"native\"\nquote-style = \"double\" # Use double quotes as voted by majority\nskip-magic-trailing-comma = false # respects magic trailing commas\n\n# Ignore S101 (assert) in all test files\n[tool.ruff.lint.per-file-ignores]\n\"tests/**/*.py\" = [\n  \"S101\",    # Assert in tests is expected\n  \"S110\",    # try-except-pass detected\n  \"FBT001\",  # Often conflicts in tests\n  \"PLR2004\", # Magic numbers are acceptable in tests\n]\n"
  },
  {
    "path": "scripts/build-versions.mjs",
    "content": "#!/usr/bin/env node\n/**\n * Build the Docusaurus site with per-tag versioned docs derived from git history.\n *\n * Strategy:\n *   1. List git tags matching the project release pattern (e.g. \"5.1.0\").\n *   2. Keep only the tags whose tree already contains a Docusaurus-style\n *      docs/ tree and sidebars.ts (i.e. tags cut after docs migration).\n *   3. For each eligible tag (newest -> oldest):\n *        a. Copy docs/ + sidebars.ts from that tag into the working tree.\n *        b. Run `docusaurus docs:version <tag>` to snapshot it.\n *        c. Restore HEAD docs/ + sidebars.ts.\n *   4. Set DOCS_LAST_VERSION env var to the newest tag and invoke `npm run build`.\n *\n * versioned_docs/, versioned_sidebars/, and versions.json are NOT committed to\n * the repo (see .gitignore). They are regenerated on every build.\n */\n\nimport { execSync } from \"node:child_process\";\nimport { existsSync, mkdtempSync, rmSync, cpSync } from \"node:fs\";\nimport { tmpdir } from \"node:os\";\nimport { join } from \"node:path\";\n\nconst log = (msg) => console.log(`[build-versions] ${msg}`);\n\nfunction sh(cmd, opts = {}) {\n  return execSync(cmd, { encoding: \"utf8\", stdio: [\"ignore\", \"pipe\", \"pipe\"], ...opts });\n}\n\nfunction tagHasDocusaurusDocs(tag) {\n  try {\n    execSync(`git cat-file -e ${tag}:sidebars.ts`, { stdio: \"ignore\" });\n    execSync(`git cat-file -e ${tag}:docs/intro.md`, { stdio: \"ignore\" });\n    return true;\n  } catch {\n    return false;\n  }\n}\n\nfunction listTags() {\n  const out = sh(\"git tag --sort=-version:refname\").trim();\n  if (!out) return [];\n  return out\n    .split(\"\\n\")\n    .map((t) => t.trim())\n    .filter((t) => /^\\d+\\.\\d+\\.\\d+$/.test(t));\n}\n\nfunction snapshotTag(tag) {\n  log(`Snapshotting ${tag}`);\n  const work = mkdtempSync(join(tmpdir(), `docs-${tag}-`));\n  try {\n    // Extract docs/ and sidebars.ts from the tag into a temp dir.\n    sh(`git archive ${tag} docs sidebars.ts | tar -x -C ${work}`, {\n      shell: \"/bin/bash\",\n    });\n    // Swap into the working tree, then snapshot, then restore HEAD.\n    rmSync(\"docs\", { recursive: true, force: true });\n    cpSync(join(work, \"docs\"), \"docs\", { recursive: true });\n    cpSync(join(work, \"sidebars.ts\"), \"sidebars.ts\");\n    execSync(`npx --no-install docusaurus docs:version ${tag}`, {\n      stdio: \"inherit\",\n    });\n  } finally {\n    rmSync(work, { recursive: true, force: true });\n    // Restore HEAD versions of docs/ and sidebars.ts.\n    execSync(\"git checkout HEAD -- docs sidebars.ts\", { stdio: \"ignore\" });\n  }\n}\n\nfunction main() {\n  // Refuse to run if working tree has uncommitted changes to docs or sidebars,\n  // because we temporarily overwrite them during snapshots.\n  const dirty = sh(\"git status --porcelain -- docs sidebars.ts\").trim();\n  if (dirty && !process.env.FORCE_BUILD_VERSIONS) {\n    console.error(\n      \"[build-versions] docs/ or sidebars.ts has uncommitted changes; refusing to run.\\n\" +\n        \"Commit / stash them first, or set FORCE_BUILD_VERSIONS=1 to override.\",\n    );\n    process.exit(1);\n  }\n\n  const tags = listTags();\n  const eligible = tags.filter(tagHasDocusaurusDocs);\n\n  log(\n    eligible.length\n      ? `Found ${eligible.length} eligible tag(s): ${eligible.join(\", \")}`\n      : \"No eligible release tags found; building HEAD as 'Current' only.\",\n  );\n\n  for (const tag of eligible) {\n    snapshotTag(tag);\n  }\n\n  const lastVersion = eligible[0] || \"\";\n  log(`DOCS_LAST_VERSION=${lastVersion || \"(unset, HEAD only)\"}`);\n\n  execSync(\"npm run build\", {\n    stdio: \"inherit\",\n    env: { ...process.env, DOCS_LAST_VERSION: lastVersion },\n  });\n}\n\nmain();\n"
  },
  {
    "path": "scripts/bump-docs-version.sh",
    "content": "#!/usr/bin/env bash\n# Bump every version-pinning reference in README and the documentation tree.\n#\n# Patterns rewritten:\n#   uvx.sh/confluence-markdown-exporter/<version>/install.sh\n#   uvx.sh/confluence-markdown-exporter/<version>/install.ps1\n#   confluence-markdown-exporter==<version>\n#   spenhouet/confluence-markdown-exporter:<version>    (Docker pin; :latest left alone)\n#\n# Auto-discovers any file under README.md, docs/, or src/ that contains one of\n# the patterns above, so no explicit file list needs to be maintained when new\n# docs pages adopt version-pinning snippets.\n#\n# Usage: scripts/bump-docs-version.sh <new-version>\nset -euo pipefail\n\nif [[ $# -ne 1 ]]; then\n  echo \"usage: $0 <new-version>\" >&2\n  exit 1\nfi\nNEW=\"$1\"\n\n# Validate: tolerate \"1.2.3\", \"1.2.3a4\", \"1.2.3rc1\" etc. (anything pip accepts).\nif [[ ! \"$NEW\" =~ ^[0-9]+\\.[0-9]+\\.[0-9]+([.-][0-9A-Za-z]+)*$ ]]; then\n  echo \"error: '$NEW' does not look like a valid version\" >&2\n  exit 1\nfi\n\nPATTERN='(uvx\\.sh/confluence-markdown-exporter/[^/[:space:]]+/install\\.(sh|ps1)|confluence-markdown-exporter==[0-9]|spenhouet/confluence-markdown-exporter:[0-9])'\n\nmapfile -t files < <(\n  # Search README.md, docs/, src/ if they exist. Suppress \"No such file\" noise.\n  for root in README.md docs src; do\n    [[ -e \"$root\" ]] || continue\n    if [[ -f \"$root\" ]]; then\n      echo \"$root\"\n    else\n      find \"$root\" -type f \\( -name '*.md' -o -name '*.mdx' -o -name '*.tsx' -o -name '*.ts' \\)\n    fi\n  done | xargs -r grep -lE \"$PATTERN\" 2>/dev/null\n)\n\nif [[ ${#files[@]} -eq 0 ]]; then\n  echo \"No files contain version-pin patterns; nothing to update.\"\n  exit 0\nfi\n\nfor f in \"${files[@]}\"; do\n  sed -i \\\n    -e \"s|uvx\\.sh/confluence-markdown-exporter/[^/[:space:]]*/install\\.sh|uvx.sh/confluence-markdown-exporter/${NEW}/install.sh|g\" \\\n    -e \"s|uvx\\.sh/confluence-markdown-exporter/[^/[:space:]]*/install\\.ps1|uvx.sh/confluence-markdown-exporter/${NEW}/install.ps1|g\" \\\n    -e \"s|confluence-markdown-exporter==[0-9A-Za-z.\\\\-]*|confluence-markdown-exporter==${NEW}|g\" \\\n    -e \"s|spenhouet/confluence-markdown-exporter:[0-9][0-9A-Za-z.\\\\-]*|spenhouet/confluence-markdown-exporter:${NEW}|g\" \\\n    \"$f\"\n  echo \"updated: $f\"\ndone\n"
  },
  {
    "path": "sidebars.ts",
    "content": "import type { SidebarsConfig } from \"@docusaurus/plugin-content-docs\";\n\nconst sidebars: SidebarsConfig = {\n  docsSidebar: [\n    \"intro\",\n    {\n      type: \"category\",\n      label: \"Quickstart\",\n      collapsed: false,\n      items: [\"installation\", \"usage\"],\n    },\n    \"features\",\n    {\n      type: \"category\",\n      label: \"Configuration\",\n      collapsed: false,\n      link: { type: \"doc\", id: \"configuration/index\" },\n      items: [\n        \"configuration/options\",\n        \"configuration/authentication\",\n        \"configuration/target-systems\",\n        \"configuration/ci\",\n      ],\n    },\n    \"docker\",\n    \"compatibility\",\n    \"troubleshooting\",\n    \"contributing\",\n  ],\n};\n\nexport default sidebars;\n"
  },
  {
    "path": "src/components/HomepageFeatures/index.tsx",
    "content": "import React, { type ReactNode } from \"react\";\nimport clsx from \"clsx\";\nimport Link from \"@docusaurus/Link\";\nimport styles from \"./styles.module.css\";\n\ntype Feature = {\n  icon: string;\n  title: string;\n  description: ReactNode;\n  href: string;\n};\n\nconst FEATURES: Feature[] = [\n  {\n    icon: \"🚀\",\n    title: \"One-command install\",\n    href: \"/installation\",\n    description: (\n      <>\n        A single curl/PowerShell line installs an isolated, self-updating CLI\n        via <code>uv</code>. No virtualenv juggling.\n      </>\n    ),\n  },\n  {\n    icon: \"📚\",\n    title: \"Pages, spaces, orgs\",\n    href: \"/usage\",\n    description: (\n      <>\n        Export a single page, a page subtree, an entire space, or every space\n        in your Atlassian organisation.\n      </>\n    ),\n  },\n  {\n    icon: \"⚡\",\n    title: \"Incremental by default\",\n    href: \"/features\",\n    description: (\n      <>\n        Skips unchanged pages using a lockfile. Re-runs export only what\n        actually moved since last time.\n      </>\n    ),\n  },\n  {\n    icon: \"🎯\",\n    title: \"Target presets\",\n    href: \"/configuration/target-systems\",\n    description: (\n      <>\n        Pre-baked configurations for Obsidian (wiki links, Dataview, Meta Bind)\n        and Azure DevOps wikis (sanitized filenames, attachments folder).\n      </>\n    ),\n  },\n  {\n    icon: \"🧩\",\n    title: \"Macros & add-ons\",\n    href: \"/features\",\n    description: (\n      <>\n        Status badges, panels, page properties, draw.io, PlantUML, Mermaid,\n        include/excerpt: all converted to portable Markdown.\n      </>\n    ),\n  },\n  {\n    icon: \"🔐\",\n    title: \"Cloud & Server\",\n    href: \"/configuration/authentication\",\n    description: (\n      <>\n        Works against Confluence Cloud, the Atlassian API gateway, and\n        on-premise Server / Data Center. API tokens, PATs, scoped tokens: all\n        supported.\n      </>\n    ),\n  },\n];\n\nfunction FeatureCard({ icon, title, description, href }: Feature) {\n  return (\n    <Link to={href} className={clsx(\"col col--4\", styles.featureCol)}>\n      <div className={clsx(\"feature-card\", styles.featureCard)}>\n        <span className=\"feature-icon\" aria-hidden=\"true\">\n          {icon}\n        </span>\n        <h3>{title}</h3>\n        <p>{description}</p>\n      </div>\n    </Link>\n  );\n}\n\nexport default function HomepageFeatures(): ReactNode {\n  return (\n    <section className={styles.features}>\n      <div className=\"container\">\n        <div className=\"row\">\n          {FEATURES.map((f) => (\n            <FeatureCard key={f.title} {...f} />\n          ))}\n        </div>\n      </div>\n    </section>\n  );\n}\n"
  },
  {
    "path": "src/components/HomepageFeatures/styles.module.css",
    "content": ".features {\n  padding: 4rem 0;\n  width: 100%;\n}\n\n.featureCol {\n  margin-bottom: 1.5rem;\n  text-decoration: none !important;\n}\n\n.featureCol:hover {\n  text-decoration: none !important;\n}\n\n.featureCard {\n  display: flex;\n  flex-direction: column;\n  gap: 0.25rem;\n}\n\n@media (max-width: 768px) {\n  .features {\n    padding: 2rem 0;\n  }\n}\n"
  },
  {
    "path": "src/components/quickstart/index.tsx",
    "content": "import React, { type ReactNode } from \"react\";\nimport Tabs from \"@theme/Tabs\";\nimport TabItem from \"@theme/TabItem\";\nimport CodeBlock from \"@theme/CodeBlock\";\nimport Link from \"@docusaurus/Link\";\n\n/**\n * Build a six-tab group keyed by the install-method groupId, so it stays in\n * sync with the install tabs on landing / intro / installation pages.\n *\n * The five non-docker tabs share the same `local` content; the docker tab\n * shows the container equivalent.\n */\nfunction makeStepTabs(local: ReactNode, docker: ReactNode) {\n  return (\n    <Tabs groupId=\"install-method\" queryString>\n      <TabItem value=\"linux\" label=\"Linux\">\n        {local}\n      </TabItem>\n      <TabItem value=\"macos\" label=\"macOS\">\n        {local}\n      </TabItem>\n      <TabItem value=\"windows\" label=\"Windows\">\n        {local}\n      </TabItem>\n      <TabItem value=\"pip\" label=\"pip\">\n        {local}\n      </TabItem>\n      <TabItem value=\"uv\" label=\"uv\">\n        {local}\n      </TabItem>\n      <TabItem value=\"docker\" label=\"Docker\">\n        {docker}\n      </TabItem>\n    </Tabs>\n  );\n}\n\n/** Step 2: Authenticate. Interactive `cme config` locally, JSON config for Docker. */\nexport function AuthenticateTabs() {\n  return makeStepTabs(\n    <CodeBlock language=\"bash\">{`cme config edit auth.confluence`}</CodeBlock>,\n    <>\n      <p>\n        The container has no interactive menu. Generate the JSON config on a\n        workstation first, then mount it (or pass credentials via{\" \"}\n        <code>CME_AUTH__*</code> env vars):\n      </p>\n      <CodeBlock language=\"bash\" title=\"On your workstation\">\n        {`# Writes ~/.config/confluence-markdown-exporter/app_data.json\ncme config edit auth.confluence`}\n      </CodeBlock>\n      <p>\n        Copy that <code>app_data.json</code> to your CI repo or secret store,\n        then mount it on every container run (next step). See the{\" \"}\n        <Link to=\"/docker\">Docker page</Link> for the env-var alternative.\n      </p>\n    </>,\n  );\n}\n\n/** Step 3: Export. `cme pages …` locally, `docker run … pages …` for Docker. */\nexport function ExportTabs() {\n  return makeStepTabs(\n    <CodeBlock language=\"bash\">\n      {`# A page, a subtree, an entire space, or every space of an org:\ncme pages   https://example.atlassian.net/wiki/spaces/SPACE/pages/123/Title\ncme spaces  https://example.atlassian.net/wiki/spaces/SPACE\ncme orgs    https://example.atlassian.net`}\n    </CodeBlock>,\n    <CodeBlock language=\"bash\">\n      {`docker run --rm \\\\\n  -v \"$PWD/app_data.json:/data/config/app_data.json:ro\" \\\\\n  -v \"$PWD/output:/data/output\" \\\\\n  spenhouet/confluence-markdown-exporter \\\\\n  pages https://example.atlassian.net/wiki/spaces/SPACE/pages/123/Title`}\n    </CodeBlock>,\n  );\n}\n\n/** \"Verify the install\" tab variants for the installation page. */\nexport function VerifyTabs() {\n  return makeStepTabs(\n    <CodeBlock language=\"bash\">{`cme --help`}</CodeBlock>,\n    <CodeBlock language=\"bash\">\n      {`docker run --rm spenhouet/confluence-markdown-exporter --help`}\n    </CodeBlock>,\n  );\n}\n"
  },
  {
    "path": "src/css/custom.css",
    "content": "/**\n * Theme overrides for Docusaurus Infima.\n * Primary palette tuned for a modern docs look.\n */\n\n:root {\n  --ifm-color-primary: #5b6cff;\n  --ifm-color-primary-dark: #3c50ff;\n  --ifm-color-primary-darker: #2c41ff;\n  --ifm-color-primary-darkest: #0026e6;\n  --ifm-color-primary-light: #7a88ff;\n  --ifm-color-primary-lighter: #8a96ff;\n  --ifm-color-primary-lightest: #b5bdff;\n\n  --ifm-code-font-size: 90%;\n  --ifm-font-family-base: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\",\n    Roboto, Oxygen, Ubuntu, Cantarell, \"Helvetica Neue\", sans-serif;\n  --ifm-font-family-monospace: \"JetBrains Mono\", ui-monospace, SFMono-Regular,\n    \"SF Mono\", Consolas, \"Liberation Mono\", monospace;\n\n  --ifm-heading-font-weight: 700;\n  --ifm-h1-font-size: 2.5rem;\n  --ifm-h2-font-size: 1.75rem;\n  --ifm-h3-font-size: 1.25rem;\n\n  --ifm-navbar-shadow: 0 1px 0 0 rgb(0 0 0 / 5%);\n  --ifm-navbar-background-color: rgba(255, 255, 255, 0.85);\n  --ifm-navbar-link-hover-color: var(--ifm-color-primary);\n\n  --ifm-toc-border-color: transparent;\n  --ifm-table-stripe-background: rgba(0, 0, 0, 0.02);\n  --ifm-table-border-color: rgba(0, 0, 0, 0.08);\n\n  --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.08);\n}\n\n[data-theme=\"dark\"] {\n  --ifm-color-primary: #8a96ff;\n  --ifm-color-primary-dark: #6a7aff;\n  --ifm-color-primary-darker: #5b6cff;\n  --ifm-color-primary-darkest: #3c50ff;\n  --ifm-color-primary-light: #a4adff;\n  --ifm-color-primary-lighter: #b5bdff;\n  --ifm-color-primary-lightest: #d4d9ff;\n\n  --ifm-background-color: #0d1117;\n  --ifm-background-surface-color: #161b22;\n  --ifm-navbar-background-color: rgba(13, 17, 23, 0.85);\n\n  --ifm-table-stripe-background: rgba(255, 255, 255, 0.03);\n  --ifm-table-border-color: rgba(255, 255, 255, 0.08);\n\n  --docusaurus-highlighted-code-line-bg: rgba(255, 255, 255, 0.08);\n}\n\n@font-face {\n  font-family: \"Inter\";\n  font-style: normal;\n  font-weight: 100 900;\n  font-display: swap;\n  src: url(\"https://rsms.me/inter/font-files/InterVariable.woff2\") format(\"woff2\");\n}\n\nhtml {\n  scroll-padding-top: var(--ifm-navbar-height);\n}\n\n.navbar {\n  backdrop-filter: saturate(180%) blur(20px);\n  -webkit-backdrop-filter: saturate(180%) blur(20px);\n}\n\n.navbar__title {\n  font-weight: 700;\n}\n\n.header-github-link::before {\n  content: \"\";\n  display: inline-block;\n  width: 24px;\n  height: 24px;\n  background-color: var(--ifm-navbar-link-color);\n  mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12'/%3E%3C/svg%3E\");\n  mask-repeat: no-repeat;\n  mask-size: contain;\n  vertical-align: middle;\n}\n\n.header-github-link:hover::before {\n  background-color: var(--ifm-color-primary);\n}\n\n.header-github-link {\n  font-size: 0;\n  padding: 0.5rem !important;\n}\n\n/* Hero */\n.hero {\n  background: linear-gradient(\n    135deg,\n    var(--ifm-color-primary-darkest) 0%,\n    var(--ifm-color-primary) 50%,\n    var(--ifm-color-primary-light) 100%\n  );\n  color: #fff;\n  padding: 4rem 0 5rem;\n  position: relative;\n  overflow: hidden;\n}\n\n.hero::before {\n  content: \"\";\n  position: absolute;\n  inset: 0;\n  background:\n    radial-gradient(circle at 20% 20%, rgba(255, 255, 255, 0.15), transparent 50%),\n    radial-gradient(circle at 80% 80%, rgba(255, 255, 255, 0.1), transparent 50%);\n  pointer-events: none;\n}\n\n.hero > .container {\n  position: relative;\n  z-index: 1;\n}\n\n.hero__title {\n  font-size: 3rem;\n  font-weight: 800;\n  letter-spacing: -0.02em;\n}\n\n.hero__subtitle {\n  font-size: 1.25rem;\n  opacity: 0.92;\n  max-width: 36rem;\n  margin: 1rem auto 0;\n}\n\n.hero-logo {\n  max-width: 480px;\n  width: 80%;\n  margin-bottom: 1.5rem;\n  filter: drop-shadow(0 12px 32px rgba(0, 0, 0, 0.25));\n}\n\n@media (max-width: 768px) {\n  .hero-logo {\n    max-width: 320px;\n    width: 90%;\n  }\n}\n\n.button--hero {\n  background: #fff;\n  color: var(--ifm-color-primary-darkest);\n  border: none;\n  font-weight: 600;\n  transition: transform 0.15s ease, box-shadow 0.15s ease;\n}\n\n.button--hero:hover {\n  background: #fff;\n  color: var(--ifm-color-primary-darker);\n  transform: translateY(-1px);\n  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2);\n}\n\n.button--hero-secondary {\n  background: transparent;\n  color: #fff;\n  border: 1px solid rgba(255, 255, 255, 0.5);\n  font-weight: 600;\n}\n\n.button--hero-secondary:hover {\n  background: rgba(255, 255, 255, 0.1);\n  color: #fff;\n  border-color: #fff;\n}\n\n/* Feature cards */\n.feature-card {\n  height: 100%;\n  padding: 1.75rem;\n  border-radius: 12px;\n  background: var(--ifm-background-surface-color);\n  border: 1px solid var(--ifm-color-emphasis-200);\n  transition: transform 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;\n}\n\n.feature-card:hover {\n  transform: translateY(-2px);\n  border-color: var(--ifm-color-primary);\n  box-shadow: 0 12px 24px -8px rgba(91, 108, 255, 0.25);\n}\n\n.feature-card h3 {\n  margin: 0.75rem 0 0.5rem;\n  font-size: 1.15rem;\n}\n\n.feature-card p {\n  color: var(--ifm-color-emphasis-700);\n  margin: 0;\n  font-size: 0.95rem;\n}\n\n.feature-icon {\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  width: 48px;\n  height: 48px;\n  border-radius: 12px;\n  background: linear-gradient(\n    135deg,\n    var(--ifm-color-primary) 0%,\n    var(--ifm-color-primary-light) 100%\n  );\n  font-size: 1.5rem;\n}\n\n/* Code blocks polish */\n.theme-code-block {\n  border-radius: 10px;\n}\n\n/* Admonition tweaks */\n.alert--info,\n.alert--note,\n.alert--tip,\n.alert--warning,\n.alert--danger {\n  border-left-width: 4px;\n}\n\n/* Table polish */\ntable {\n  border-radius: 8px;\n  overflow: hidden;\n}\n"
  },
  {
    "path": "src/pages/index.module.css",
    "content": ".heroBanner {\n  text-align: center;\n}\n\n.buttons {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  gap: 1rem;\n  margin-top: 2rem;\n  flex-wrap: wrap;\n}\n\n.quickstart {\n  padding: 3rem 0 5rem;\n}\n\n.quickstartTitle {\n  text-align: center;\n  font-size: 2rem;\n  letter-spacing: -0.01em;\n  margin-bottom: 0.5rem;\n}\n\n.quickstartLead {\n  text-align: center;\n  color: var(--ifm-color-emphasis-700);\n  margin-bottom: 2rem;\n  font-size: 1.05rem;\n}\n\n.quickstartFooter {\n  text-align: center;\n  margin-top: 2rem;\n  color: var(--ifm-color-emphasis-600);\n  font-size: 0.9rem;\n}\n\n.stepTitle {\n  font-size: 1.15rem;\n  font-weight: 600;\n  margin: 2rem 0 0.75rem;\n  color: var(--ifm-color-emphasis-900);\n  letter-spacing: -0.005em;\n}\n\n.stepTitle:first-of-type {\n  margin-top: 1rem;\n}\n\n@media (max-width: 768px) {\n  .quickstart {\n    padding: 2rem 0;\n  }\n}\n"
  },
  {
    "path": "src/pages/index.tsx",
    "content": "import React, { type ReactNode } from \"react\";\nimport clsx from \"clsx\";\nimport Link from \"@docusaurus/Link\";\nimport useDocusaurusContext from \"@docusaurus/useDocusaurusContext\";\nimport Layout from \"@theme/Layout\";\nimport CodeBlock from \"@theme/CodeBlock\";\nimport Tabs from \"@theme/Tabs\";\nimport TabItem from \"@theme/TabItem\";\nimport HomepageFeatures from \"@site/src/components/HomepageFeatures\";\nimport {\n  AuthenticateTabs,\n  ExportTabs,\n} from \"@site/src/components/quickstart\";\nimport styles from \"./index.module.css\";\n\nfunction HomepageHeader() {\n  const { siteConfig } = useDocusaurusContext();\n  return (\n    <header className={clsx(\"hero\", styles.heroBanner)}>\n      <div className=\"container\">\n        <img\n          src=\"img/logo.png\"\n          alt={siteConfig.title}\n          className=\"hero-logo\"\n        />\n        <p className=\"hero__subtitle\">{siteConfig.tagline}</p>\n        <div className={styles.buttons}>\n          <Link\n            className=\"button button--hero button--lg\"\n            to=\"/installation\"\n          >\n            Get started →\n          </Link>\n          <Link\n            className=\"button button--hero-secondary button--lg\"\n            to=\"https://github.com/Spenhouet/confluence-markdown-exporter\"\n          >\n            View on GitHub\n          </Link>\n        </div>\n      </div>\n    </header>\n  );\n}\n\nconst INSTALL_SNIPPETS = {\n  linux: `# Installs an isolated, self-updating CLI via uv.\ncurl -LsSf uvx.sh/confluence-markdown-exporter/install.sh | sh`,\n  macos: `# Installs an isolated, self-updating CLI via uv.\ncurl -LsSf uvx.sh/confluence-markdown-exporter/install.sh | sh`,\n  windows: `powershell -ExecutionPolicy ByPass -c \"irm https://uvx.sh/confluence-markdown-exporter/install.ps1 | iex\"`,\n  pip: `pip install confluence-markdown-exporter`,\n  uv: `# Install as an isolated tool…\nuv tool install confluence-markdown-exporter\n\n# …or run it once without installing:\nuvx confluence-markdown-exporter --help`,\n  docker: `# Pull and run the prebuilt image (non-interactive / CI use).\ndocker pull spenhouet/confluence-markdown-exporter:latest\ndocker run --rm spenhouet/confluence-markdown-exporter --help`,\n};\n\nfunction InstallTabs() {\n  return (\n    <Tabs groupId=\"install-method\" queryString>\n      <TabItem value=\"linux\" label=\"Linux\">\n        <CodeBlock language=\"bash\">{INSTALL_SNIPPETS.linux}</CodeBlock>\n      </TabItem>\n      <TabItem value=\"macos\" label=\"macOS\">\n        <CodeBlock language=\"bash\">{INSTALL_SNIPPETS.macos}</CodeBlock>\n      </TabItem>\n      <TabItem value=\"windows\" label=\"Windows\">\n        <CodeBlock language=\"powershell\">{INSTALL_SNIPPETS.windows}</CodeBlock>\n      </TabItem>\n      <TabItem value=\"pip\" label=\"pip\">\n        <CodeBlock language=\"bash\">{INSTALL_SNIPPETS.pip}</CodeBlock>\n      </TabItem>\n      <TabItem value=\"uv\" label=\"uv\">\n        <CodeBlock language=\"bash\">{INSTALL_SNIPPETS.uv}</CodeBlock>\n      </TabItem>\n      <TabItem value=\"docker\" label=\"Docker\">\n        <CodeBlock language=\"bash\">{INSTALL_SNIPPETS.docker}</CodeBlock>\n      </TabItem>\n    </Tabs>\n  );\n}\n\nfunction QuickstartSection() {\n  return (\n    <section className={styles.quickstart}>\n      <div className=\"container\">\n        <div className=\"row\">\n          <div className=\"col col--8 col--offset-2\">\n            <h2 className={styles.quickstartTitle}>Get going in 60 seconds</h2>\n            <p className={styles.quickstartLead}>\n              Install, authenticate, export. That's the whole flow.\n            </p>\n\n            <h3 className={styles.stepTitle}>1. Install</h3>\n            <InstallTabs />\n\n            <h3 className={styles.stepTitle}>2. Authenticate</h3>\n            <AuthenticateTabs />\n\n            <h3 className={styles.stepTitle}>3. Export</h3>\n            <ExportTabs />\n\n            <p className={styles.quickstartFooter}>\n              Detailed setup and per-target presets in the{\" \"}\n              <Link to=\"/installation\">installation docs</Link>.\n            </p>\n          </div>\n        </div>\n      </div>\n    </section>\n  );\n}\n\nexport default function Home(): ReactNode {\n  const { siteConfig } = useDocusaurusContext();\n  return (\n    <Layout\n      title={siteConfig.title}\n      description={siteConfig.tagline}\n    >\n      <HomepageHeader />\n      <main>\n        <HomepageFeatures />\n        <QuickstartSection />\n      </main>\n    </Layout>\n  );\n}\n"
  },
  {
    "path": "tests/__init__.py",
    "content": "# Test package for confluence-markdown-exporter\n"
  },
  {
    "path": "tests/conftest.py",
    "content": "\"\"\"Shared test fixtures and configuration for confluence-markdown-exporter tests.\"\"\"\n\nimport importlib\nimport os\nimport sys\nimport tempfile\nfrom collections.abc import Generator\nfrom pathlib import Path\nfrom typing import Any\nfrom unittest.mock import MagicMock\n\n# Isolate tests from the developer's user config. The package binds APP_CONFIG_PATH\n# at import time from CME_CONFIG_PATH (or, when unset, typer.get_app_dir() which\n# resolves to ~/.config/confluence-markdown-exporter/app_data.json on Linux).\n# Without this, local settings like `page_href=\"wiki\"` leak into tests that rely\n# on the schema defaults.\n_test_config_dir = tempfile.mkdtemp(prefix=\"cme-test-config-\")\nos.environ[\"CME_CONFIG_PATH\"] = str(Path(_test_config_dir) / \"app_data.json\")\n\nimport pytest  # noqa: E402\nfrom pydantic import SecretStr  # noqa: E402\n\nfrom confluence_markdown_exporter.utils.app_data_store import ApiDetails  # noqa: E402\nfrom confluence_markdown_exporter.utils.app_data_store import AuthConfig  # noqa: E402\nfrom confluence_markdown_exporter.utils.app_data_store import ConfigModel  # noqa: E402\nfrom confluence_markdown_exporter.utils.app_data_store import ConnectionConfig  # noqa: E402\nfrom confluence_markdown_exporter.utils.app_data_store import ExportConfig  # noqa: E402\n\n# Store original functions before any patching\n_original_get_confluence = None\n_original_get_jira = None\n\n\ndef pytest_configure(config: pytest.Config) -> None:  # noqa: ARG001\n    \"\"\"Configure pytest and mock API clients before test collection.\"\"\"\n    import confluence_markdown_exporter.api_clients\n\n    global _original_get_confluence, _original_get_jira  # noqa: PLW0603\n\n    # Save the original functions\n    _original_get_confluence = confluence_markdown_exporter.api_clients.get_confluence_instance\n    _original_get_jira = confluence_markdown_exporter.api_clients.get_jira_instance\n\n    # Create mock objects that will be returned by the wrapper\n    mock_confluence = MagicMock()\n    mock_confluence.get_all_spaces.return_value = []\n\n    mock_jira = MagicMock()\n\n    # Replace with wrapper functions that return mocks\n    confluence_markdown_exporter.api_clients.get_confluence_instance = lambda _url: mock_confluence\n    confluence_markdown_exporter.api_clients.get_jira_instance = lambda _url: mock_jira\n\n\ndef pytest_unconfigure(config: pytest.Config) -> None:  # noqa: ARG001\n    \"\"\"Restore original functions after test session.\"\"\"\n    import confluence_markdown_exporter.api_clients\n\n    global _original_get_confluence, _original_get_jira  # noqa: PLW0602\n\n    if _original_get_confluence:\n        confluence_markdown_exporter.api_clients.get_confluence_instance = _original_get_confluence\n    if _original_get_jira:\n        confluence_markdown_exporter.api_clients.get_jira_instance = _original_get_jira\n\n\n@pytest.fixture(autouse=True)\ndef restore_api_functions_for_specific_tests(\n    request: pytest.FixtureRequest,\n) -> Generator[None, None, None]:\n    \"\"\"Restore original API functions for api_clients tests that test those functions.\n\n    This allows those tests to properly mock and test the actual function behavior.\n    \"\"\"\n    import confluence_markdown_exporter.api_clients\n\n    global _original_get_confluence, _original_get_jira  # noqa: PLW0602\n\n    # Check if this is a test that needs the original functions\n    is_api_client_function_test = (\n        \"test_api_clients.py\" in str(request.fspath) and\n        (\"TestGetConfluenceInstance\" in request.node.nodeid or\n         \"TestGetJiraInstance\" in request.node.nodeid)\n    )\n\n    if is_api_client_function_test and _original_get_confluence and _original_get_jira:\n        # Temporarily restore original functions\n        confluence_markdown_exporter.api_clients.get_confluence_instance = _original_get_confluence\n        confluence_markdown_exporter.api_clients.get_jira_instance = _original_get_jira\n\n        # Force reimport in the test module to pick up the restored functions\n        # This is needed because the test module imported the mocked versions at collection time\n        if \"tests.unit.test_api_clients\" in sys.modules:\n            importlib.reload(sys.modules[\"tests.unit.test_api_clients\"])\n\n    yield\n\n    # Re-apply mocks after the test\n    if is_api_client_function_test:\n        mock_confluence = MagicMock()\n        mock_confluence.get_all_spaces.return_value = []\n        mock_jira = MagicMock()\n\n        confluence_markdown_exporter.api_clients.get_confluence_instance = (\n            lambda _url: mock_confluence\n        )\n        confluence_markdown_exporter.api_clients.get_jira_instance = lambda _url: mock_jira\n\n\n@pytest.fixture\ndef temp_config_dir() -> Generator[Path, None, None]:\n    \"\"\"Create a temporary directory for test configuration.\"\"\"\n    with tempfile.TemporaryDirectory() as temp_dir:\n        yield Path(temp_dir)\n\n\n@pytest.fixture\ndef mock_confluence_client() -> MagicMock:\n    \"\"\"Create a mock Confluence client for testing.\"\"\"\n    mock_client = MagicMock()\n    mock_client.get_all_spaces.return_value = [\n        {\"key\": \"TEST\", \"name\": \"Test Space\", \"id\": \"123456\"}\n    ]\n    mock_client.get_page_by_id.return_value = {\n        \"id\": \"123456\",\n        \"title\": \"Test Page\",\n        \"body\": {\"storage\": {\"value\": \"<p>Test content</p>\"}},\n        \"space\": {\"key\": \"TEST\"},\n        \"version\": {\"number\": 1},\n    }\n    return mock_client\n\n\n@pytest.fixture\ndef mock_jira_client() -> MagicMock:\n    \"\"\"Create a mock Jira client for testing.\"\"\"\n    mock_client = MagicMock()\n    mock_client.get_all_projects.return_value = [\n        {\"key\": \"TEST\", \"name\": \"Test Project\", \"id\": \"10000\"}\n    ]\n    mock_client.get_issue.return_value = {\n        \"key\": \"TEST-123\",\n        \"fields\": {\n            \"summary\": \"Test Issue\",\n            \"description\": \"Test description\",\n            \"status\": {\"name\": \"Open\"},\n        },\n    }\n    return mock_client\n\n\nSAMPLE_CONFLUENCE_URL = \"https://test.atlassian.net\"\n\n\n@pytest.fixture\ndef sample_api_details() -> ApiDetails:\n    \"\"\"Create sample API details for testing.\"\"\"\n    return ApiDetails(\n        username=SecretStr(\"test@example.com\"),\n        api_token=SecretStr(\"test-token\"),\n        pat=SecretStr(\"test-pat\"),\n    )\n\n\n@pytest.fixture\ndef sample_connection_config() -> ConnectionConfig:\n    \"\"\"Create sample connection configuration for testing.\"\"\"\n    return ConnectionConfig(\n        backoff_and_retry=True,\n        backoff_factor=2,\n        max_backoff_seconds=60,\n        max_backoff_retries=5,\n        retry_status_codes=[413, 429, 502, 503, 504],\n        verify_ssl=True,\n    )\n\n\n@pytest.fixture\ndef sample_config_model(\n    sample_api_details: ApiDetails,\n    sample_connection_config: ConnectionConfig,\n    temp_config_dir: Path,\n) -> ConfigModel:\n    \"\"\"Create sample configuration for testing.\"\"\"\n    auth_config = AuthConfig(\n        confluence={SAMPLE_CONFLUENCE_URL: sample_api_details},\n        jira={SAMPLE_CONFLUENCE_URL: sample_api_details},\n    )\n\n    export_config = ExportConfig(\n        output_path=temp_config_dir / \"output\",\n    )\n\n    return ConfigModel(\n        auth=auth_config,\n        export=export_config,\n        connection_config=sample_connection_config,\n    )\n\n\n@pytest.fixture\ndef confluence_page_response() -> dict[str, Any]:\n    \"\"\"Sample Confluence page response for testing.\"\"\"\n    return {\n        \"id\": \"123456\",\n        \"type\": \"page\",\n        \"status\": \"current\",\n        \"title\": \"Test Page\",\n        \"space\": {\"key\": \"TEST\", \"name\": \"Test Space\", \"id\": \"123\"},\n        \"version\": {\n            \"number\": 1,\n            \"when\": \"2023-01-01T00:00:00.000Z\",\n            \"by\": {\"displayName\": \"Test User\", \"username\": \"testuser\"},\n        },\n        \"ancestors\": [],\n        \"children\": {\"page\": {\"results\": [], \"size\": 0}},\n        \"descendants\": {\"page\": {\"results\": [], \"size\": 0}},\n        \"body\": {\n            \"storage\": {\n                \"value\": (\n                    \"<h1>Test Heading</h1><p>Test content with <strong>bold</strong> text.</p>\"\n                ),\n                \"representation\": \"storage\",\n            }\n        },\n        \"_links\": {\n            \"webui\": \"/spaces/TEST/pages/123456/Test+Page\",\n            \"base\": \"https://test.atlassian.net/wiki\",\n        },\n    }\n\n\n@pytest.fixture\ndef confluence_space_response() -> dict[str, Any]:\n    \"\"\"Sample Confluence space response for testing.\"\"\"\n    return {\n        \"id\": \"123\",\n        \"key\": \"TEST\",\n        \"name\": \"Test Space\",\n        \"description\": {\"plain\": {\"value\": \"A test space\"}},\n        \"homepage\": {\"id\": \"123456\"},\n        \"_links\": {\n            \"webui\": \"/spaces/TEST\",\n            \"base\": \"https://test.atlassian.net/wiki\",\n        },\n    }\n\n\n@pytest.fixture\ndef jira_issue_response() -> dict[str, Any]:\n    \"\"\"Sample Jira issue response for testing.\"\"\"\n    return {\n        \"id\": \"10000\",\n        \"key\": \"TEST-123\",\n        \"fields\": {\n            \"summary\": \"Test Issue Summary\",\n            \"description\": \"This is a test issue description\",\n            \"status\": {\"name\": \"Open\", \"id\": \"1\"},\n            \"priority\": {\"name\": \"Medium\", \"id\": \"3\"},\n            \"issuetype\": {\"name\": \"Bug\", \"id\": \"1\"},\n            \"created\": \"2023-01-01T00:00:00.000+0000\",\n            \"updated\": \"2023-01-01T12:00:00.000+0000\",\n        },\n    }\n"
  },
  {
    "path": "tests/integration/__init__.py",
    "content": "\"\"\"Integration tests for confluence-markdown-exporter.\"\"\"\n"
  },
  {
    "path": "tests/integration/test_cli_integration.py",
    "content": "\"\"\"Basic tests for confluence-markdown-exporter package.\"\"\"\n\nimport subprocess\nimport sys\n\nimport pytest\n\nimport confluence_markdown_exporter.main as main_module\nfrom confluence_markdown_exporter import __version__\n\n\ndef test_package_has_version() -> None:\n    \"\"\"Test that package has a version attribute.\"\"\"\n    assert __version__ is not None\n    assert isinstance(__version__, str)\n    assert len(__version__) > 0\n\n\ndef test_version_command() -> None:\n    \"\"\"Test that the version command works correctly.\"\"\"\n    try:\n        # Test the version command\n        result = subprocess.run(\n            [sys.executable, \"-m\", \"confluence_markdown_exporter.main\", \"version\"],\n            capture_output=True,\n            text=True,\n            check=True,\n            timeout=10,\n        )\n\n        # Check that version output contains expected format\n        assert \"confluence-markdown-exporter\" in result.stdout\n        assert result.returncode == 0\n\n        # The version should be present in output\n        # Note: We don't check exact match since dev versions may have extra info\n        assert len(result.stdout.strip()) > len(\"confluence-markdown-exporter\")\n\n    except subprocess.TimeoutExpired:\n        pytest.fail(\"Version command timed out\")\n    except subprocess.CalledProcessError as e:\n        pytest.fail(f\"Version command failed: {e}\")\n    except Exception as e:  # noqa: BLE001\n        pytest.fail(f\"Unexpected error testing version command: {e}\")\n\n\ndef test_config_list_command() -> None:\n    \"\"\"Test that the config list command works correctly.\"\"\"\n    import yaml\n\n    try:\n        result = subprocess.run(\n            [\n                sys.executable,\n                \"-m\",\n                \"confluence_markdown_exporter.main\",\n                \"config\",\n                \"list\",\n            ],\n            capture_output=True,\n            text=True,\n            check=True,\n            timeout=10,\n        )\n\n        assert result.returncode == 0\n        assert \"auth:\" in result.stdout\n        assert \"export:\" in result.stdout\n        assert \"connection_config:\" in result.stdout\n\n        # Verify it's valid YAML\n        config_data = yaml.safe_load(result.stdout)\n        assert isinstance(config_data, dict)\n        assert \"auth\" in config_data\n        assert \"export\" in config_data\n        assert \"connection_config\" in config_data\n\n    except subprocess.TimeoutExpired:\n        pytest.fail(\"Config list command timed out\")\n    except subprocess.CalledProcessError as e:\n        pytest.fail(f\"Config list command failed: {e}\")\n    except Exception as e:  # noqa: BLE001\n        pytest.fail(f\"Unexpected error testing config list command: {e}\")\n\n\ndef test_cli_entry_points() -> None:\n    \"\"\"Test that CLI entry points are properly configured.\"\"\"\n    # Test that we can import the main module without triggering execution\n    try:\n        # Check that the main module exists and has expected attributes\n        assert main_module is not None\n        # Check if the app is defined (typer app)\n        assert hasattr(main_module, \"app\")\n    except ImportError as e:\n        pytest.fail(f\"Could not import main module: {e}\")\n    except Exception:  # noqa: BLE001\n        # Allow other exceptions as the module might have initialization code\n        # but we can still verify it's importable\n        pass\n"
  },
  {
    "path": "tests/unit/__init__.py",
    "content": "\"\"\"Unit tests for confluence-markdown-exporter.\"\"\"\n"
  },
  {
    "path": "tests/unit/test_alert_conversion.py",
    "content": "\"\"\"Test Confluence alert/panel macro conversion.\"\"\"\n\nfrom __future__ import annotations\n\nfrom typing import TYPE_CHECKING\n\nimport pytest\n\nif TYPE_CHECKING:\n    from confluence_markdown_exporter.confluence import Page\n\n\ndef _make_converter(editor2: str = \"\") -> Page.Converter:\n    from confluence_markdown_exporter.confluence import Page\n\n    class MockPage:\n        def __init__(self) -> None:\n            self.id = \"test-page\"\n            self.title = \"Test Page\"\n            self.html = \"\"\n            self.labels = []\n            self.ancestors = []\n            self.editor2 = editor2\n\n        def get_attachment_by_file_id(self, file_id: str) -> None:\n            return None\n\n    return Page.Converter(MockPage())\n\n\n@pytest.fixture\ndef converter() -> Page.Converter:\n    return _make_converter()\n\n\nclass TestAlertOutsideTable:\n    def test_panel_renders_as_note_alert(self, converter: Page.Converter) -> None:\n        html = '<div data-macro-name=\"panel\"><p>body text</p></div>'\n        out = converter.convert(html)\n        assert \"> [!NOTE]\" in out\n        assert \"body text\" in out\n\n    def test_warning_renders_as_caution_alert(self, converter: Page.Converter) -> None:\n        html = '<div data-macro-name=\"warning\"><p>danger</p></div>'\n        out = converter.convert(html)\n        assert \"> [!CAUTION]\" in out\n\n\nclass TestAlertInsideTableCell:\n    def test_panel_in_td_emits_emoji_no_blockquote(self, converter: Page.Converter) -> None:\n        html = (\n            \"<table><tr><td>\"\n            '<div data-macro-name=\"panel\"><p>Klinische Abteilung</p></div>'\n            \"</td></tr></table>\"\n        )\n        out = converter.convert(html)\n        assert \"[!NOTE]\" not in out\n        assert \">\" not in out.replace(\"</td>\", \"\")\n        assert \"\\U0001f4dd Klinische Abteilung\" in out\n\n    def test_info_in_td_emits_important_emoji(self, converter: Page.Converter) -> None:\n        html = (\n            \"<table><tr><td>\"\n            '<div data-macro-name=\"info\"><p>info text</p></div>'\n            \"</td></tr></table>\"\n        )\n        out = converter.convert(html)\n        assert \"[!IMPORTANT]\" not in out\n        assert \"❗ info text\" in out\n\n    def test_warning_in_td_emits_caution_emoji(self, converter: Page.Converter) -> None:\n        html = (\n            \"<table><tr><td>\"\n            '<div data-macro-name=\"warning\"><p>danger</p></div>'\n            \"</td></tr></table>\"\n        )\n        out = converter.convert(html)\n        assert \"[!CAUTION]\" not in out\n        assert \"\\U0001f6d1 danger\" in out\n\n    def test_tip_in_td_emits_tip_emoji(self, converter: Page.Converter) -> None:\n        html = (\n            \"<table><tr><td>\"\n            '<div data-macro-name=\"tip\"><p>helpful</p></div>'\n            \"</td></tr></table>\"\n        )\n        out = converter.convert(html)\n        assert \"[!TIP]\" not in out\n        assert \"\\U0001f4a1 helpful\" in out\n\n    def test_note_in_td_emits_warning_emoji(self, converter: Page.Converter) -> None:\n        html = (\n            \"<table><tr><td>\"\n            '<div data-macro-name=\"note\"><p>watch out</p></div>'\n            \"</td></tr></table>\"\n        )\n        out = converter.convert(html)\n        assert \"[!WARNING]\" not in out\n        assert \"⚠️ watch out\" in out\n\n    def test_panel_in_th_emits_emoji_no_blockquote(self, converter: Page.Converter) -> None:\n        html = (\n            \"<table><tr><th>\"\n            '<div data-macro-name=\"panel\"><p>header note</p></div>'\n            \"</th></tr></table>\"\n        )\n        out = converter.convert(html)\n        assert \"[!NOTE]\" not in out\n        assert \"\\U0001f4dd header note\" in out\n\n\nclass TestCustomPanelEmoji:\n    def test_custom_panel_icon_text_used_in_table_cell(self) -> None:\n        editor2 = (\n            '<ac:structured-macro ac:name=\"panel\" ac:macro-id=\"abc-1\">'\n            '<ac:parameter ac:name=\"panelIconId\">1f6e0</ac:parameter>'\n            '<ac:parameter ac:name=\"panelIcon\">:tools:</ac:parameter>'\n            '<ac:parameter ac:name=\"panelIconText\">\\U0001f6e0️</ac:parameter>'\n            \"<ac:rich-text-body><p>Klinische Abteilung</p></ac:rich-text-body>\"\n            \"</ac:structured-macro>\"\n        )\n        converter = _make_converter(editor2)\n        html = (\n            \"<table><tr><td>\"\n            '<div data-macro-name=\"panel\" data-macro-id=\"abc-1\"><p>Klinische Abteilung</p></div>'\n            \"</td></tr></table>\"\n        )\n        out = converter.convert(html)\n        assert \"\\U0001f6e0️ Klinische Abteilung\" in out\n        assert \"\\U0001f4dd\" not in out\n\n    def test_custom_panel_icon_id_decoded_when_no_text(self) -> None:\n        editor2 = (\n            '<ac:structured-macro ac:name=\"panel\" ac:macro-id=\"abc-2\">'\n            '<ac:parameter ac:name=\"panelIconId\">1f6e0</ac:parameter>'\n            \"<ac:rich-text-body><p>x</p></ac:rich-text-body>\"\n            \"</ac:structured-macro>\"\n        )\n        converter = _make_converter(editor2)\n        html = (\n            \"<table><tr><td>\"\n            '<div data-macro-name=\"panel\" data-macro-id=\"abc-2\"><p>x</p></div>'\n            \"</td></tr></table>\"\n        )\n        out = converter.convert(html)\n        assert \"\\U0001f6e0 x\" in out\n\n    def test_panel_without_custom_icon_falls_back_to_default(self) -> None:\n        editor2 = (\n            '<ac:structured-macro ac:name=\"panel\" ac:macro-id=\"plain-1\">'\n            \"<ac:rich-text-body><p>plain</p></ac:rich-text-body>\"\n            \"</ac:structured-macro>\"\n        )\n        converter = _make_converter(editor2)\n        html = (\n            \"<table><tr><td>\"\n            '<div data-macro-name=\"panel\" data-macro-id=\"plain-1\"><p>plain</p></div>'\n            \"</td></tr></table>\"\n        )\n        out = converter.convert(html)\n        assert \"\\U0001f4dd plain\" in out\n\n    def test_unknown_macro_id_falls_back_to_default(self) -> None:\n        converter = _make_converter(\"\")\n        html = (\n            \"<table><tr><td>\"\n            '<div data-macro-name=\"panel\" data-macro-id=\"missing\"><p>y</p></div>'\n            \"</td></tr></table>\"\n        )\n        out = converter.convert(html)\n        assert \"\\U0001f4dd y\" in out\n"
  },
  {
    "path": "tests/unit/test_api_clients.py",
    "content": "\"\"\"Unit tests for api_clients module.\"\"\"\n\nimport urllib.parse\nfrom unittest.mock import MagicMock\nfrom unittest.mock import patch\n\nimport pytest\nimport requests\nfrom atlassian.errors import ApiError\nfrom pydantic import SecretStr\n\nfrom confluence_markdown_exporter.api_clients import ApiClientFactory\nfrom confluence_markdown_exporter.api_clients import AuthNotConfiguredError\nfrom confluence_markdown_exporter.api_clients import ConfluenceRef\nfrom confluence_markdown_exporter.api_clients import get_confluence_instance\nfrom confluence_markdown_exporter.api_clients import parse_confluence_path\nfrom confluence_markdown_exporter.api_clients import response_hook\nfrom confluence_markdown_exporter.utils.app_data_store import ApiDetails\nfrom confluence_markdown_exporter.utils.app_data_store import AtlassianSdkConnectionConfig\nfrom confluence_markdown_exporter.utils.app_data_store import AuthConfig\nfrom confluence_markdown_exporter.utils.app_data_store import ConfigModel\nfrom tests.conftest import SAMPLE_CONFLUENCE_URL\n\n_PARSE_CONFLUENCE_PATH_CASES = [\n    (\n        \"https://company.atlassian.net/wiki/spaces/SPACEKEY\",\n        ConfluenceRef(space_key=\"SPACEKEY\"),\n    ),\n    (\n        \"https://company.atlassian.net/wiki/spaces/SPACEKEY/pages/123456789/Page+Title\",\n        ConfluenceRef(space_key=\"SPACEKEY\", page_id=123456789, page_title=\"Page Title\"),\n    ),\n    (\n        \"https://company.atlassian.net/wiki/spaces/SPACEKEY/pages/sddssd/Page+Title\",\n        None,\n    ),\n    (\n        \"https://company.atlassian.net/wiki/spaces/SPACEKEY/overview\",\n        ConfluenceRef(space_key=\"SPACEKEY\"),\n    ),\n    (\n        \"https://api.atlassian.com/ex/confluence/CLOUDID/wiki/spaces/SPACEKEY/pages/123456789/Page+Title\",\n        ConfluenceRef(space_key=\"SPACEKEY\", page_id=123456789, page_title=\"Page Title\"),\n    ),\n    (\n        \"https://api.atlassian.com/ex/confluence/1232132-12312312-21321332/wiki/spaces/SPACEKEY\",\n        ConfluenceRef(space_key=\"SPACEKEY\"),\n    ),\n    (\n        \"https://api.atlassian.com/ex/confluence/1232132-12312312-21321332/wiki/spaces/SPACEKEY/pages/123456789\",\n        ConfluenceRef(space_key=\"SPACEKEY\", page_id=123456789),\n    ),\n    (\n        \"/wiki/spaces/SPACEKEY/\",\n        ConfluenceRef(space_key=\"SPACEKEY\"),\n    ),\n    (\n        \"/wiki/spaces/SPACEKEY/overview\",\n        ConfluenceRef(space_key=\"SPACEKEY\"),\n    ),\n    (\n        \"/wiki/spaces/SPACEKEY/pages/123456789/Page+Title\",\n        ConfluenceRef(space_key=\"SPACEKEY\", page_id=123456789, page_title=\"Page Title\"),\n    ),\n    (\n        \"/ex/confluence/CLOUDID/wiki/spaces/SPACEKEY/pages/123456789/Page+Title\",\n        ConfluenceRef(space_key=\"SPACEKEY\", page_id=123456789, page_title=\"Page Title\"),\n    ),\n    (\n        \"/ex/confluence/1232132-12312312-21321332/wiki/spaces/SPACEKEY\",\n        ConfluenceRef(space_key=\"SPACEKEY\"),\n    ),\n    (\n        \"/ex/confluence/1232132-12312312-21321332/wiki/spaces/SPACEKEY/pages/123456789\",\n        ConfluenceRef(space_key=\"SPACEKEY\", page_id=123456789),\n    ),\n    (\n        \"https://confluence.company.com/display/SPACEKEY\",\n        ConfluenceRef(space_key=\"SPACEKEY\"),\n    ),\n    (\n        \"https://confluence.company.com/display/SPACEKEY/Page+Title\",\n        ConfluenceRef(space_key=\"SPACEKEY\", page_title=\"Page Title\"),\n    ),\n    (\n        \"https://confluence.company.com/SPACEKEY\",\n        ConfluenceRef(space_key=\"SPACEKEY\"),\n    ),\n    (\n        \"https://confluence.company.com/SPACEKEY/Page+Title\",\n        ConfluenceRef(space_key=\"SPACEKEY\", page_title=\"Page Title\"),\n    ),\n    (\n        \"https://company.atlassian.net/display/SPACEKEY/Page+Title\",\n        ConfluenceRef(space_key=\"SPACEKEY\", page_title=\"Page Title\"),\n    ),\n    (\n        \"https://company.atlassian.net/SPACEKEY/Page+Title\",\n        ConfluenceRef(space_key=\"SPACEKEY\", page_title=\"Page Title\"),\n    ),\n    (\n        \"/display/SPACEKEY\",\n        ConfluenceRef(space_key=\"SPACEKEY\"),\n    ),\n    (\n        \"/display/SPACEKEY/Page+Title\",\n        ConfluenceRef(space_key=\"SPACEKEY\", page_title=\"Page Title\"),\n    ),\n    (\n        \"/SPACEKEY\",\n        ConfluenceRef(space_key=\"SPACEKEY\"),\n    ),\n    (\n        \"/SPACEKEY/Page+Title\",\n        ConfluenceRef(space_key=\"SPACEKEY\", page_title=\"Page Title\"),\n    ),\n    (\n        \"https://wiki.aaa.aaa/spaces/SPACEKEY/pages/123456789/Page+Title\",\n        ConfluenceRef(space_key=\"SPACEKEY\", page_id=123456789, page_title=\"Page Title\"),\n    ),\n    (\n        \"/spaces/SPACEKEY/pages/123456789/Page+Title\",\n        ConfluenceRef(space_key=\"SPACEKEY\", page_id=123456789, page_title=\"Page Title\"),\n    ),\n]\n\n\nclass TestParseConfluencePath:\n    \"\"\"Test cases for parse_confluence_path function.\"\"\"\n\n    @pytest.mark.parametrize((\"url\", \"expected\"), _PARSE_CONFLUENCE_PATH_CASES)\n    def test_parse_confluence_path(self, url: str, expected: ConfluenceRef | None) -> None:\n        path = urllib.parse.urlparse(url).path if \"://\" in url else url\n        result = parse_confluence_path(path)\n        if expected is None:\n            assert result is None\n        else:\n            assert result is not None\n            assert result.model_dump() == expected.model_dump()\n\n\nclass TestResponseHook:\n    \"\"\"Test cases for response_hook function.\"\"\"\n\n    def test_successful_response(self, caplog: pytest.LogCaptureFixture) -> None:\n        \"\"\"Test that successful responses don't log warnings.\"\"\"\n        response = MagicMock(spec=requests.Response)\n        response.ok = True\n        response.status_code = 200\n\n        result = response_hook(response)\n\n        assert result == response\n        assert len(caplog.records) == 0\n\n    def test_failed_response(self, caplog: pytest.LogCaptureFixture) -> None:\n        \"\"\"Test that failed responses log warnings.\"\"\"\n        response = MagicMock(spec=requests.Response)\n        response.ok = False\n        response.status_code = 404\n        response.url = \"https://test.atlassian.net/api/test\"\n        response.headers = {\"Content-Type\": \"application/json\"}\n\n        result = response_hook(response)\n\n        assert result == response\n        assert len(caplog.records) == 1\n        log_record = caplog.records[0]\n        expected_msg = \"Request to https://test.atlassian.net/api/test failed with status 404\"\n        assert expected_msg in log_record.message\n        assert \"Response headers: {'Content-Type': 'application/json'}\" in log_record.message\n\n\nclass TestApiClientFactory:\n    \"\"\"Test cases for ApiClientFactory class.\"\"\"\n\n    def test_init(self) -> None:\n        \"\"\"Test ApiClientFactory initialization stores an AtlassianSdkConnectionConfig.\"\"\"\n        config = AtlassianSdkConnectionConfig()\n        factory = ApiClientFactory(config)\n        assert factory.connection_config == config\n        assert isinstance(factory.connection_config, AtlassianSdkConnectionConfig)\n\n    @patch(\"confluence_markdown_exporter.api_clients.ConfluenceApiSdk\")\n    def test_create_confluence_success(\n        self, mock_confluence_sdk: MagicMock, sample_api_details: ApiDetails\n    ) -> None:\n        \"\"\"Test successful Confluence client creation.\"\"\"\n        mock_instance = MagicMock()\n        mock_instance.get_all_spaces.return_value = [{\"key\": \"TEST\"}]\n        mock_confluence_sdk.return_value = mock_instance\n\n        sdk_config = AtlassianSdkConnectionConfig()\n        factory = ApiClientFactory(sdk_config)\n\n        result = factory.create_confluence(SAMPLE_CONFLUENCE_URL, sample_api_details)\n\n        assert result == mock_instance\n        mock_confluence_sdk.assert_called_once_with(\n            url=SAMPLE_CONFLUENCE_URL,\n            username=sample_api_details.username.get_secret_value(),\n            password=sample_api_details.api_token.get_secret_value(),\n            token=sample_api_details.pat.get_secret_value(),\n            **sdk_config.model_dump(),\n        )\n        mock_instance.get_all_spaces.assert_called_once_with(limit=1)\n\n    @patch(\"confluence_markdown_exporter.api_clients.ConfluenceApiSdk\")\n    def test_create_confluence_connection_failure(\n        self, mock_confluence_sdk: MagicMock, sample_api_details: ApiDetails\n    ) -> None:\n        \"\"\"Test Confluence client creation with connection failure.\"\"\"\n        mock_instance = MagicMock()\n        mock_instance.get_all_spaces.side_effect = ApiError(\"Connection failed\")\n        mock_confluence_sdk.return_value = mock_instance\n\n        factory = ApiClientFactory(AtlassianSdkConnectionConfig())\n\n        with pytest.raises(ConnectionError, match=\"Confluence connection failed\"):\n            factory.create_confluence(SAMPLE_CONFLUENCE_URL, sample_api_details)\n\n    @patch(\"confluence_markdown_exporter.api_clients.JiraApiSdk\")\n    def test_create_jira_success(\n        self, mock_jira_sdk: MagicMock, sample_api_details: ApiDetails\n    ) -> None:\n        \"\"\"Test successful Jira client creation.\"\"\"\n        mock_instance = MagicMock()\n        mock_instance.get_all_projects.return_value = [{\"key\": \"TEST\"}]\n        mock_jira_sdk.return_value = mock_instance\n\n        sdk_config = AtlassianSdkConnectionConfig()\n        factory = ApiClientFactory(sdk_config)\n\n        result = factory.create_jira(SAMPLE_CONFLUENCE_URL, sample_api_details)\n\n        assert result == mock_instance\n        mock_jira_sdk.assert_called_once_with(\n            url=SAMPLE_CONFLUENCE_URL,\n            username=sample_api_details.username.get_secret_value(),\n            password=sample_api_details.api_token.get_secret_value(),\n            token=sample_api_details.pat.get_secret_value(),\n            **sdk_config.model_dump(),\n        )\n        mock_instance.get_all_projects.assert_called_once()\n\n    @patch(\"confluence_markdown_exporter.api_clients.JiraApiSdk\")\n    def test_create_jira_connection_failure(\n        self, mock_jira_sdk: MagicMock, sample_api_details: ApiDetails\n    ) -> None:\n        \"\"\"Test Jira client creation with connection failure.\"\"\"\n        mock_instance = MagicMock()\n        mock_instance.get_all_projects.side_effect = ApiError(\"Connection failed\")\n        mock_jira_sdk.return_value = mock_instance\n\n        factory = ApiClientFactory(AtlassianSdkConnectionConfig())\n\n        with pytest.raises(ConnectionError, match=\"Jira connection failed\"):\n            factory.create_jira(SAMPLE_CONFLUENCE_URL, sample_api_details)\n\n\nclass TestGetConfluenceInstance:\n    \"\"\"Test cases for get_confluence_instance function.\"\"\"\n\n    @patch(\"confluence_markdown_exporter.api_clients._confluence_clients\", {})\n    @patch(\"confluence_markdown_exporter.api_clients.get_settings\")\n    @patch(\"confluence_markdown_exporter.api_clients.ApiClientFactory\")\n    def test_successful_connection(\n        self,\n        mock_factory_class: MagicMock,\n        mock_get_settings: MagicMock,\n        sample_config_model: ConfigModel,\n    ) -> None:\n        \"\"\"Test successful Confluence instance creation.\"\"\"\n        mock_get_settings.return_value = sample_config_model\n        mock_factory = MagicMock()\n        mock_confluence = MagicMock()\n        mock_factory.create_confluence.return_value = mock_confluence\n        mock_factory_class.return_value = mock_factory\n\n        result = get_confluence_instance(SAMPLE_CONFLUENCE_URL)\n\n        assert result == mock_confluence\n        mock_factory_class.assert_called_once_with(sample_config_model.connection_config)\n        mock_factory.create_confluence.assert_called_once_with(\n            SAMPLE_CONFLUENCE_URL,\n            sample_config_model.auth.get_instance(SAMPLE_CONFLUENCE_URL),\n        )\n\n    @patch(\"confluence_markdown_exporter.api_clients._confluence_clients\", {})\n    @patch(\"confluence_markdown_exporter.api_clients.get_settings\")\n    @patch(\"confluence_markdown_exporter.api_clients.ApiClientFactory\")\n    def test_connection_failure_raises(\n        self,\n        mock_factory_class: MagicMock,\n        mock_get_settings: MagicMock,\n        sample_config_model: ConfigModel,\n    ) -> None:\n        \"\"\"Test that a Confluence connection failure raises AuthNotConfiguredError.\"\"\"\n        mock_get_settings.return_value = sample_config_model\n\n        mock_factory = MagicMock()\n        mock_factory.create_confluence.side_effect = ConnectionError(\"Connection failed\")\n        mock_factory_class.return_value = mock_factory\n\n        with pytest.raises(AuthNotConfiguredError) as exc_info:\n            get_confluence_instance(SAMPLE_CONFLUENCE_URL)\n\n        assert exc_info.value.url == SAMPLE_CONFLUENCE_URL\n        assert exc_info.value.service == \"Confluence\"\n        assert mock_factory.create_confluence.call_count == 1\n\n\nclass TestAuthConfigContextPath:\n    \"\"\"Test auth lookup for instances deployed under a context path (e.g. /confluence).\"\"\"\n\n    def _make_config(self, key: str) -> AuthConfig:\n        details = ApiDetails(username=SecretStr(\"user\"), api_token=SecretStr(\"token\"))\n        return AuthConfig(confluence={key: details})\n\n    @pytest.mark.parametrize(\n        (\"stored_key\", \"lookup_url\"),\n        [\n            # Auth stored without context path, URL includes context path\n            (\"https://host.example.com\", \"https://host.example.com/confluence\"),\n            (\"https://host.example.com\", \"https://host.example.com/confluence/spaces/KEY\"),\n            (\"https://host.example.com\", \"https://host.example.com/confluence/display/KEY/Title\"),\n            # Auth stored with context path, URL includes context path\n            (\"https://host.example.com/confluence\", \"https://host.example.com/confluence\"),\n            (\n                \"https://host.example.com/confluence\",\n                \"https://host.example.com/confluence/spaces/KEY/pages/123\",\n            ),\n            # Non-standard port\n            (\"https://host.example.com:8443\", \"https://host.example.com:8443/confluence\"),\n        ],\n    )\n    def test_get_instance_matches_context_path_url(\n        self, stored_key: str, lookup_url: str\n    ) -> None:\n        config = self._make_config(stored_key)\n        assert config.get_instance(lookup_url) is not None\n\n    @pytest.mark.parametrize(\n        (\"stored_key\", \"lookup_url\"),\n        [\n            # Different host — must not match\n            (\"https://other.example.com\", \"https://host.example.com/confluence\"),\n            # Different port — must not match\n            (\"https://host.example.com:8080\", \"https://host.example.com:9090/confluence\"),\n            # Gateway URL — must not match by host fallback\n            (\n                \"https://api.atlassian.com/ex/confluence/CLOUD1\",\n                \"https://api.atlassian.com/ex/confluence/CLOUD2/wiki/spaces/KEY\",\n            ),\n        ],\n    )\n    def test_get_instance_no_false_match(self, stored_key: str, lookup_url: str) -> None:\n        config = self._make_config(stored_key)\n        assert config.get_instance(lookup_url) is None\n"
  },
  {
    "path": "tests/unit/test_confluence.py",
    "content": "\"\"\"Unit tests for confluence module URL resolution.\"\"\"\n\nfrom __future__ import annotations\n\nimport types\nfrom pathlib import Path\nfrom unittest.mock import MagicMock\nfrom unittest.mock import patch\n\nimport pytest\n\nfrom confluence_markdown_exporter.confluence import Attachment\nfrom confluence_markdown_exporter.confluence import Page\nfrom confluence_markdown_exporter.confluence import Space\nfrom confluence_markdown_exporter.confluence import User\nfrom confluence_markdown_exporter.confluence import Version\n\n\nclass MockPage:\n    \"\"\"Minimal page object for Converter tests.\"\"\"\n\n    def __init__(self) -> None:\n        self.id = \"test-page\"\n        self.title = \"Test Page\"\n        self.type = \"\"\n        self.html = \"\"\n        self.body_storage = \"\"\n        self.web_url = \"\"\n        self.tiny_url = \"\"\n        self.labels = []\n        self.ancestors = []\n        self.space = MagicMock()\n        self.space.key = \"TEST\"\n        self.version = MagicMock()\n        self.version.number = 1\n        self.version.when = \"\"\n        self.version.by = MagicMock()\n        self.version.by.display_name = \"\"\n        self.history = MagicMock()\n        self.history.created = \"\"\n        self.history.created_by = MagicMock()\n        self.history.created_by.display_name = \"\"\n\n    def get_attachment_by_file_id(self, file_id: str) -> None:\n        return None\n\n\n@pytest.fixture\ndef converter() -> Page.Converter:\n    return Page.Converter(MockPage())\n\n\nclass TestSquareBracketEscaping:\n    \"\"\"Square brackets in plain text must be escaped to avoid markdown link syntax.\"\"\"\n\n    def test_bracket_notation_escaped(self, converter: Page.Converter) -> None:\n        html = \"<p>test [R1] test</p>\"\n        result = converter.convert(html).strip()\n        assert result == r\"test \\[R1\\] test\"\n\n    def test_bracket_at_start(self, converter: Page.Converter) -> None:\n        html = \"<p>[R1] test</p>\"\n        result = converter.convert(html).strip()\n        assert result == r\"\\[R1\\] test\"\n\n    def test_bracket_at_end(self, converter: Page.Converter) -> None:\n        html = \"<p>test [R1]</p>\"\n        result = converter.convert(html).strip()\n        assert result == r\"test \\[R1\\]\"\n\n    def test_multiple_bracket_groups(self, converter: Page.Converter) -> None:\n        html = \"<p>[A1] and [B2]</p>\"\n        result = converter.convert(html).strip()\n        assert result == r\"\\[A1\\] and \\[B2\\]\"\n\n    def test_bracket_in_code_not_escaped(self, converter: Page.Converter) -> None:\n        html = \"<code>[R1]</code>\"\n        result = converter.convert(html).strip()\n        assert result == \"`[R1]`\"\n\n    def test_real_link_not_affected(self, converter: Page.Converter) -> None:\n        html = '<a href=\"https://example.com\">click here</a>'\n        result = converter.convert(html).strip()\n        assert result == \"[click here](https://example.com)\"\n\n\nclass TestAnchorLinkConversion:\n    \"\"\"Internal anchor links must use the href value for slug, not link text.\"\"\"\n\n    def test_anchor_uses_href_not_link_text(self, converter: Page.Converter) -> None:\n        \"\"\"Anchor slug derived from href, not display text.\"\"\"\n        html = '<a href=\"#1.-Request-Service\">request service</a>'\n        result = converter.convert(html).strip()\n        assert result == \"[request service](#1-request-service)\"\n\n    def test_anchor_plain_heading(self, converter: Page.Converter) -> None:\n        \"\"\"Simple heading anchor round-trips correctly.\"\"\"\n        html = '<a href=\"#My-Heading\">My Heading</a>'\n        result = converter.convert(html).strip()\n        assert result == \"[My Heading](#my-heading)\"\n\n    def test_anchor_with_numbers_and_punctuation(self, converter: Page.Converter) -> None:\n        \"\"\"Numbered heading anchors match GitHub markdown anchor format.\"\"\"\n        html = '<a href=\"#2.-Setup-Steps\">setup steps</a>'\n        result = converter.convert(html).strip()\n        assert result == \"[setup steps](#2-setup-steps)\"\n\n    def test_wiki_anchor_uses_link_text(self, converter: Page.Converter) -> None:\n        \"\"\"Wiki links use link text for slug, not href.\"\"\"\n        from unittest.mock import patch\n\n        with patch(\"confluence_markdown_exporter.confluence.settings\") as mock_settings:\n            mock_settings.export.page_href = \"wiki\"\n            html = '<a href=\"#1.-Request-Service\">Request Service</a>'\n            result = converter.convert(html).strip()\n        assert result == \"[[#Request Service]]\"\n\n\ndef _make_attachment(\n    att_id: str,\n    file_id: str,\n    title: str = \"file.png\",\n    media_type: str = \"image/png\",\n) -> Attachment:\n    space = Space(base_url=\"https://example.com\", key=\"TS\", name=\"Test\", description=\"\", homepage=0)\n    version = Version(\n        number=1,\n        by=User(account_id=\"u1\", display_name=\"User\", username=\"user\", public_name=\"\", email=\"\"),\n        when=\"2024-01-01T00:00:00Z\",\n        friendly_when=\"Jan 1\",\n    )\n    return Attachment(\n        base_url=\"https://example.com\",\n        title=title,\n        space=space,\n        ancestors=[],\n        version=version,\n        id=att_id,\n        file_size=100,\n        media_type=media_type,\n        media_type_description=\"\",\n        file_id=file_id,\n        collection_name=\"\",\n        download_link=\"/download\",\n        comment=\"\",\n    )\n\n\ndef _make_page(\n    body: str,\n    body_export: str,\n    attachments: list[Attachment],\n    body_storage: str = \"\",\n) -> Page:\n    space = Space(base_url=\"https://example.com\", key=\"TS\", name=\"Test\", description=\"\", homepage=0)\n    version = Version(\n        number=1,\n        by=User(account_id=\"u1\", display_name=\"User\", username=\"user\", public_name=\"\", email=\"\"),\n        when=\"2024-01-01T00:00:00Z\",\n        friendly_when=\"Jan 1\",\n    )\n    return Page(\n        base_url=\"https://example.com\",\n        id=1,\n        title=\"Test Page\",\n        space=space,\n        ancestors=[],\n        version=version,\n        body=body,\n        body_export=body_export,\n        editor2=\"\",\n        body_storage=body_storage,\n        labels=[],\n        attachments=attachments,\n    )\n\n\nclass TestAttachmentsForExport:\n    \"\"\"_attachments_for_export selects the right attachments.\"\"\"\n\n    def test_file_id_in_body_included(self) -> None:\n        att = _make_attachment(\"111\", \"abc-guid-111\")\n        page = _make_page(\n            body='<img data-media-id=\"abc-guid-111\" src=\"...\">',\n            body_export=\"\",\n            attachments=[att],\n        )\n        with patch(\"confluence_markdown_exporter.confluence.settings\") as s:\n            s.export.attachments_export = \"referenced\"\n            result = page._attachments_for_export()\n        assert att in result\n\n    def test_attachment_id_in_body_included(self) -> None:\n        \"\"\"SVG/MP4 referenced via data-linked-resource-id must be exported.\"\"\"\n        att = _make_attachment(\n            \"99999\", \"xyz-guid-99\", title=\"image.svg\", media_type=\"image/svg+xml\"\n        )\n        page = _make_page(\n            body='<img data-linked-resource-id=\"99999\" src=\"...\">',\n            body_export=\"\",\n            attachments=[att],\n        )\n        with patch(\"confluence_markdown_exporter.confluence.settings\") as s:\n            s.export.attachments_export = \"referenced\"\n            result = page._attachments_for_export()\n        assert att in result\n\n    def test_attachment_id_in_body_export_included(self) -> None:\n        \"\"\"Attachment referenced only in body_export (e.g. MP4) must be exported.\"\"\"\n        att = _make_attachment(\"88888\", \"xyz-guid-88\", title=\"video.mp4\", media_type=\"video/mp4\")\n        page = _make_page(\n            body=\"\",\n            body_export='<a data-linked-resource-id=\"88888\" href=\"...\">video.mp4</a>',\n            attachments=[att],\n        )\n        with patch(\"confluence_markdown_exporter.confluence.settings\") as s:\n            s.export.attachments_export = \"referenced\"\n            result = page._attachments_for_export()\n        assert att in result\n\n    def test_title_in_body_src_url_included(self) -> None:\n        \"\"\"SVG referenced only by filename in src URL (no data attributes) must be exported.\"\"\"\n        att = _make_attachment(\n            \"66666\", \"xyz-guid-66\", title=\"MEP-Symbol_CH-REP.svg\", media_type=\"image/svg+xml\"\n        )\n        page = _make_page(\n            body='<img src=\"/download/attachments/12345/MEP-Symbol_CH-REP.svg?version=1\">',\n            body_export=\"\",\n            attachments=[att],\n        )\n        with patch(\"confluence_markdown_exporter.confluence.settings\") as s:\n            s.export.attachments_export = \"referenced\"\n            result = page._attachments_for_export()\n        assert att in result\n\n    def test_title_with_spaces_url_encoded_in_body_export_included(self) -> None:\n        att = _make_attachment(\"55555\", \"xyz-guid-55\", title=\"my video.mp4\", media_type=\"video/mp4\")\n        page = _make_page(\n            body=\"\",\n            body_export='<img src=\"/download/attachments/12345/my%20video.mp4\">',\n            attachments=[att],\n        )\n        with patch(\"confluence_markdown_exporter.confluence.settings\") as s:\n            s.export.attachments_export = \"referenced\"\n            result = page._attachments_for_export()\n        assert att in result\n\n    def test_unreferenced_attachment_excluded(self) -> None:\n        att = _make_attachment(\"77777\", \"xyz-guid-77\", title=\"unused.png\")\n        page = _make_page(body=\"no references here\", body_export=\"\", attachments=[att])\n        with patch(\"confluence_markdown_exporter.confluence.settings\") as s:\n            s.export.attachments_export = \"referenced\"\n            result = page._attachments_for_export()\n        assert att not in result\n\n    def test_attachments_export_all_returns_all(self) -> None:\n        att1 = _make_attachment(\"111\", \"aaa\")\n        att2 = _make_attachment(\"222\", \"bbb\", title=\"other.svg\", media_type=\"image/svg+xml\")\n        page = _make_page(body=\"\", body_export=\"\", attachments=[att1, att2])\n        with patch(\"confluence_markdown_exporter.confluence.settings\") as s:\n            s.export.attachments_export = \"all\"\n            result = page._attachments_for_export()\n        assert result == [att1, att2]\n\n\nclass TestAttachmentsExportFlag:\n    \"\"\"Tests for the export.attachments_export setting.\"\"\"\n\n    def _make_attachment_mock(self, att_id: str = \"att-1\", version: int = 3) -> MagicMock:\n        att = MagicMock()\n        att.id = att_id\n        att.version.number = version\n        att.export_path = Path(f\"attachments/{att_id}.bin\")\n        return att\n\n    def _make_page_mock(self, attachments: list) -> MagicMock:\n        page = MagicMock()\n        page.id = 42\n        page._attachments_for_export.return_value = attachments\n        return page\n\n    def test_referenced_default_exports_attachments(self, tmp_path: Path) -> None:\n        \"\"\"With attachments_export='referenced' (default), attachments are downloaded.\"\"\"\n        att = self._make_attachment_mock()\n        page = self._make_page_mock([att])\n\n        with (\n            patch(\"confluence_markdown_exporter.confluence.settings\") as mock_settings,\n            patch(\n                \"confluence_markdown_exporter.confluence.LockfileManager\"\n            ) as mock_lockfile,\n            patch(\"confluence_markdown_exporter.confluence.get_stats\"),\n        ):\n            mock_settings.export.attachments_export = \"referenced\"\n            mock_settings.export.output_path = tmp_path\n            mock_lockfile.get_page_attachment_entries.return_value = {}\n\n            result = Page.export_attachments(page)\n\n        att.export.assert_called_once()\n        assert \"att-1\" in result\n\n    def test_disabled_skips_download_and_lockfile(self) -> None:\n        \"\"\"With attachments_export='disabled', no download and no lockfile lookup.\"\"\"\n        att = self._make_attachment_mock()\n        page = self._make_page_mock([att])\n\n        with (\n            patch(\"confluence_markdown_exporter.confluence.settings\") as mock_settings,\n            patch(\n                \"confluence_markdown_exporter.confluence.LockfileManager\"\n            ) as mock_lockfile,\n            patch(\"confluence_markdown_exporter.confluence.get_stats\"),\n        ):\n            mock_settings.export.attachments_export = \"disabled\"\n\n            result = Page.export_attachments(page)\n\n        assert result == {}\n        att.export.assert_not_called()\n        mock_lockfile.get_page_attachment_entries.assert_not_called()\n\n    def test_metadata_still_populated_when_disabled(self) -> None:\n        \"\"\"Page.from_json populates Page.attachments even when downloads are disabled.\n\n        Guards against future scope creep that would gate metadata loading on\n        the same flag — body image and file links must keep resolving.\n        \"\"\"\n        base_url = \"https://example.atlassian.net\"\n        fake_space = Space(\n            base_url=base_url, key=\"K\", name=\"Space\", description=\"\", homepage=None\n        )\n        fake_user = User(\n            account_id=\"\", username=\"\", display_name=\"\", public_name=\"\", email=\"\"\n        )\n        fake_version = Version(number=1, by=fake_user, when=\"\", friendly_when=\"\")\n        fake_attachment = Attachment(\n            base_url=base_url,\n            id=\"att-1\",\n            title=\"file.png\",\n            space=fake_space,\n            ancestors=[],\n            version=fake_version,\n            file_size=10,\n            media_type=\"image/png\",\n            media_type_description=\"\",\n            file_id=\"file-id-1\",\n            collection_name=\"\",\n            download_link=\"\",\n            comment=\"\",\n        )\n        page_data = {\n            \"id\": 42,\n            \"title\": \"Test\",\n            \"_expandable\": {\"space\": \"/rest/api/space/K\"},\n            \"body\": {\n                \"view\": {\"value\": \"\"},\n                \"export_view\": {\"value\": \"\"},\n                \"editor2\": {\"value\": \"\"},\n            },\n            \"metadata\": {\"labels\": {\"results\": []}},\n            \"ancestors\": [],\n            \"version\": {},\n        }\n\n        with (\n            patch(\n                \"confluence_markdown_exporter.confluence.Attachment.from_page_id\",\n                return_value=[fake_attachment],\n            ),\n            patch(\n                \"confluence_markdown_exporter.confluence.Space.from_key\",\n                return_value=fake_space,\n            ),\n            patch(\"confluence_markdown_exporter.confluence.settings\") as mock_settings,\n        ):\n            mock_settings.export.attachments_export = \"disabled\"\n\n            page = Page.from_json(page_data, base_url)\n\n        assert len(page.attachments) == 1\n        assert page.attachments[0].id == \"att-1\"\n\n\nclass TestTransformErrorImg:\n    \"\"\"transform-error SVG images must resolve via data-encoded-xml.\"\"\"\n\n    def test_transform_error_resolves_attachment_by_encoded_xml(self) -> None:\n        from pathlib import Path\n        from urllib.parse import quote\n\n        class MockAttachment:\n            title = \"MEP-Symbol_CH-REP.svg\"\n            export_path = Path(\"TEST/attachments/guid123.svg\")\n\n        class MockPageWithSvg:\n            def __init__(self) -> None:\n                self.id = \"test-page\"\n                self.title = \"Test Page\"\n                self.html = \"\"\n                self.body_storage = \"\"\n                self.labels: list = []\n                self.ancestors: list = []\n                self.export_path = Path(\"TEST/Instructions for Use.md\")\n\n            def get_attachment_by_file_id(self, _fid: str) -> None:\n                return None\n\n            def get_attachment_by_id(self, _aid: str) -> None:\n                return None\n\n            def get_attachments_by_title(self, title: str) -> list:\n                if title == \"MEP-Symbol_CH-REP.svg\":\n                    return [MockAttachment()]\n                return []\n\n        encoded = quote('<ac:image><ri:attachment ri:filename=\"MEP-Symbol_CH-REP.svg\"/></ac:image>')\n        html = (\n            f'<img class=\"transform-error\" data-encoded-xml=\"{encoded}\" '\n            f'src=\"https://example.com/placeholder/error\" title=\"\">'\n        )\n\n        with patch(\"confluence_markdown_exporter.confluence.settings\") as s:\n            s.export.attachment_href = \"relative\"\n            s.export.page_href = \"relative\"\n            conv = Page.Converter(MockPageWithSvg())  # type: ignore[arg-type]\n            result = conv.convert(html).strip()\n\n        assert \"placeholder/error\" not in result\n        assert \"MEP-Symbol_CH-REP.svg\" in result or \"guid123.svg\" in result\n\n\nclass TestParseImageCaptions:\n    \"\"\"_parse_image_captions extracts captions from Confluence storage XML.\"\"\"\n\n    def test_cdata_caption_extracted(self) -> None:\n        from confluence_markdown_exporter.confluence import _parse_image_captions\n\n        storage = (\n            '<ac:image ac:align=\"center\">'\n            '<ri:attachment ri:filename=\"testbild.jpeg\"/>'\n            \"<ac:caption>\"\n            \"<ac:plain-text-body><![CDATA[My Caption]]></ac:plain-text-body>\"\n            \"</ac:caption>\"\n            \"</ac:image>\"\n        )\n        assert _parse_image_captions(storage) == {\"testbild.jpeg\": \"My Caption\"}\n\n    def test_plain_text_caption_extracted(self) -> None:\n        from confluence_markdown_exporter.confluence import _parse_image_captions\n\n        storage = (\n            \"<ac:image>\"\n            '<ri:attachment ri:filename=\"photo.png\"/>'\n            \"<ac:caption>\"\n            \"<ac:plain-text-body>Plain Caption</ac:plain-text-body>\"\n            \"</ac:caption>\"\n            \"</ac:image>\"\n        )\n        assert _parse_image_captions(storage) == {\"photo.png\": \"Plain Caption\"}\n\n    def test_paragraph_caption_extracted(self) -> None:\n        from confluence_markdown_exporter.confluence import _parse_image_captions\n\n        storage = (\n            '<ac:image ac:align=\"center\">'\n            '<ri:attachment ri:filename=\"screenshot.png\" ri:version-at-save=\"1\"/>'\n            \"<ac:caption><p>Dialog in VS Code to create a new branch</p></ac:caption>\"\n            \"</ac:image>\"\n        )\n        result = _parse_image_captions(storage)\n        assert result == {\"screenshot.png\": \"Dialog in VS Code to create a new branch\"}\n\n    def test_caption_with_attributes_extracted(self) -> None:\n        from confluence_markdown_exporter.confluence import _parse_image_captions\n\n        storage = (\n            '<ac:image ac:align=\"center\" ac:width=\"544\">'\n            '<ri:attachment ri:filename=\"TissueMap.png\" ri:version-at-save=\"1\"/>'\n            '<ac:caption ac:local-id=\"6a5ac213-73a0\">'\n            \"<p>Exemplary Tissue Map</p>\"\n            \"</ac:caption>\"\n            \"</ac:image>\"\n        )\n        result = _parse_image_captions(storage)\n        assert result == {\"TissueMap.png\": \"Exemplary Tissue Map\"}\n\n    def test_image_without_caption_excluded(self) -> None:\n        from confluence_markdown_exporter.confluence import _parse_image_captions\n\n        storage = (\n            \"<ac:image>\"\n            '<ri:attachment ri:filename=\"no-caption.png\"/>'\n            \"</ac:image>\"\n        )\n        assert _parse_image_captions(storage) == {}\n\n    def test_multiple_images_mixed(self) -> None:\n        from confluence_markdown_exporter.confluence import _parse_image_captions\n\n        storage = (\n            \"<ac:image>\"\n            '<ri:attachment ri:filename=\"a.png\"/>'\n            \"<ac:caption><ac:plain-text-body>\"\n            \"<![CDATA[Caption A]]></ac:plain-text-body></ac:caption>\"\n            \"</ac:image>\"\n            \"<ac:image>\"\n            '<ri:attachment ri:filename=\"b.png\"/>'\n            \"</ac:image>\"\n            \"<ac:image>\"\n            '<ri:attachment ri:filename=\"c.jpg\"/>'\n            \"<ac:caption><ac:plain-text-body>\"\n            \"<![CDATA[Caption C]]></ac:plain-text-body></ac:caption>\"\n            \"</ac:image>\"\n        )\n        result = _parse_image_captions(storage)\n        assert result == {\"a.png\": \"Caption A\", \"c.jpg\": \"Caption C\"}\n\n    def test_empty_storage_returns_empty(self) -> None:\n        from confluence_markdown_exporter.confluence import _parse_image_captions\n\n        assert _parse_image_captions(\"\") == {}\n\n\nclass TestImageCaptionsInConvertImg:\n    \"\"\"convert_img renders captions as italics below the image when image_captions is enabled.\"\"\"\n\n    def test_caption_rendered_as_italic_below_image(self) -> None:\n        att = _make_attachment(\"111\", \"abc-guid-111\", title=\"testbild.jpeg\")\n        storage = (\n            \"<ac:image>\"\n            '<ri:attachment ri:filename=\"testbild.jpeg\"/>'\n            \"<ac:caption>\"\n            \"<ac:plain-text-body><![CDATA[My Caption]]></ac:plain-text-body>\"\n            \"</ac:caption>\"\n            \"</ac:image>\"\n        )\n        page = _make_page(\n            body='<img data-media-id=\"abc-guid-111\" src=\"/download/testbild.jpeg\" alt=\"\">',\n            body_export=\"\",\n            attachments=[att],\n            body_storage=storage,\n        )\n        _att_path = \"{space_name}/attachments/{attachment_file_id}{attachment_extension}\"\n        with patch(\"confluence_markdown_exporter.confluence.settings\") as s:\n            s.export.attachment_href = \"relative\"\n            s.export.attachment_path = _att_path\n            s.export.page_href = \"relative\"\n            s.export.page_path = \"{space_name}/{page_title}.md\"\n            s.export.image_captions = True\n            s.export.include_document_title = False\n            s.export.page_breadcrumbs = False\n            conv = Page.Converter(page)\n            result = conv.convert(page.body).strip()\n        assert \"![](\" in result  # image with empty alt\n        assert \"*My Caption*\" in result\n        lines = result.splitlines()\n        img_line = next(i for i, line in enumerate(lines) if \"![](\" in line)\n        assert lines[img_line + 1] == \"*My Caption*\"\n\n    def test_caption_disabled_preserves_original_alt(self) -> None:\n        att = _make_attachment(\"111\", \"abc-guid-111\", title=\"testbild.jpeg\")\n        storage = (\n            \"<ac:image>\"\n            '<ri:attachment ri:filename=\"testbild.jpeg\"/>'\n            \"<ac:caption>\"\n            \"<ac:plain-text-body><![CDATA[My Caption]]></ac:plain-text-body>\"\n            \"</ac:caption>\"\n            \"</ac:image>\"\n        )\n        page = _make_page(\n            body='<img data-media-id=\"abc-guid-111\" src=\"/download/testbild.jpeg\" alt=\"\">',\n            body_export=\"\",\n            attachments=[att],\n            body_storage=storage,\n        )\n        _att_path = \"{space_name}/attachments/{attachment_file_id}{attachment_extension}\"\n        with patch(\"confluence_markdown_exporter.confluence.settings\") as s:\n            s.export.attachment_href = \"relative\"\n            s.export.attachment_path = _att_path\n            s.export.page_href = \"relative\"\n            s.export.page_path = \"{space_name}/{page_title}.md\"\n            s.export.image_captions = False\n            s.export.include_document_title = False\n            s.export.page_breadcrumbs = False\n            conv = Page.Converter(page)\n            result = conv.convert(page.body).strip()\n        assert \"My Caption\" not in result\n\n\nclass TestPageFromUrl:\n    \"\"\"Test cases for Page.from_url.\"\"\"\n\n    def test_from_url_prefers_page_id_query_parameter_for_legacy_server_url(self) -> None:\n        \"\"\"Legacy Server/DC viewpage.action links should resolve by pageId.\"\"\"\n        page_url = (\n            \"https://wiki.example.com/pages/viewpage.action\"\n            \"?pageId=317425825&src=contextnavpagetreemode\"\n        )\n\n        with (\n            patch(\"confluence_markdown_exporter.confluence.get_confluence_instance\"),\n            patch(\"confluence_markdown_exporter.confluence.Page.from_id\") as mock_from_id,\n            patch(\"confluence_markdown_exporter.confluence.get_thread_confluence\") as mock_client,\n        ):\n            mock_from_id.return_value = \"page\"\n\n            result = Page.from_url(page_url)\n\n        assert result == \"page\"\n        mock_from_id.assert_called_once_with(317425825, \"https://wiki.example.com\")\n        mock_client.assert_not_called()\n\n\nclass TestSpanHighlightConversion:\n    \"\"\"Background-color spans must become <mark> elements when enabled.\"\"\"\n\n    def test_background_color_rgb_converted_to_mark(self, converter: Page.Converter) -> None:\n        html = '<p><span style=\"background-color: rgb(248,230,160);\">hello</span></p>'\n        result = converter.convert(html).strip()\n        assert '<mark style=\"background: #f8e6a0;\">hello</mark>' in result\n\n    def test_multiple_channels_converted_correctly(self, converter: Page.Converter) -> None:\n        html = '<p><span style=\"background-color: rgb(198,237,251);\">text</span></p>'\n        result = converter.convert(html).strip()\n        assert '<mark style=\"background: #c6edfb;\">text</mark>' in result\n\n    def test_highlight_disabled_returns_plain_text(self, converter: Page.Converter) -> None:\n        html = '<p><span style=\"background-color: rgb(248,230,160);\">hello</span></p>'\n        with patch(\"confluence_markdown_exporter.confluence.settings\") as s:\n            s.export.convert_text_highlights = False\n            s.export.convert_font_colors = True\n            result = converter.convert(html).strip()\n        assert \"<mark\" not in result\n        assert \"hello\" in result\n\n\nclass TestCellHighlightConversion:\n    \"\"\"Confluence `data-highlight-colour` on <td>/<th> must become <mark> wrappers.\"\"\"\n\n    def test_td_hex_attribute_wraps_in_mark(self, converter: Page.Converter) -> None:\n        html = (\n            '<table><tbody><tr>'\n            '<td data-highlight-colour=\"#fff0b3\"><p>2</p></td>'\n            '</tr></tbody></table>'\n        )\n        result = converter.convert(html)\n        assert '<mark style=\"background: #fff0b3;\">2</mark>' in result\n\n    def test_th_hex_attribute_wraps_in_mark(self, converter: Page.Converter) -> None:\n        html = (\n            '<table><tbody><tr>'\n            '<th data-highlight-colour=\"#ffd5d2\"><p><strong>P / S</strong></p></th>'\n            '</tr></tbody></table>'\n        )\n        result = converter.convert(html)\n        assert '<mark style=\"background: #ffd5d2;\">**P / S**</mark>' in result\n\n    def test_default_header_gray_not_wrapped(self, converter: Page.Converter) -> None:\n        \"\"\"Confluence's default <th> background (#f4f5f7) is not user-chosen — skip.\"\"\"\n        html = (\n            '<table><tbody><tr>'\n            '<th data-highlight-colour=\"#f4f5f7\"><p><strong>P / S</strong></p></th>'\n            '<td data-highlight-colour=\"#f4f5f7\"><p><strong>P5</strong></p></td>'\n            '</tr></tbody></table>'\n        )\n        result = converter.convert(html)\n        assert \"<mark\" not in result\n\n    def test_transparent_attribute_not_wrapped(self, converter: Page.Converter) -> None:\n        html = (\n            '<table><tbody><tr>'\n            '<td data-highlight-colour=\"transparent\"><p>plain</p></td>'\n            '</tr></tbody></table>'\n        )\n        result = converter.convert(html)\n        assert \"<mark\" not in result\n        assert \"plain\" in result\n\n    def test_missing_attribute_not_wrapped(self, converter: Page.Converter) -> None:\n        html = (\n            '<table><tbody><tr><td><p>plain</p></td></tr></tbody></table>'\n        )\n        result = converter.convert(html)\n        assert \"<mark\" not in result\n        assert \"plain\" in result\n\n    def test_invalid_hex_not_wrapped(self, converter: Page.Converter) -> None:\n        html = (\n            '<table><tbody><tr>'\n            '<td data-highlight-colour=\"not-a-color\"><p>x</p></td>'\n            '</tr></tbody></table>'\n        )\n        result = converter.convert(html)\n        assert \"<mark\" not in result\n\n    def test_empty_cell_with_highlight_renders_nbsp(self, converter: Page.Converter) -> None:\n        html = (\n            '<table><tbody><tr>'\n            '<td data-highlight-colour=\"#ff8f73\"></td>'\n            '</tr></tbody></table>'\n        )\n        result = converter.convert(html)\n        assert '<mark style=\"background: #ff8f73;\">&nbsp;</mark>' in result\n\n    def test_setting_disabled_returns_plain_text(self, converter: Page.Converter) -> None:\n        html = (\n            '<table><tbody><tr>'\n            '<td data-highlight-colour=\"#fff0b3\"><p>2</p></td>'\n            '</tr></tbody></table>'\n        )\n        with patch(\"confluence_markdown_exporter.confluence.settings\") as s:\n            s.export.convert_text_highlights = False\n            s.export.convert_font_colors = True\n            s.export.convert_status_badges = True\n            result = converter.convert(html)\n        assert \"<mark\" not in result\n        assert \"2\" in result\n\n\nclass TestSpanFontColorConversion:\n    \"\"\"Color spans must become <font> elements when enabled.\"\"\"\n\n    def test_inline_color_rgb_converted_to_font(self, converter: Page.Converter) -> None:\n        html = '<p><span style=\"color: rgb(7,71,166);\">blue text</span></p>'\n        result = converter.convert(html).strip()\n        assert '<font style=\"color: #0747a6;\">blue text</font>' in result\n\n    def test_background_color_not_matched_as_font_color(self, converter: Page.Converter) -> None:\n        html = '<p><span style=\"background-color: rgb(248,230,160);\">hi</span></p>'\n        result = converter.convert(html).strip()\n        assert \"<font\" not in result\n        assert '<mark style=\"background: #f8e6a0;\">hi</mark>' in result\n\n    def test_data_colorid_resolved_from_style_tag(self) -> None:\n        page = MockPage()\n        page.html = (\n            '<style>[data-colorid=abc123]{color:#ff5630} '\n            'html[data-color-mode=dark] [data-colorid=abc123]{color:#cf2600}</style>'\n        )\n        conv = Page.Converter(page)  # type: ignore[arg-type]\n        html = '<p><span data-colorid=\"abc123\">colored</span></p>'\n        result = conv.convert(html).strip()\n        assert '<font style=\"color: #ff5630;\">colored</font>' in result\n\n    def test_data_colorid_unknown_falls_through(self, converter: Page.Converter) -> None:\n        html = '<p><span data-colorid=\"unknown999\">text</span></p>'\n        result = converter.convert(html).strip()\n        assert \"<font\" not in result\n        assert \"text\" in result\n\n    def test_font_color_disabled_returns_plain_text(self, converter: Page.Converter) -> None:\n        html = '<p><span style=\"color: rgb(255,86,48);\">red text</span></p>'\n        with patch(\"confluence_markdown_exporter.confluence.settings\") as s:\n            s.export.convert_text_highlights = True\n            s.export.convert_font_colors = False\n            result = converter.convert(html).strip()\n        assert \"<font\" not in result\n        assert \"red text\" in result\n\n\nclass TestStatusBadgeConversion:\n    \"\"\"Confluence status-macro lozenge spans must become <mark> elements when enabled.\"\"\"\n\n    def _badge(self, extra_class: str, label: str) -> str:\n        classes = f\"status-macro aui-lozenge aui-lozenge-visual-refresh {extra_class}\".strip()\n        return (\n            f'<p><span class=\"{classes}\" data-macro-name=\"status\">{label}</span></p>'\n        )\n\n    def test_gray_badge(self, converter: Page.Converter) -> None:\n        html = self._badge(\"\", \"IN PROGRESS\")\n        result = converter.convert(html).strip()\n        assert '<mark style=\"background: #dfe1e6;\">IN PROGRESS</mark>' in result\n\n    def test_blue_badge(self, converter: Page.Converter) -> None:\n        html = self._badge(\"aui-lozenge-complete\", \"DONE\")\n        result = converter.convert(html).strip()\n        assert '<mark style=\"background: #cce0ff;\">DONE</mark>' in result\n\n    def test_green_badge(self, converter: Page.Converter) -> None:\n        html = self._badge(\"aui-lozenge-success\", \"SUCCESS\")\n        result = converter.convert(html).strip()\n        assert '<mark style=\"background: #baf3db;\">SUCCESS</mark>' in result\n\n    def test_yellow_badge(self, converter: Page.Converter) -> None:\n        html = self._badge(\"aui-lozenge-current\", \"ORANGE\")\n        result = converter.convert(html).strip()\n        assert '<mark style=\"background: #f8e6a0;\">ORANGE</mark>' in result\n\n    def test_red_badge(self, converter: Page.Converter) -> None:\n        html = self._badge(\"aui-lozenge-error\", \"BLOCKED\")\n        result = converter.convert(html).strip()\n        assert '<mark style=\"background: #ffd5d2;\">BLOCKED</mark>' in result\n\n    def test_purple_badge(self, converter: Page.Converter) -> None:\n        html = self._badge(\"aui-lozenge-progress\", \"VIOLET\")\n        result = converter.convert(html).strip()\n        assert '<mark style=\"background: #dfd8fd;\">VIOLET</mark>' in result\n\n    def test_badge_disabled_returns_plain_text(self, converter: Page.Converter) -> None:\n        html = self._badge(\"aui-lozenge-error\", \"BLOCKED\")\n        with patch(\"confluence_markdown_exporter.confluence.settings\") as s:\n            s.export.convert_status_badges = False\n            s.export.convert_font_colors = True\n            s.export.convert_text_highlights = True\n            result = converter.convert(html).strip()\n        assert \"<mark\" not in result\n        assert \"BLOCKED\" in result\n\n\n_DETAILS_HTML = \"\"\"\n<div data-macro-name=\"details\">\n    <table>\n        <tr><th>Author</th><td>John Doe</td></tr>\n        <tr><th>Status</th><td>Active</td></tr>\n    </table>\n</div>\n\"\"\"\n\n\nclass TestPagePropertiesFormat:\n    \"\"\"Page Properties macro renders according to page_properties_format setting.\"\"\"\n\n    def _converter(self) -> Page.Converter:\n        return Page.Converter(MockPage())\n\n    def test_frontmatter_removes_table(self) -> None:\n        converter = self._converter()\n        with patch(\"confluence_markdown_exporter.confluence.settings\") as s:\n            s.export.page_properties_format = \"frontmatter\"\n            result = converter.convert(_DETAILS_HTML)\n        assert \"Author\" not in result\n        assert \"author\" in converter.page_properties\n        assert converter.page_properties[\"author\"] == \"John Doe\"\n\n    def test_table_keeps_table_no_properties(self) -> None:\n        converter = self._converter()\n        with patch(\"confluence_markdown_exporter.confluence.settings\") as s:\n            s.export.page_properties_format = \"table\"\n            result = converter.convert(_DETAILS_HTML)\n        assert \"Author\" in result\n        assert converter.page_properties == {}\n\n    def test_frontmatter_and_table_keeps_both(self) -> None:\n        converter = self._converter()\n        with patch(\"confluence_markdown_exporter.confluence.settings\") as s:\n            s.export.page_properties_format = \"frontmatter_and_table\"\n            result = converter.convert(_DETAILS_HTML)\n        assert \"Author\" in result\n        assert \"author\" in converter.page_properties\n        assert converter.page_properties[\"author\"] == \"John Doe\"\n\n    def test_dataview_inline_field(self) -> None:\n        converter = self._converter()\n        with patch(\"confluence_markdown_exporter.confluence.settings\") as s:\n            s.export.page_properties_format = \"dataview-inline-field\"\n            result = converter.convert(_DETAILS_HTML)\n        assert \"Author:: John Doe\" in result\n        assert \"Status:: Active\" in result\n        assert \"|\" not in result\n        assert converter.page_properties == {}\n\n    def test_meta_bind_view_fields(self) -> None:\n        converter = self._converter()\n        with patch(\"confluence_markdown_exporter.confluence.settings\") as s:\n            s.export.page_properties_format = \"meta-bind-view-fields\"\n            result = converter.convert(_DETAILS_HTML)\n        assert \"| **Author** | `VIEW[{author}][text(renderMarkdown)]` |\" in result\n        assert \"| **Status** | `VIEW[{status}][text(renderMarkdown)]` |\" in result\n        assert \"author\" in converter.page_properties\n        assert \"status\" in converter.page_properties\n\n    def test_duplicate_keys_get_numeric_suffix(self) -> None:\n        html = \"\"\"\n        <div data-macro-name=\"details\">\n            <table>\n                <tr><th>Status</th><td>Draft</td></tr>\n                <tr><th>Status</th><td>Review</td></tr>\n                <tr><th>Status</th><td>Final</td></tr>\n            </table>\n        </div>\n        \"\"\"\n        converter = self._converter()\n        with patch(\"confluence_markdown_exporter.confluence.settings\") as s:\n            s.export.page_properties_format = \"frontmatter\"\n            converter.convert(html)\n        assert converter.page_properties[\"status\"] == \"Draft\"\n        assert converter.page_properties[\"status_2\"] == \"Review\"\n        assert converter.page_properties[\"status_3\"] == \"Final\"\n\n    def test_duplicate_keys_in_inline_fields(self) -> None:\n        html = \"\"\"\n        <div data-macro-name=\"details\">\n            <table>\n                <tr><th>Tag</th><td>foo</td></tr>\n                <tr><th>Tag</th><td>bar</td></tr>\n            </table>\n        </div>\n        \"\"\"\n        converter = self._converter()\n        with patch(\"confluence_markdown_exporter.confluence.settings\") as s:\n            s.export.page_properties_format = \"dataview-inline-field\"\n            result = converter.convert(html)\n        assert \"Tag:: foo\" in result\n        assert \"Tag 2:: bar\" in result\n\n\nclass TestPagePropertiesMigration:\n    \"\"\"Legacy page_properties_as_front_matter bool migrates to page_properties_format.\"\"\"\n\n    def test_old_true_maps_to_frontmatter(self) -> None:\n        from confluence_markdown_exporter.utils.app_data_store import ExportConfig\n\n        config = ExportConfig.model_validate({\"page_properties_as_front_matter\": True})\n        assert config.page_properties_format == \"frontmatter\"\n\n    def test_old_false_maps_to_table(self) -> None:\n        from confluence_markdown_exporter.utils.app_data_store import ExportConfig\n\n        config = ExportConfig.model_validate({\"page_properties_as_front_matter\": False})\n        assert config.page_properties_format == \"table\"\n\n    def test_new_field_takes_precedence_over_old(self) -> None:\n        from confluence_markdown_exporter.utils.app_data_store import ExportConfig\n\n        config = ExportConfig.model_validate(\n            {\"page_properties_as_front_matter\": True, \"page_properties_format\": \"table\"}\n        )\n        assert config.page_properties_format == \"table\"\n\n    def test_default_is_frontmatter_and_table(self) -> None:\n        from confluence_markdown_exporter.utils.app_data_store import ExportConfig\n\n        config = ExportConfig()\n        assert config.page_properties_format == \"frontmatter_and_table\"\n\n\nclass TestConfluenceUrlInFrontmatter:\n    \"\"\"Confluence page URLs render to YAML front matter according to the setting.\"\"\"\n\n    _WEBUI = \"https://example.atlassian.net/wiki/spaces/TEST/pages/123/Test+Page\"\n    _TINYUI = \"https://example.atlassian.net/wiki/x/AbCdEf\"\n\n    def _converter(self, *, with_urls: bool = True) -> Page.Converter:\n        page = MockPage()\n        if with_urls:\n            page.web_url = self._WEBUI\n            page.tiny_url = self._TINYUI\n        return Page.Converter(page)\n\n    def test_get_web_url_combines_base_and_webui(self) -> None:\n        from confluence_markdown_exporter.confluence import _get_web_url\n\n        data = {\n            \"_links\": {\n                \"base\": \"https://example.atlassian.net/wiki\",\n                \"webui\": \"/spaces/TEST/pages/123/Test+Page\",\n            }\n        }\n        assert _get_web_url(data) == self._WEBUI\n\n    def test_get_tiny_url_combines_base_and_tinyui(self) -> None:\n        from confluence_markdown_exporter.confluence import _get_tiny_url\n\n        data = {\n            \"_links\": {\n                \"base\": \"https://example.atlassian.net/wiki\",\n                \"tinyui\": \"/x/AbCdEf\",\n            }\n        }\n        assert _get_tiny_url(data) == self._TINYUI\n\n    def test_helpers_strip_redundant_separators(self) -> None:\n        from confluence_markdown_exporter.confluence import _get_web_url\n\n        data = {\n            \"_links\": {\n                \"base\": \"https://example.atlassian.net/wiki/\",\n                \"webui\": \"/spaces/TEST/pages/123/Test+Page\",\n            }\n        }\n        assert _get_web_url(data) == self._WEBUI\n\n    def test_helpers_return_empty_when_links_missing(self) -> None:\n        from confluence_markdown_exporter.confluence import _get_tiny_url\n        from confluence_markdown_exporter.confluence import _get_web_url\n\n        assert _get_web_url({}) == \"\"\n        assert _get_tiny_url({}) == \"\"\n\n    def test_helpers_return_empty_when_links_not_dict(self) -> None:\n        from confluence_markdown_exporter.confluence import _get_tiny_url\n        from confluence_markdown_exporter.confluence import _get_web_url\n\n        assert _get_web_url({\"_links\": \"broken\"}) == \"\"\n        assert _get_tiny_url({\"_links\": None}) == \"\"\n\n    def test_helpers_return_empty_when_base_or_rel_missing(self) -> None:\n        from confluence_markdown_exporter.confluence import _get_web_url\n\n        assert _get_web_url({\"_links\": {\"base\": \"https://example.com\"}}) == \"\"\n        assert _get_web_url({\"_links\": {\"webui\": \"/spaces/TEST\"}}) == \"\"\n\n    def test_frontmatter_contains_webui_url_when_mode_webui(self) -> None:\n        converter = self._converter()\n        with patch(\"confluence_markdown_exporter.confluence.settings\") as s:\n            s.export.confluence_url_in_frontmatter = \"webui\"\n            result = converter.front_matter\n        assert f\"confluence_webui_url: {self._WEBUI}\" in result\n        assert \"confluence_tinyui_url\" not in result\n\n    def test_frontmatter_contains_tinyui_url_when_mode_tinyui(self) -> None:\n        converter = self._converter()\n        with patch(\"confluence_markdown_exporter.confluence.settings\") as s:\n            s.export.confluence_url_in_frontmatter = \"tinyui\"\n            result = converter.front_matter\n        assert f\"confluence_tinyui_url: {self._TINYUI}\" in result\n        assert \"confluence_webui_url\" not in result\n\n    def test_frontmatter_contains_both_when_mode_both(self) -> None:\n        converter = self._converter()\n        with patch(\"confluence_markdown_exporter.confluence.settings\") as s:\n            s.export.confluence_url_in_frontmatter = \"both\"\n            result = converter.front_matter\n        assert f\"confluence_webui_url: {self._WEBUI}\" in result\n        assert f\"confluence_tinyui_url: {self._TINYUI}\" in result\n\n    def test_frontmatter_omits_urls_when_mode_none(self) -> None:\n        converter = self._converter()\n        with patch(\"confluence_markdown_exporter.confluence.settings\") as s:\n            s.export.confluence_url_in_frontmatter = \"none\"\n            result = converter.front_matter\n        assert \"confluence_webui_url\" not in result\n        assert \"confluence_tinyui_url\" not in result\n\n    def test_frontmatter_skips_when_url_value_is_empty(self) -> None:\n        converter = self._converter(with_urls=False)\n        with patch(\"confluence_markdown_exporter.confluence.settings\") as s:\n            s.export.confluence_url_in_frontmatter = \"both\"\n            result = converter.front_matter\n        assert \"confluence_webui_url\" not in result\n        assert \"confluence_tinyui_url\" not in result\n\n    def test_macro_value_takes_precedence_over_extracted_url(self) -> None:\n        converter = self._converter()\n        converter.page_properties[\"confluence_webui_url\"] = \"manual-override\"\n        with patch(\"confluence_markdown_exporter.confluence.settings\") as s:\n            s.export.confluence_url_in_frontmatter = \"webui\"\n            result = converter.front_matter\n        assert \"confluence_webui_url: manual-override\" in result\n        assert self._WEBUI not in result\n\n\nclass TestPageMetadataInFrontmatter:\n    \"\"\"Page metadata fields render to YAML front matter according to the setting.\"\"\"\n\n    def _make_page(\n        self,\n        *,\n        display_name: str = \"Alex Johnson\",\n        page_type: str = \"page\",\n        created: str = \"2024-08-15T08:34:12.000+02:00\",\n        created_by: str = \"Sam Creator\",\n    ) -> MockPage:\n        page = MockPage()\n        page.id = 123\n        page.type = page_type\n        space = MagicMock()\n        space.key = \"TEAM\"\n        page.space = space\n        version = MagicMock()\n        version.when = \"2026-04-12T10:34:00.000+02:00\"\n        version.number = 7\n        version.by = MagicMock()\n        version.by.display_name = display_name\n        page.version = version\n        history = MagicMock()\n        history.created = created\n        history.created_by = MagicMock()\n        history.created_by.display_name = created_by\n        page.history = history\n        return page\n\n    def _converter(self, **kwargs: object) -> Page.Converter:\n        return Page.Converter(self._make_page(**kwargs))\n\n    def test_default_disabled_writes_no_metadata(self) -> None:\n        converter = self._converter()\n        with patch(\"confluence_markdown_exporter.confluence.settings\") as s:\n            s.export.confluence_url_in_frontmatter = \"none\"\n            s.export.page_metadata_in_frontmatter = False\n            result = converter.front_matter\n        assert \"confluence_page_id\" not in result\n        assert \"confluence_space_key\" not in result\n        assert \"confluence_type\" not in result\n        assert \"confluence_created\" not in result\n        assert \"confluence_created_by\" not in result\n        assert \"confluence_last_modified\" not in result\n        assert \"confluence_last_modified_by\" not in result\n        assert \"confluence_version\" not in result\n\n    def test_enabled_writes_all_eight_keys(self) -> None:\n        converter = self._converter()\n        with patch(\"confluence_markdown_exporter.confluence.settings\") as s:\n            s.export.confluence_url_in_frontmatter = \"none\"\n            s.export.page_metadata_in_frontmatter = True\n            result = converter.front_matter\n        assert \"confluence_page_id: '123'\" in result\n        assert \"confluence_space_key: TEAM\" in result\n        assert \"confluence_type: page\" in result\n        assert \"confluence_created: '2024-08-15T08:34:12.000+02:00'\" in result\n        assert \"confluence_created_by: Sam Creator\" in result\n        assert \"confluence_last_modified: '2026-04-12T10:34:00.000+02:00'\" in result\n        assert \"confluence_last_modified_by: Alex Johnson\" in result\n        assert \"confluence_version: 7\" in result\n        assert \"confluence_version: '7'\" not in result\n\n    def test_blogpost_type_renders(self) -> None:\n        converter = self._converter(page_type=\"blogpost\")\n        with patch(\"confluence_markdown_exporter.confluence.settings\") as s:\n            s.export.confluence_url_in_frontmatter = \"none\"\n            s.export.page_metadata_in_frontmatter = True\n            result = converter.front_matter\n        assert \"confluence_type: blogpost\" in result\n\n    def test_macro_precedence_for_page_id(self) -> None:\n        converter = self._converter()\n        converter.page_properties[\"confluence_page_id\"] = \"macro-override\"\n        with patch(\"confluence_markdown_exporter.confluence.settings\") as s:\n            s.export.confluence_url_in_frontmatter = \"none\"\n            s.export.page_metadata_in_frontmatter = True\n            result = converter.front_matter\n        assert \"confluence_page_id: macro-override\" in result\n        assert \"confluence_page_id: '123'\" not in result\n\n    @pytest.mark.parametrize(\n        (\"key\", \"macro_value\", \"api_substring\"),\n        [\n            (\"confluence_type\", \"macro-type\", \"confluence_type: page\"),\n            (\"confluence_created\", \"macro-created\", \"2024-08-15T08:34:12.000+02:00\"),\n            (\"confluence_created_by\", \"macro-author\", \"Sam Creator\"),\n        ],\n    )\n    def test_macro_precedence_for_history_fields(\n        self, key: str, macro_value: str, api_substring: str\n    ) -> None:\n        converter = self._converter()\n        converter.page_properties[key] = macro_value\n        with patch(\"confluence_markdown_exporter.confluence.settings\") as s:\n            s.export.confluence_url_in_frontmatter = \"none\"\n            s.export.page_metadata_in_frontmatter = True\n            result = converter.front_matter\n        assert f\"{key}: {macro_value}\" in result\n        assert api_substring not in result\n\n    def test_empty_display_name_skipped(self) -> None:\n        converter = self._converter(display_name=\"\")\n        with patch(\"confluence_markdown_exporter.confluence.settings\") as s:\n            s.export.confluence_url_in_frontmatter = \"none\"\n            s.export.page_metadata_in_frontmatter = True\n            result = converter.front_matter\n        assert \"confluence_last_modified_by\" not in result\n        assert \"confluence_page_id: '123'\" in result\n        assert \"confluence_space_key: TEAM\" in result\n        assert \"confluence_last_modified\" in result\n        assert \"confluence_version: 7\" in result\n\n    def test_empty_creator_skipped(self) -> None:\n        converter = self._converter(created_by=\"\")\n        with patch(\"confluence_markdown_exporter.confluence.settings\") as s:\n            s.export.confluence_url_in_frontmatter = \"none\"\n            s.export.page_metadata_in_frontmatter = True\n            result = converter.front_matter\n        assert \"confluence_created_by\" not in result\n        assert \"confluence_created: '2024-08-15T08:34:12.000+02:00'\" in result\n        assert \"confluence_type: page\" in result\n\n    def test_empty_type_skipped(self) -> None:\n        converter = self._converter(page_type=\"\")\n        with patch(\"confluence_markdown_exporter.confluence.settings\") as s:\n            s.export.confluence_url_in_frontmatter = \"none\"\n            s.export.page_metadata_in_frontmatter = True\n            result = converter.front_matter\n        assert \"confluence_type\" not in result\n        assert \"confluence_page_id: '123'\" in result\n        assert \"confluence_created_by: Sam Creator\" in result\n\n\nclass TestInlineCommentsFrontMatter:\n    \"\"\"Pin the YAML front matter keys written into *.comments.md sidecars.\"\"\"\n\n    def test_front_matter_uses_confluence_prefix(self) -> None:\n        page = MockPage()\n        page.id = 123\n        page.title = \"My Page\"\n        page.space = MagicMock()\n        page.space.key = \"TEAM\"\n        page.base_url = \"https://example.atlassian.net\"\n        page.export_path = Path(\"TEAM/My Page.md\")\n        page._marked_texts = {\"ref-1\": \"marked excerpt\"}\n        page._COMMENT_TITLE_MAX_LEN = Page._COMMENT_TITLE_MAX_LEN.default\n        page._fetch_inline_comments = lambda: [\n            {\n                \"id\": \"c1\",\n                \"extensions\": {\"inlineProperties\": {\"markerRef\": \"ref-1\"}},\n                \"history\": {\n                    \"createdBy\": {\"displayName\": \"Alice\"},\n                    \"createdDate\": \"2026-04-01T10:00:00Z\",\n                },\n                \"body\": {\"view\": {\"value\": \"<p>nice</p>\"}},\n            }\n        ]\n        page._fetch_page_comments = list\n        page._fetch_comment_replies = lambda _cid: []\n        page._render_inline_comments = types.MethodType(Page._render_inline_comments, page)\n        page._render_page_comments = types.MethodType(Page._render_page_comments, page)\n\n        with (\n            patch(\"confluence_markdown_exporter.confluence.save_file\") as mock_save,\n            patch(\"confluence_markdown_exporter.confluence.settings\") as s,\n        ):\n            s.export.output_path = Path(\"out\")\n            s.export.comments_export = \"inline\"\n            Page.export_comments_sidecar(page)\n\n        assert mock_save.called\n        content = mock_save.call_args[0][1]\n\n        # New keys with correct YAML form\n        assert \"confluence_page_id: '123'\" in content\n        assert 'confluence_page_title: \"My Page\"' in content\n        assert (\n            'confluence_webui_url: \"https://example.atlassian.net'\n            '/wiki/spaces/TEAM/pages/123\"' in content\n        )\n\n        # Regression guard: old keys must not reappear\n        assert \"\\npage_id:\" not in content\n        assert \"\\npage_title:\" not in content\n        assert \"\\nsource:\" not in content\n\n\ndef _make_comments_page(\n    *,\n    inline_comments: list[dict] | None = None,\n    page_comments: list[dict] | None = None,\n    replies: dict[str, list[dict]] | None = None,\n    marked_texts: dict[str, str] | None = None,\n) -> MockPage:\n    page = MockPage()\n    page.id = 123\n    page.title = \"My Page\"\n    page.space = MagicMock()\n    page.space.key = \"TEAM\"\n    page.base_url = \"https://example.atlassian.net\"\n    page.export_path = Path(\"TEAM/My Page.md\")\n    page._marked_texts = marked_texts or {}\n    page._COMMENT_TITLE_MAX_LEN = Page._COMMENT_TITLE_MAX_LEN.default\n    page._fetch_inline_comments = lambda: list(inline_comments or [])\n    page._fetch_page_comments = lambda: list(page_comments or [])\n    replies_map = replies or {}\n    page._fetch_comment_replies = lambda cid: list(replies_map.get(cid, []))\n    page._render_inline_comments = types.MethodType(Page._render_inline_comments, page)\n    page._render_page_comments = types.MethodType(Page._render_page_comments, page)\n    return page\n\n\ndef _run_export_capturing_save(page: MockPage, mode: str) -> MagicMock:\n    with (\n        patch(\"confluence_markdown_exporter.confluence.save_file\") as mock_save,\n        patch(\"confluence_markdown_exporter.confluence.settings\") as s,\n    ):\n        s.export.output_path = Path(\"out\")\n        s.export.comments_export = mode\n        Page.export_comments_sidecar(page)\n    return mock_save\n\n\ndef _inline_comment(\n    ref: str = \"ref-1\",\n    body: str = \"<p>nice</p>\",\n    cid: str = \"c1\",\n    author: str = \"Alice\",\n) -> dict:\n    return {\n        \"id\": cid,\n        \"extensions\": {\"inlineProperties\": {\"markerRef\": ref}},\n        \"history\": {\n            \"createdBy\": {\"displayName\": author},\n            \"createdDate\": \"2026-04-01T10:00:00Z\",\n        },\n        \"body\": {\"view\": {\"value\": body}},\n    }\n\n\ndef _page_comment(\n    cid: str = \"p1\",\n    body: str = \"<p>discussion body</p>\",\n    author: str = \"Bob\",\n    *,\n    resolved: bool = False,\n) -> dict:\n    return {\n        \"id\": cid,\n        \"extensions\": {\"resolution\": {\"status\": \"resolved\" if resolved else \"open\"}},\n        \"history\": {\n            \"createdBy\": {\"displayName\": author},\n            \"createdDate\": \"2026-04-02T11:00:00Z\",\n        },\n        \"body\": {\"view\": {\"value\": body}},\n    }\n\n\nclass TestPageCommentsSidecarBody:\n    \"\"\"Sidecar rendering for page-level (footer) and combined comments.\"\"\"\n\n    def test_only_footer_writes_only_page_section(self) -> None:\n        page = _make_comments_page(page_comments=[_page_comment()])\n        save = _run_export_capturing_save(page, \"footer\")\n        assert save.called\n        content = save.call_args[0][1]\n        assert \"## Page comments\" in content\n        assert \"## Inline comments\" not in content\n        assert \"discussion body\" in content\n        assert \"**Bob** · 2026-04-02\" in content\n\n    def test_all_writes_both_sections_inline_first(self) -> None:\n        page = _make_comments_page(\n            inline_comments=[_inline_comment()],\n            page_comments=[_page_comment()],\n            marked_texts={\"ref-1\": \"marked excerpt\"},\n        )\n        save = _run_export_capturing_save(page, \"all\")\n        assert save.called\n        content = save.call_args[0][1]\n        assert \"## Inline comments\" in content\n        assert \"## Page comments\" in content\n        assert content.index(\"## Inline comments\") < content.index(\"## Page comments\")\n\n    def test_none_writes_no_file(self) -> None:\n        page = _make_comments_page(\n            inline_comments=[_inline_comment()],\n            page_comments=[_page_comment()],\n        )\n        save = _run_export_capturing_save(page, \"none\")\n        assert save.called is False\n\n    def test_inline_only_omits_page_section(self) -> None:\n        page = _make_comments_page(\n            inline_comments=[_inline_comment()],\n            page_comments=[_page_comment()],\n            marked_texts={\"ref-1\": \"marked excerpt\"},\n        )\n        save = _run_export_capturing_save(page, \"inline\")\n        assert save.called\n        content = save.call_args[0][1]\n        assert \"## Inline comments\" in content\n        assert \"## Page comments\" not in content\n\n    def test_page_comment_title_falls_back_to_comment_id(self) -> None:\n        page = _make_comments_page(\n            page_comments=[_page_comment(cid=\"abcdef1234567\", body=\"\")],\n        )\n        save = _run_export_capturing_save(page, \"footer\")\n        assert save.called\n        content = save.call_args[0][1]\n        assert \"### Comment abcdef12\" in content\n\n    def test_page_comment_replies_render_under_parent(self) -> None:\n        replies = {\n            \"p1\": [\n                {\n                    \"id\": \"r1\",\n                    \"history\": {\n                        \"createdBy\": {\"displayName\": \"Carol\"},\n                        \"createdDate\": \"2026-04-03T11:00:00Z\",\n                    },\n                    \"body\": {\"view\": {\"value\": \"<p>reply one</p>\"}},\n                },\n                {\n                    \"id\": \"r2\",\n                    \"history\": {\n                        \"createdBy\": {\"displayName\": \"Dave\"},\n                        \"createdDate\": \"2026-04-03T12:00:00Z\",\n                    },\n                    \"body\": {\"view\": {\"value\": \"<p>reply two</p>\"}},\n                },\n            ]\n        }\n        page = _make_comments_page(\n            page_comments=[_page_comment(cid=\"p1\", body=\"<p>parent body</p>\", author=\"Bob\")],\n            replies=replies,\n        )\n        save = _run_export_capturing_save(page, \"footer\")\n        assert save.called\n        content = save.call_args[0][1]\n        assert content.index(\"Bob\") < content.index(\"Carol\") < content.index(\"Dave\")\n        assert \"reply one\" in content\n        assert \"reply two\" in content\n\n    def test_fetch_page_comments_filters_resolved(self) -> None:\n        page = MockPage()\n        page.id = 123\n        page.base_url = \"https://example.atlassian.net\"\n\n        client = MagicMock()\n        client.get_page_comments.return_value = {\n            \"results\": [\n                _page_comment(cid=\"open1\", body=\"<p>open one</p>\"),\n                _page_comment(cid=\"resolved1\", body=\"<p>resolved one</p>\", resolved=True),\n                _page_comment(cid=\"open2\", body=\"<p>open two</p>\"),\n            ],\n            \"_links\": {},\n        }\n\n        with patch(\n            \"confluence_markdown_exporter.confluence.get_thread_confluence\",\n            return_value=client,\n        ):\n            results = Page._fetch_page_comments(page)\n\n        ids = [c[\"id\"] for c in results]\n        assert ids == [\"open1\", \"open2\"]\n\n\nclass TestPagePropertiesReportDataview:\n    \"\"\"Page Properties Report macro can be exported as a Dataview DQL query.\"\"\"\n\n    _REPORT_HTML = (\n        '<table class=\"aui metadata-summary-macro null\"'\n        ' data-cql=\\'label = \"tool-validation\" and parent = \"42\"\\''\n        ' data-current-content-id=\"42\"'\n        ' data-current-space-key=\"TS\"'\n        ' data-first-column-heading=\"Title\"'\n        ' data-headings=\"Tool Version,Approved for Use\"'\n        ' data-sort-by=\"Title\"'\n        ' data-reverse-sort=\"false\">'\n        \"</table>\"\n    )\n\n    _BODY_EXPORT = (\n        '<table class=\"aui metadata-summary-macro null\"'\n        ' data-cql=\\'label = \"tool-validation\" and parent = \"42\"\\''\n        \">\"\n        \"<tr><th>Title</th><th>Tool Version</th><th>Approved for Use</th></tr>\"\n        \"<tr><td>Page A</td><td>1.0</td><td>Yes</td></tr>\"\n        \"</table>\"\n    )\n\n    class _MockPageWithExport:\n        def __init__(self, body_export: str = \"\") -> None:\n            from pathlib import Path\n\n            self.id = 42\n            self.title = \"Test Page\"\n            self.html = \"\"\n            self.labels: list = []\n            self.ancestors: list = []\n            self.body_export = body_export\n            self.export_path = Path(\"Test Space/Test Page/Test Page.md\")\n\n        def get_attachment_by_file_id(self, file_id: str) -> None:\n            return None\n\n    def test_dataview_output_contains_table_clause(self) -> None:\n        page = self._MockPageWithExport(body_export=self._BODY_EXPORT)\n        converter = Page.Converter(page)\n        with patch(\"confluence_markdown_exporter.confluence.settings\") as s:\n            s.export.page_properties_report_format = \"dataview\"\n            result = converter.convert(self._REPORT_HTML)\n        assert \"```dataview\" in result\n        expected_cols = 'tool_version AS \"Tool Version\", approved_for_use AS \"Approved for Use\"'\n        assert f\"TABLE {expected_cols}\" in result\n\n    def test_dataview_output_contains_from_clause(self) -> None:\n        page = self._MockPageWithExport(body_export=self._BODY_EXPORT)\n        converter = Page.Converter(page)\n        with patch(\"confluence_markdown_exporter.confluence.settings\") as s:\n            s.export.page_properties_report_format = \"dataview\"\n            result = converter.convert(self._REPORT_HTML)\n        assert 'FROM \"Test Space/Test Page\"' in result\n\n    def test_dataview_from_clause_with_current_content_ancestor(self) -> None:\n        html = (\n            '<table class=\"aui metadata-summary-macro null\"'\n            \" data-cql='label = \\\"tool\\\" and ancestor = currentContent()'\"\n            ' data-current-content-id=\"99\"'\n            ' data-current-space-key=\"TS\"'\n            ' data-first-column-heading=\"Name\"'\n            ' data-headings=\"Vendor\"'\n            ' data-sort-by=\"Name\"'\n            ' data-reverse-sort=\"false\">'\n            \"</table>\"\n        )\n        page = self._MockPageWithExport(body_export=\"\")\n        converter = Page.Converter(page)\n        with patch(\"confluence_markdown_exporter.confluence.settings\") as s:\n            s.export.page_properties_report_format = \"dataview\"\n            result = converter.convert(html)\n        assert 'FROM \"Test Space/Test Page\"' in result\n\n    def test_dataview_output_contains_label_in_from_clause(self) -> None:\n        page = self._MockPageWithExport(body_export=self._BODY_EXPORT)\n        converter = Page.Converter(page)\n        with patch(\"confluence_markdown_exporter.confluence.settings\") as s:\n            s.export.page_properties_report_format = \"dataview\"\n            result = converter.convert(self._REPORT_HTML)\n        assert \"#tool-validation\" in result\n\n    def test_dataview_output_contains_sort_clause(self) -> None:\n        page = self._MockPageWithExport(body_export=self._BODY_EXPORT)\n        converter = Page.Converter(page)\n        with patch(\"confluence_markdown_exporter.confluence.settings\") as s:\n            s.export.page_properties_report_format = \"dataview\"\n            result = converter.convert(self._REPORT_HTML)\n        assert \"SORT title ASC\" in result\n\n    def test_frozen_table_when_format_is_frozen(self) -> None:\n        page = self._MockPageWithExport(body_export=self._BODY_EXPORT)\n        converter = Page.Converter(page)\n        with patch(\"confluence_markdown_exporter.confluence.settings\") as s:\n            s.export.page_properties_report_format = \"frozen\"\n            result = converter.convert(self._REPORT_HTML)\n        assert \"```dataview\" not in result\n        assert \"Page A\" in result\n\n\nclass TestAttachmentTemplateVars:\n    \"\"\"`attachment_file_id` falls back to the content id when fileId is empty.\"\"\"\n\n    def test_cloud_style_keeps_file_id(self) -> None:\n        \"\"\"Cloud attachments expose the GUID fileId verbatim.\"\"\"\n        attachment = _make_attachment(\"content-456\", \"cloud-guid-123\")\n        assert attachment._template_vars[\"attachment_file_id\"] == \"cloud-guid-123\"\n\n    def test_dc_style_falls_back_to_content_id(self) -> None:\n        \"\"\"Data Center / Server attachments fall back to the content id.\"\"\"\n        attachment = _make_attachment(\"content-456\", \"\")\n        assert attachment._template_vars[\"attachment_file_id\"] == \"content-456\"\n\n    def test_two_dc_attachments_get_distinct_paths(self) -> None:\n        \"\"\"Two DC attachments with the same extension must not collide.\"\"\"\n        att1 = _make_attachment(\"123\", \"\")\n        att2 = _make_attachment(\"124\", \"\")\n\n        with patch(\"confluence_markdown_exporter.confluence.settings\") as mock_settings:\n            mock_settings.export.attachment_path = (\n                \"{space_name}/attachments/{attachment_file_id}{attachment_extension}\"\n            )\n            path1 = att1.export_path\n            path2 = att2.export_path\n\n        assert path1 != path2\n\n\nclass TestWikiLinkDisambiguation:\n    \"\"\"Wiki page links use a vault-relative path when titles collide across spaces.\"\"\"\n\n    def _make_target_page(self, page_id: int, title: str, space_key: str) -> Page:\n        space = Space(\n            base_url=\"https://example.com\",\n            key=space_key,\n            name=space_key,\n            description=\"\",\n            homepage=0,\n        )\n        version = Version(\n            number=1,\n            by=User(\n                account_id=\"u1\",\n                display_name=\"User\",\n                username=\"user\",\n                public_name=\"\",\n                email=\"\",\n            ),\n            when=\"2024-01-01T00:00:00Z\",\n            friendly_when=\"Jan 1\",\n        )\n        return Page(\n            base_url=\"https://example.com\",\n            id=page_id,\n            title=title,\n            space=space,\n            ancestors=[],\n            version=version,\n            body=\"\",\n            body_export=\"\",\n            editor2=\"\",\n            body_storage=\"\",\n            labels=[],\n            attachments=[],\n        )\n\n    def test_unique_title_emits_short_wiki_link(self) -> None:\n        from confluence_markdown_exporter.utils.page_registry import PageTitleRegistry\n\n        PageTitleRegistry.reset()\n        target = self._make_target_page(101, \"Unique Page\", \"ALPHA\")\n        PageTitleRegistry.register(target.id, target.title)\n\n        source = _make_page(body=\"\", body_export=\"\", attachments=[])\n\n        with (\n            patch(\"confluence_markdown_exporter.confluence.Page.from_id\", return_value=target),\n            patch(\"confluence_markdown_exporter.confluence.settings\") as s,\n        ):\n            s.export.page_href = \"wiki\"\n            s.export.page_path = \"{space_name}/{page_title}.md\"\n            conv = Page.Converter(source)\n            html = '<a data-linked-resource-type=\"page\" data-linked-resource-id=\"101\">x</a>'\n            result = conv.convert(html).strip()\n\n        PageTitleRegistry.reset()\n        assert result == \"[[Unique Page]]\"\n\n    def test_colliding_title_emits_path_qualified_wiki_link(self) -> None:\n        from confluence_markdown_exporter.utils.page_registry import PageTitleRegistry\n\n        PageTitleRegistry.reset()\n        target_alpha = self._make_target_page(201, \"Shared Title\", \"ALPHA\")\n        target_beta = self._make_target_page(202, \"Shared Title\", \"BETA\")\n        PageTitleRegistry.register(target_alpha.id, target_alpha.title)\n        PageTitleRegistry.register(target_beta.id, target_beta.title)\n\n        source = _make_page(body=\"\", body_export=\"\", attachments=[])\n\n        with (\n            patch(\n                \"confluence_markdown_exporter.confluence.Page.from_id\",\n                return_value=target_alpha,\n            ),\n            patch(\"confluence_markdown_exporter.confluence.settings\") as s,\n        ):\n            s.export.page_href = \"wiki\"\n            s.export.page_path = \"{space_name}/{page_title}.md\"\n            conv = Page.Converter(source)\n            html = '<a data-linked-resource-type=\"page\" data-linked-resource-id=\"201\">x</a>'\n            result = conv.convert(html).strip()\n\n        PageTitleRegistry.reset()\n        assert result == \"[[ALPHA/Shared Title|Shared Title]]\"\n\n    def test_relative_link_unaffected(self) -> None:\n        from confluence_markdown_exporter.utils.page_registry import PageTitleRegistry\n\n        PageTitleRegistry.reset()\n        target_alpha = self._make_target_page(201, \"Shared Title\", \"ALPHA\")\n        target_beta = self._make_target_page(202, \"Shared Title\", \"BETA\")\n        PageTitleRegistry.register(target_alpha.id, target_alpha.title)\n        PageTitleRegistry.register(target_beta.id, target_beta.title)\n\n        source = _make_page(body=\"\", body_export=\"\", attachments=[])\n\n        with (\n            patch(\n                \"confluence_markdown_exporter.confluence.Page.from_id\",\n                return_value=target_alpha,\n            ),\n            patch(\"confluence_markdown_exporter.confluence.settings\") as s,\n        ):\n            s.export.page_href = \"relative\"\n            s.export.page_path = \"{space_name}/{page_title}.md\"\n            conv = Page.Converter(source)\n            html = '<a data-linked-resource-type=\"page\" data-linked-resource-id=\"201\">x</a>'\n            result = conv.convert(html).strip()\n\n        PageTitleRegistry.reset()\n        assert \"Shared%20Title.md\" in result\n        assert result.startswith(\"[Shared Title](\")\n\n\nclass TestAbsoluteUrlPageLinks:\n    \"\"\"Absolute Confluence URLs in href must resolve to page links, not pass through.\"\"\"\n\n    def _make_target_page(self, page_id: int, title: str, space_key: str) -> Page:\n        space = Space(\n            base_url=\"https://example.com\",\n            key=space_key,\n            name=space_key,\n            description=\"\",\n            homepage=0,\n        )\n        version = Version(\n            number=1,\n            by=User(\n                account_id=\"u1\",\n                display_name=\"User\",\n                username=\"user\",\n                public_name=\"\",\n                email=\"\",\n            ),\n            when=\"2024-01-01T00:00:00Z\",\n            friendly_when=\"Jan 1\",\n        )\n        return Page(\n            base_url=\"https://example.com\",\n            id=page_id,\n            title=title,\n            space=space,\n            ancestors=[],\n            version=version,\n            body=\"\",\n            body_export=\"\",\n            editor2=\"\",\n            body_storage=\"\",\n            labels=[],\n            attachments=[],\n        )\n\n    def test_absolute_url_same_host_resolves_page(self) -> None:\n        from confluence_markdown_exporter.utils.page_registry import PageTitleRegistry\n\n        PageTitleRegistry.reset()\n        target = self._make_target_page(1437663233, \"Linked Page\", \"STRUCT\")\n\n        source = _make_page(body=\"\", body_export=\"\", attachments=[])\n\n        with (\n            patch(\n                \"confluence_markdown_exporter.confluence.Page.from_id\",\n                return_value=target,\n            ),\n            patch(\"confluence_markdown_exporter.confluence.settings\") as s,\n        ):\n            s.export.page_href = \"wiki\"\n            s.export.page_path = \"{space_name}/{page_title}.md\"\n            conv = Page.Converter(source)\n            html = (\n                '<a href=\"https://example.com/wiki/spaces/STRUCT/pages/1437663233\">'\n                \"https://example.com/wiki/spaces/STRUCT/pages/1437663233</a>\"\n            )\n            result = conv.convert(html).strip()\n\n        PageTitleRegistry.reset()\n        assert result == \"[[Linked Page]]\"\n\n    def test_absolute_url_different_host_left_alone(self) -> None:\n        source = _make_page(body=\"\", body_export=\"\", attachments=[])\n        conv = Page.Converter(source)\n        html = (\n            '<a href=\"https://other.atlassian.net/wiki/spaces/X/pages/9/T\">'\n            \"https://other.atlassian.net/wiki/spaces/X/pages/9/T</a>\"\n        )\n        result = conv.convert(html).strip()\n        assert result == \"<https://other.atlassian.net/wiki/spaces/X/pages/9/T>\"\n\n    def test_legacy_pageid_query_resolves_page(self) -> None:\n        from confluence_markdown_exporter.utils.page_registry import PageTitleRegistry\n\n        PageTitleRegistry.reset()\n        target = self._make_target_page(555, \"Legacy Page\", \"OLD\")\n\n        source = _make_page(body=\"\", body_export=\"\", attachments=[])\n\n        with (\n            patch(\n                \"confluence_markdown_exporter.confluence.Page.from_id\",\n                return_value=target,\n            ),\n            patch(\"confluence_markdown_exporter.confluence.settings\") as s,\n        ):\n            s.export.page_href = \"wiki\"\n            s.export.page_path = \"{space_name}/{page_title}.md\"\n            conv = Page.Converter(source)\n            html = '<a href=\"https://example.com/pages/viewpage.action?pageId=555\">x</a>'\n            result = conv.convert(html).strip()\n\n        PageTitleRegistry.reset()\n        assert result == \"[[Legacy Page]]\"\n"
  },
  {
    "path": "tests/unit/test_emoticon_conversion.py",
    "content": "\"\"\"Test that Confluence emoticon img tags are converted to unicode emoji.\"\"\"\n\nfrom __future__ import annotations\n\nfrom typing import TYPE_CHECKING\n\nimport pytest\n\nif TYPE_CHECKING:\n    from confluence_markdown_exporter.confluence import Page\n\n\n@pytest.fixture\ndef converter() -> Page.Converter:\n    from confluence_markdown_exporter.confluence import Page\n\n    class MockPage:\n        def __init__(self) -> None:\n            self.id = \"test-page\"\n            self.title = \"Test Page\"\n            self.html = \"\"\n            self.labels = []\n            self.ancestors = []\n\n        def get_attachment_by_file_id(self, file_id: str) -> None:\n            return None\n\n    return Page.Converter(MockPage())\n\n\nclass TestEmoticonConversion:\n    def test_atlassian_check_mark(self, converter: Page.Converter) -> None:\n        html = (\n            '<img class=\"emoticon emoticon-tick\"'\n            ' data-emoji-id=\"atlassian-check_mark\"'\n            ' data-emoji-fallback=\":check_mark:\"'\n            ' data-emoji-shortname=\":check_mark:\"'\n            ' alt=\"(tick)\" />'\n        )\n        assert converter.convert(html).strip() == \"✅\"\n\n    def test_atlassian_cross_mark(self, converter: Page.Converter) -> None:\n        html = (\n            '<img class=\"emoticon emoticon-cross\"'\n            ' data-emoji-id=\"atlassian-cross_mark\"'\n            ' data-emoji-fallback=\":cross_mark:\"'\n            ' data-emoji-shortname=\":cross_mark:\"'\n            ' alt=\"(error)\" />'\n        )\n        assert converter.convert(html).strip() == \"❌\"\n\n    def test_unicode_emoji_by_hex_id(self, converter: Page.Converter) -> None:\n        html = (\n            '<img class=\"emoticon emoticon-blue-star\"'\n            ' data-emoji-id=\"1f6e0\"'\n            ' data-emoji-fallback=\"\\U0001f6e0️\"'\n            ' data-emoji-shortname=\":tools:\"'\n            ' alt=\"(blue star)\" />'\n        )\n        assert converter.convert(html).strip() == \"\\U0001f6e0️\"\n\n    def test_unicode_emoji_fallback_direct(self, converter: Page.Converter) -> None:\n        html = (\n            '<img class=\"emoticon\"'\n            ' data-emoji-id=\"1f600\"'\n            ' data-emoji-fallback=\"\\U0001f600\"'\n            ' alt=\"smile\" />'\n        )\n        assert converter.convert(html).strip() == \"\\U0001f600\"\n\n    def test_custom_emoji_uuid_falls_back_to_shortname(self, converter: Page.Converter) -> None:\n        html = (\n            '<img class=\"emoticon emoticon-blue-star\"'\n            ' data-emoji-id=\"fb5b359f-23fa-44bd-872b-676e207eaaef\"'\n            ' data-emoji-fallback=\":alert-1:\"'\n            ' data-emoji-shortname=\":alert-1:\"'\n            ' alt=\"(blue star)\" />'\n        )\n        assert converter.convert(html).strip() == \":alert-1:\"\n\n    def test_non_emoticon_img_unchanged(self, converter: Page.Converter) -> None:\n        html = '<img src=\"http://example.com/image.png\" alt=\"photo\" />'\n        result = converter.convert(html).strip()\n        assert \"emoticon\" not in result\n        assert \"example.com\" in result\n\n    def test_emoticon_inline_in_text(self, converter: Page.Converter) -> None:\n        html = (\n            'Status: <img class=\"emoticon emoticon-tick\"'\n            ' data-emoji-id=\"atlassian-check_mark\"'\n            ' data-emoji-fallback=\":check_mark:\"'\n            ' alt=\"(tick)\" /> Done'\n        )\n        result = converter.convert(html).strip()\n        assert \"✅\" in result\n        assert \"Done\" in result\n"
  },
  {
    "path": "tests/unit/test_include_macro_conversion.py",
    "content": "\"\"\"Unit tests for `include` / `excerpt-include` macro conversion.\"\"\"\n\nfrom unittest.mock import MagicMock\nfrom unittest.mock import patch\n\nfrom bs4 import BeautifulSoup\n\nfrom confluence_markdown_exporter.confluence import Page\n\n\ndef _make_page(editor2: str) -> MagicMock:\n    page = MagicMock(spec=Page)\n    page.id = 12345\n    page.title = \"Test Page\"\n    page.html = \"<h1>Test Page</h1>\"\n    page.labels = []\n    page.ancestors = []\n    page.attachments = []\n    page.editor2 = editor2\n    return page\n\n\nINCLUDE_EDITOR2 = \"\"\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<ac:structured-macro ac:name=\"include\" ac:schema-version=\"1\"\n    ac:local-id=\"local-1\" ac:macro-id=\"macro-include-1\">\n    <ac:parameter ac:name=\"\">\n        <ac:link><ri:page ri:content-title=\"Shared Reference Page\"\n            ri:version-at-save=\"1\" /></ac:link>\n    </ac:parameter>\n</ac:structured-macro>\"\"\"\n\nEXCERPT_INCLUDE_EDITOR2 = \"\"\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<ac:structured-macro ac:name=\"excerpt-include\" ac:schema-version=\"1\"\n    ac:local-id=\"local-2\" ac:macro-id=\"macro-excerpt-1\">\n    <ac:parameter ac:name=\"\">\n        <ac:link><ri:page ri:content-title=\"Source Page\" /></ac:link>\n    </ac:parameter>\n    <ac:parameter ac:name=\"name\">Named Excerpt</ac:parameter>\n</ac:structured-macro>\"\"\"\n\n\n@patch(\"confluence_markdown_exporter.confluence.settings\")\ndef test_include_macro_transclusion_mode(mock_settings: MagicMock) -> None:\n    mock_settings.export.include_document_title = False\n    mock_settings.export.page_breadcrumbs = False\n    mock_settings.export.include_macro = \"transclusion\"\n\n    converter = Page.Converter(_make_page(INCLUDE_EDITOR2))\n\n    html = (\n        '<div data-macro-name=\"include\" data-macro-id=\"macro-include-1\">'\n        \"<p>fallback inline text</p></div>\"\n    )\n    el = BeautifulSoup(html, \"html.parser\").find(\"div\")\n\n    result = converter.convert_include(el, \"fallback inline text\", [])\n\n    assert result.strip() == \"![[Shared Reference Page]]\"\n\n\n@patch(\"confluence_markdown_exporter.confluence.settings\")\ndef test_excerpt_include_macro_transclusion_mode(mock_settings: MagicMock) -> None:\n    mock_settings.export.include_document_title = False\n    mock_settings.export.page_breadcrumbs = False\n    mock_settings.export.include_macro = \"transclusion\"\n\n    converter = Page.Converter(_make_page(EXCERPT_INCLUDE_EDITOR2))\n\n    html = (\n        '<div data-macro-name=\"excerpt-include\" data-macro-id=\"macro-excerpt-1\">'\n        \"<p>resolved excerpt body</p></div>\"\n    )\n    el = BeautifulSoup(html, \"html.parser\").find(\"div\")\n\n    result = converter.convert_include(el, \"resolved excerpt body\", [])\n\n    assert result.strip() == \"![[Source Page]]\"\n\n\n@patch(\"confluence_markdown_exporter.confluence.settings\")\ndef test_include_macro_inline_mode(mock_settings: MagicMock) -> None:\n    mock_settings.export.include_document_title = False\n    mock_settings.export.page_breadcrumbs = False\n    mock_settings.export.include_macro = \"inline\"\n\n    converter = Page.Converter(_make_page(INCLUDE_EDITOR2))\n\n    html = (\n        '<div data-macro-name=\"include\" data-macro-id=\"macro-include-1\">'\n        \"<p>inlined content</p></div>\"\n    )\n    el = BeautifulSoup(html, \"html.parser\").find(\"div\")\n\n    result = converter.convert_include(el, \"inlined content\", [])\n\n    assert \"![[\" not in result\n\n\n@patch(\"confluence_markdown_exporter.confluence.settings\")\ndef test_excerpt_include_inline_strips_source_page_title_panel(\n    mock_settings: MagicMock,\n) -> None:\n    mock_settings.export.include_document_title = False\n    mock_settings.export.page_breadcrumbs = False\n    mock_settings.export.include_macro = \"inline\"\n\n    converter = Page.Converter(_make_page(EXCERPT_INCLUDE_EDITOR2))\n\n    html = (\n        '<div class=\"panel conf-macro output-inline\" data-macro-name=\"excerpt-include\"'\n        ' data-macro-id=\"macro-excerpt-1\">'\n        '<div class=\"panelHeader\"><b>Source Page</b></div>'\n        '<div class=\"panelContent\"><table><tr><td>body cell</td></tr></table></div>'\n        \"</div>\"\n    )\n\n    stripped = converter._strip_excerpt_include_panel_titles(html)\n\n    assert \"Source Page\" not in stripped\n    assert \"panelHeader\" not in stripped\n    assert \"panelContent\" not in stripped\n    assert \"body cell\" in stripped\n\n\n@patch(\"confluence_markdown_exporter.confluence.settings\")\ndef test_excerpt_include_inline_keeps_body_when_no_panel(\n    mock_settings: MagicMock,\n) -> None:\n    mock_settings.export.include_document_title = False\n    mock_settings.export.page_breadcrumbs = False\n    mock_settings.export.include_macro = \"inline\"\n\n    converter = Page.Converter(_make_page(EXCERPT_INCLUDE_EDITOR2))\n\n    html = (\n        '<span class=\"conf-macro output-inline\" data-macro-name=\"excerpt-include\"'\n        ' data-macro-id=\"macro-excerpt-1\">actual excerpt body</span>'\n    )\n\n    stripped = converter._strip_excerpt_include_panel_titles(html)\n\n    assert \"actual excerpt body\" in stripped\n\n\n@patch(\"confluence_markdown_exporter.confluence.settings\")\ndef test_include_macro_transclusion_falls_back_when_target_unresolvable(\n    mock_settings: MagicMock,\n) -> None:\n    mock_settings.export.include_document_title = False\n    mock_settings.export.page_breadcrumbs = False\n    mock_settings.export.include_macro = \"transclusion\"\n\n    # editor2 has a different macro-id → lookup fails\n    converter = Page.Converter(_make_page(INCLUDE_EDITOR2))\n\n    html = '<div data-macro-name=\"include\" data-macro-id=\"missing-id\"><p>inlined content</p></div>'\n    el = BeautifulSoup(html, \"html.parser\").find(\"div\")\n\n    result = converter.convert_include(el, \"inlined content\", [])\n\n    assert \"![[\" not in result\n"
  },
  {
    "path": "tests/unit/test_main.py",
    "content": "\"\"\"Unit tests for main module.\"\"\"\n\nimport pytest\nimport typer\n\nfrom confluence_markdown_exporter.main import app\nfrom confluence_markdown_exporter.main import version\n\n\nclass TestVersionCommand:\n    \"\"\"Test cases for version command.\"\"\"\n\n    def test_version_output(self, capsys: pytest.CaptureFixture[str]) -> None:\n        \"\"\"Test that version command outputs correct format.\"\"\"\n        version()\n\n        captured = capsys.readouterr()\n        assert \"confluence-markdown-exporter\" in captured.out\n        # Should contain version information\n        assert len(captured.out.strip()) > len(\"confluence-markdown-exporter\")\n\n\nclass TestAppConfiguration:\n    \"\"\"Test cases for the Typer app configuration.\"\"\"\n\n    def test_app_is_typer_instance(self) -> None:\n        \"\"\"Test that app is a Typer instance.\"\"\"\n        assert isinstance(app, typer.Typer)\n\n    def test_app_has_commands(self) -> None:\n        \"\"\"Test that app has expected top-level commands.\"\"\"\n        commands = [\n            callback.callback.__name__.replace(\"_\", \"-\")\n            for callback in app.registered_commands\n            if callback.callback is not None\n        ]\n\n        expected_commands = [\"pages\", \"pages-with-descendants\", \"spaces\", \"orgs\", \"version\"]\n        for expected_command in expected_commands:\n            assert expected_command in commands\n\n    def test_app_has_config_group(self) -> None:\n        \"\"\"Test that the config sub-app is registered as a command group.\"\"\"\n        group_names = [group.name for group in app.registered_groups]\n        assert \"config\" in group_names\n"
  },
  {
    "path": "tests/unit/test_nbsp_fix.py",
    "content": "\"\"\"Test that Unicode whitespace (especially &nbsp;) is preserved in inline formatting.\"\"\"\n\nfrom __future__ import annotations\n\nfrom typing import TYPE_CHECKING\n\nimport pytest\n\nif TYPE_CHECKING:\n    from confluence_markdown_exporter.confluence import Page\n\n\nclass TestNbspPreservation:\n    \"\"\"Test that non-breaking spaces and other Unicode whitespace are preserved.\"\"\"\n\n    @pytest.fixture\n    def converter(self) -> Page.Converter:\n        \"\"\"Create a minimal Page object with a Converter for testing.\"\"\"\n        from confluence_markdown_exporter.confluence import Page\n\n        # Create a minimal page object for testing\n        class MockPage:\n            def __init__(self) -> None:\n                self.id = \"test-page\"\n                self.title = \"Test Page\"\n                self.html = \"\"\n                self.labels = []\n                self.ancestors = []\n\n            def get_attachment_by_file_id(self, file_id: str) -> None:\n                return None\n\n        page = MockPage()\n        return Page.Converter(page)\n\n    def test_em_with_leading_nbsp(self, converter: Page.Converter) -> None:\n        \"\"\"Test <em>&nbsp;text</em> converts to ' *text*' (space before asterisk).\"\"\"\n        html = \"<em>&nbsp;text</em>\"\n        result = converter.convert(html).strip()\n        assert result == \"*text*\", f\"Expected '*text*' but got '{result}'\"\n        # The space is preserved in the conversion\n        html_with_context = \"word<em>&nbsp;text</em>\"\n        result_with_context = converter.convert(html_with_context).strip()\n        assert \"word *text*\" in result_with_context or \"word  *text*\" in result_with_context\n\n    def test_em_with_trailing_nbsp(self, converter: Page.Converter) -> None:\n        \"\"\"Test <em>text&nbsp;</em> converts to '*text* ' (space after asterisk).\"\"\"\n        html = \"<em>text&nbsp;</em>\"\n        result = converter.convert(html).strip()\n        assert result == \"*text*\", f\"Expected '*text*' but got '{result}'\"\n        # The space is preserved in the conversion\n        html_with_context = \"<em>text&nbsp;</em>word\"\n        result_with_context = converter.convert(html_with_context).strip()\n        assert \"*text* word\" in result_with_context or \"*text*  word\" in result_with_context\n\n    def test_em_with_both_nbsp(self, converter: Page.Converter) -> None:\n        \"\"\"Test <em>&nbsp;text&nbsp;</em> preserves both spaces.\"\"\"\n        html = \"word<em>&nbsp;text&nbsp;</em>end\"\n        result = converter.convert(html).strip()\n        # Should have spaces around the emphasis\n        assert \"*text*\" in result\n        # Check that there's space before and after\n        assert \"word *text* end\" in result or \"word  *text*  end\" in result\n\n    def test_strong_with_leading_nbsp(self, converter: Page.Converter) -> None:\n        \"\"\"Test <strong>&nbsp;text</strong> converts to ' **text**'.\"\"\"\n        html = \"word<strong>&nbsp;text</strong>\"\n        result = converter.convert(html).strip()\n        assert \"**text**\" in result\n        assert \"word **text**\" in result or \"word  **text**\" in result\n\n    def test_strong_with_trailing_nbsp(self, converter: Page.Converter) -> None:\n        \"\"\"Test <strong>text&nbsp;</strong> converts to '**text** '.\"\"\"\n        html = \"<strong>text&nbsp;</strong>word\"\n        result = converter.convert(html).strip()\n        assert \"**text**\" in result\n        assert \"**text** word\" in result or \"**text**  word\" in result\n\n    def test_code_with_leading_nbsp(self, converter: Page.Converter) -> None:\n        \"\"\"Test <code>&nbsp;text</code> converts to ' `text`'.\"\"\"\n        html = \"word<code>&nbsp;text</code>\"\n        result = converter.convert(html).strip()\n        assert \"`text`\" in result\n        assert \"word `text`\" in result or \"word  `text`\" in result\n\n    def test_code_with_trailing_nbsp(self, converter: Page.Converter) -> None:\n        \"\"\"Test <code>text&nbsp;</code> converts to '`text` '.\"\"\"\n        html = \"<code>text&nbsp;</code>word\"\n        result = converter.convert(html).strip()\n        assert \"`text`\" in result\n        assert \"`text` word\" in result or \"`text`  word\" in result\n\n    def test_i_tag_with_nbsp(self, converter: Page.Converter) -> None:\n        \"\"\"Test <i>&nbsp;text</i> (italic alias) preserves space.\"\"\"\n        html = \"word<i>&nbsp;text</i>\"\n        result = converter.convert(html).strip()\n        assert \"*text*\" in result\n        assert \"word *text*\" in result or \"word  *text*\" in result\n\n    def test_b_tag_with_nbsp(self, converter: Page.Converter) -> None:\n        \"\"\"Test <b>&nbsp;text</b> (bold alias) preserves space.\"\"\"\n        html = \"word<b>&nbsp;text</b>\"\n        result = converter.convert(html).strip()\n        assert \"**text**\" in result\n        assert \"word **text**\" in result or \"word  **text**\" in result\n\n    def test_real_world_confluence_example(self, converter: Page.Converter) -> None:\n        \"\"\"Test the actual example from MOSART Audio.md.\"\"\"\n        html = \"property<em>&nbsp;JungerRoot</em> .\"\n        result = converter.convert(html).strip()\n        # Should NOT be \"property*JungerRoot*\" (missing space)\n        assert \"property*JungerRoot*\" not in result, \"Space was lost!\"\n        # Should be \"property *JungerRoot*\" or \"property  *JungerRoot*\"\n        assert \"*JungerRoot*\" in result\n        assert \"property\" in result\n\n    def test_multiple_nbsp_in_sequence(self, converter: Page.Converter) -> None:\n        \"\"\"Test multiple &nbsp; entities in a row.\"\"\"\n        html = \"word<em>&nbsp;&nbsp;text</em>\"\n        result = converter.convert(html).strip()\n        # Multiple nbsp should become multiple spaces\n        assert \"*text*\" in result or \"* text*\" in result\n\n    def test_mixed_whitespace(self, converter: Page.Converter) -> None:\n        \"\"\"Test normal spaces work alongside nbsp.\"\"\"\n        html = \"see <em>figure 1</em> below\"\n        result = converter.convert(html).strip()\n        assert \"see *figure 1* below\" in result\n\n    def test_normalize_helper_function(self, converter: Page.Converter) -> None:\n        \"\"\"Test the _normalize_unicode_whitespace helper directly.\"\"\"\n        # Test with various Unicode whitespace characters\n        test_text = \"\\xa0text\\xa0\"  # \\xa0 is nbsp\n\n        # Before normalization\n        assert \"\\xa0\" in test_text\n\n        # Normalize\n        normalized_text = converter._normalize_unicode_whitespace(test_text)\n\n        # After normalization - nbsp should be replaced with regular space\n        assert \"\\xa0\" not in normalized_text, \"nbsp should be replaced\"\n        assert normalized_text.strip() == \"text\", \"Text should be preserved\"\n        # Spaces should now be regular spaces\n        assert normalized_text.startswith(\" \"), \"Leading space should be preserved\"\n        assert normalized_text.endswith(\" \"), \"Trailing space should be preserved\"\n\n    def test_unicode_em_space(self, converter: Page.Converter) -> None:\n        \"\"\"Test that EM SPACE (\\u2003) is also normalized.\"\"\"\n        test_text = \"\\u2003text\"  # EM SPACE\n\n        normalized_text = converter._normalize_unicode_whitespace(test_text)\n\n        assert \"\\u2003\" not in normalized_text, \"EM SPACE should be replaced\"\n        assert normalized_text.strip() == \"text\"\n        assert normalized_text.startswith(\" \"), \"Space should be preserved as regular space\"\n\n    def test_unicode_thin_space(self, converter: Page.Converter) -> None:\n        \"\"\"Test that THIN SPACE (\\u2009) is normalized.\"\"\"\n        test_text = \"text\\u2009end\"  # THIN SPACE\n\n        normalized_text = converter._normalize_unicode_whitespace(test_text)\n\n        assert \"\\u2009\" not in normalized_text, \"THIN SPACE should be replaced\"\n        assert normalized_text == \"text end\", \"Space should be preserved as regular space\"\n\n    def test_preserves_newlines_and_tabs(self, converter: Page.Converter) -> None:\n        \"\"\"Test that normal whitespace (newlines, tabs) are NOT affected.\"\"\"\n        test_text = \"text\\nwith\\nnewlines\"\n\n        normalized_text = converter._normalize_unicode_whitespace(test_text)\n\n        # Newlines should be preserved\n        assert \"\\n\" in normalized_text\n        assert normalized_text == test_text, \"Regular whitespace should not be touched\"\n\n    def test_no_modification_when_no_unicode_whitespace(self, converter: Page.Converter) -> None:\n        \"\"\"Test that text without Unicode whitespace is not modified.\"\"\"\n        test_text = \"normal text\"\n\n        normalized_text = converter._normalize_unicode_whitespace(test_text)\n\n        assert normalized_text == test_text, \"Normal text should not be modified\"\n"
  },
  {
    "path": "tests/unit/test_plantuml_code_block_detection.py",
    "content": "\"\"\"Unit tests for PlantUML auto-detection in code blocks.\"\"\"\n\nfrom unittest.mock import MagicMock\nfrom unittest.mock import patch\n\nfrom bs4 import BeautifulSoup\n\nfrom confluence_markdown_exporter.confluence import Page\n\n\nclass TestPlantUMLCodeBlockDetection:\n    \"\"\"Test cases for @startuml auto-detection in <pre> code blocks.\"\"\"\n\n    def _make_page(self) -> MagicMock:\n        page = MagicMock(spec=Page)\n        page.id = 12345\n        page.title = \"Test Page\"\n        page.html = \"<h1>Test Page</h1>\"\n        page.labels = []\n        page.ancestors = []\n        page.attachments = []\n        page.editor2 = \"\"\n        page.body_storage = \"\"\n        return page\n\n    @patch(\"confluence_markdown_exporter.confluence.settings\")\n    def test_pre_with_startuml_uses_plantuml_fence(self, mock_settings: MagicMock) -> None:\n        \"\"\"Code block containing @startuml should be fenced as plantuml.\"\"\"\n        mock_settings.export.include_document_title = False\n\n        converter = Page.Converter(self._make_page())\n\n        html = (\n            '<pre data-syntaxhighlighter-params=\"brush: java; gutter: false\">'\n            \"@startuml\\nA -> B\\n@enduml</pre>\"\n        )\n        el = BeautifulSoup(html, \"html.parser\").find(\"pre\")\n\n        result = converter.convert_pre(el, \"@startuml\\nA -> B\\n@enduml\", [])\n\n        assert \"```plantuml\" in result\n        assert \"```java\" not in result\n\n    @patch(\"confluence_markdown_exporter.confluence.settings\")\n    def test_pre_without_startuml_keeps_original_language(self, mock_settings: MagicMock) -> None:\n        \"\"\"Regular code blocks should keep their original language.\"\"\"\n        mock_settings.export.include_document_title = False\n\n        converter = Page.Converter(self._make_page())\n\n        html = (\n            '<pre data-syntaxhighlighter-params=\"brush: java; gutter: false\">'\n            \"public class Foo {}</pre>\"\n        )\n        el = BeautifulSoup(html, \"html.parser\").find(\"pre\")\n\n        result = converter.convert_pre(el, \"public class Foo {}\", [])\n\n        assert \"```java\" in result\n        assert \"```plantuml\" not in result\n\n    @patch(\"confluence_markdown_exporter.confluence.settings\")\n    def test_pre_empty_text_returns_empty(self, mock_settings: MagicMock) -> None:\n        \"\"\"Empty pre block should return empty string.\"\"\"\n        mock_settings.export.include_document_title = False\n\n        converter = Page.Converter(self._make_page())\n\n        html = \"<pre></pre>\"\n        el = BeautifulSoup(html, \"html.parser\").find(\"pre\")\n\n        result = converter.convert_pre(el, \"\", [])\n\n        assert result == \"\"\n\n    @patch(\"confluence_markdown_exporter.confluence.settings\")\n    def test_pre_no_language_with_startuml(self, mock_settings: MagicMock) -> None:\n        \"\"\"Pre block without brush param but containing @startuml gets plantuml fence.\"\"\"\n        mock_settings.export.include_document_title = False\n\n        converter = Page.Converter(self._make_page())\n\n        html = \"<pre>@startuml\\nBob -> Alice\\n@enduml</pre>\"\n        el = BeautifulSoup(html, \"html.parser\").find(\"pre\")\n\n        result = converter.convert_pre(el, \"@startuml\\nBob -> Alice\\n@enduml\", [])\n\n        assert \"```plantuml\" in result\n"
  },
  {
    "path": "tests/unit/test_plantuml_conversion.py",
    "content": "\"\"\"Unit tests for PlantUML diagram conversion.\"\"\"\n\nfrom unittest.mock import MagicMock\nfrom unittest.mock import patch\n\nimport pytest\nfrom bs4 import BeautifulSoup\n\nfrom confluence_markdown_exporter.confluence import Page\n\n\nclass TestPlantUMLConversion:\n    \"\"\"Test cases for PlantUML diagram conversion.\"\"\"\n\n    @pytest.fixture\n    def mock_page(self) -> MagicMock:\n        \"\"\"Create a mock page with PlantUML content in editor2 (Cloud format).\"\"\"\n        page = MagicMock(spec=Page)\n        page.id = 12345\n        page.title = \"Test Page\"\n        page.html = \"<h1>Test Page</h1>\"\n        page.labels = []\n        page.ancestors = []\n        page.attachments = []\n        page.body_storage = \"\"\n\n        # Sample editor2 XML with PlantUML macro\n        uml_data = '{\"umlDefinition\":\"@startuml\\\\nAlice -> Bob: Hello\\\\n@enduml\"}'\n        page.editor2 = f'''<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<ac:structured-macro ac:name=\"plantuml\" ac:schema-version=\"1\"\n    ac:macro-id=\"test-macro-id-123\">\n    <ac:parameter ac:name=\"fileName\">plantuml_test</ac:parameter>\n    <ac:plain-text-body><![CDATA[{uml_data}]]></ac:plain-text-body>\n</ac:structured-macro>'''\n\n        return page\n\n    @pytest.fixture\n    def mock_server_page(self) -> MagicMock:\n        \"\"\"Create a mock page with PlantUML content in body.storage (Server format).\"\"\"\n        page = MagicMock(spec=Page)\n        page.id = 67890\n        page.title = \"Server Page\"\n        page.html = \"<h1>Server Page</h1>\"\n        page.labels = []\n        page.ancestors = []\n        page.attachments = []\n        page.editor2 = \"\"\n\n        page.body_storage = (\n            '<ac:structured-macro ac:name=\"plantuml\">'\n            \"<ac:plain-text-body>\"\n            \"<![CDATA[@startuml\\nAlice -> Bob: Hello\\n@enduml]]>\"\n            \"</ac:plain-text-body>\"\n            \"</ac:structured-macro>\"\n        )\n\n        return page\n\n    @patch(\"confluence_markdown_exporter.confluence.settings\")\n    def test_convert_plantuml_cloud_editor2(\n        self, mock_settings: MagicMock, mock_page: MagicMock\n    ) -> None:\n        \"\"\"Test PlantUML conversion from editor2 XML (Cloud format).\"\"\"\n        mock_settings.export.include_document_title = False\n        mock_settings.export.page_breadcrumbs = False\n\n        converter = Page.Converter(mock_page)\n\n        html = '<div data-macro-name=\"plantuml\" data-macro-id=\"test-macro-id-123\"></div>'\n        el = BeautifulSoup(html, \"html.parser\").find(\"div\")\n\n        result = converter.convert_plantuml(el, \"\", [])\n\n        assert \"```plantuml\" in result\n        assert \"@startuml\" in result\n        assert \"Alice -> Bob: Hello\" in result\n        assert \"@enduml\" in result\n\n    @patch(\"confluence_markdown_exporter.confluence.settings\")\n    def test_convert_plantuml_server_storage(\n        self, mock_settings: MagicMock, mock_server_page: MagicMock\n    ) -> None:\n        \"\"\"Test PlantUML conversion from body.storage (Server/DC format).\"\"\"\n        mock_settings.export.include_document_title = False\n\n        converter = Page.Converter(mock_server_page)\n\n        # Server renders PlantUML as <span> without macro-id\n        html = '<span class=\"plantuml-svg-image\" data-macro-name=\"plantuml\"></span>'\n        el = BeautifulSoup(html, \"html.parser\").find(\"span\")\n\n        result = converter.convert_plantuml(el, \"\", [])\n\n        assert \"```plantuml\" in result\n        assert \"@startuml\" in result\n        assert \"Alice -> Bob: Hello\" in result\n        assert \"@enduml\" in result\n\n    @patch(\"confluence_markdown_exporter.confluence.settings\")\n    def test_convert_plantuml_server_multiple_diagrams(\n        self, mock_settings: MagicMock\n    ) -> None:\n        \"\"\"Test positional matching of multiple PlantUML diagrams on Server.\"\"\"\n        mock_settings.export.include_document_title = False\n\n        page = MagicMock(spec=Page)\n        page.id = 11111\n        page.title = \"Multi-Diagram Page\"\n        page.html = \"<h1>Multi-Diagram Page</h1>\"\n        page.labels = []\n        page.ancestors = []\n        page.attachments = []\n        page.editor2 = \"\"\n\n        page.body_storage = (\n            '<ac:structured-macro ac:name=\"plantuml\">'\n            \"<ac:plain-text-body>\"\n            \"<![CDATA[@startuml\\nAlice -> Bob: First\\n@enduml]]>\"\n            \"</ac:plain-text-body>\"\n            \"</ac:structured-macro>\"\n            \"<p>Some text between diagrams</p>\"\n            '<ac:structured-macro ac:name=\"plantuml\">'\n            \"<ac:plain-text-body>\"\n            \"<![CDATA[@startuml\\nBob -> Carol: Second\\n@enduml]]>\"\n            \"</ac:plain-text-body>\"\n            \"</ac:structured-macro>\"\n        )\n\n        converter = Page.Converter(page)\n\n        html1 = '<span data-macro-name=\"plantuml\"></span>'\n        el1 = BeautifulSoup(html1, \"html.parser\").find(\"span\")\n        result1 = converter.convert_plantuml(el1, \"\", [])\n\n        html2 = '<span data-macro-name=\"plantuml\"></span>'\n        el2 = BeautifulSoup(html2, \"html.parser\").find(\"span\")\n        result2 = converter.convert_plantuml(el2, \"\", [])\n\n        assert \"Alice -> Bob: First\" in result1\n        assert \"Bob -> Carol: Second\" in result2\n\n    @patch(\"confluence_markdown_exporter.confluence.settings\")\n    def test_convert_plantuml_no_source_available(\n        self, mock_settings: MagicMock\n    ) -> None:\n        \"\"\"Test PlantUML conversion when neither editor2 nor storage has content.\"\"\"\n        mock_settings.export.include_document_title = False\n\n        page = MagicMock(spec=Page)\n        page.id = 99999\n        page.title = \"Empty Page\"\n        page.html = \"<h1>Empty Page</h1>\"\n        page.labels = []\n        page.ancestors = []\n        page.attachments = []\n        page.editor2 = \"\"\n        page.body_storage = \"\"\n\n        converter = Page.Converter(page)\n\n        html = '<div data-macro-name=\"plantuml\"></div>'\n        el = BeautifulSoup(html, \"html.parser\").find(\"div\")\n\n        result = converter.convert_plantuml(el, \"\", [])\n\n        assert \"<!-- PlantUML diagram\" in result\n        assert \"source not found\" in result\n\n    @patch(\"confluence_markdown_exporter.confluence.settings\")\n    def test_convert_plantuml_complex_diagram(self, mock_settings: MagicMock) -> None:\n        \"\"\"Test PlantUML conversion with a complex diagram.\"\"\"\n        mock_settings.export.include_document_title = False\n\n        page = MagicMock(spec=Page)\n        page.id = 12345\n        page.title = \"Test Page\"\n        page.html = \"<h1>Test Page</h1>\"\n        page.labels = []\n        page.ancestors = []\n        page.attachments = []\n        page.body_storage = \"\"\n\n        # Complex PlantUML diagram - properly escaped for JSON\n        uml_definition = (\n            \"@startuml\\\\nskinparam backgroundColor white\\\\ntitle Test Diagram\\\\n\\\\n\"\n            \"|Actor|\\\\nstart\\\\n:Action 1;\\\\n:Action 2;\\\\nstop\\\\n@enduml\"\n        )\n\n        page.editor2 = f'''<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<ac:structured-macro ac:name=\"plantuml\" ac:schema-version=\"1\"\n    ac:macro-id=\"complex-macro-id\">\n    <ac:plain-text-body><![CDATA[{{\"umlDefinition\":\"{uml_definition}\"}}]]></ac:plain-text-body>\n</ac:structured-macro>'''\n\n        converter = Page.Converter(page)\n\n        html = '<div data-macro-name=\"plantuml\" data-macro-id=\"complex-macro-id\"></div>'\n        el = BeautifulSoup(html, \"html.parser\").find(\"div\")\n\n        result = converter.convert_plantuml(el, \"\", [])\n\n        assert \"```plantuml\" in result\n        assert \"@startuml\" in result\n        assert \"skinparam backgroundColor white\" in result\n        assert \"title Test Diagram\" in result\n        assert \"@enduml\" in result\n\n    @patch(\"confluence_markdown_exporter.confluence.settings\")\n    def test_convert_plantuml_editor2_fallback_to_storage(\n        self, mock_settings: MagicMock\n    ) -> None:\n        \"\"\"Test that when editor2 macro-id doesn't match, storage is used as fallback.\"\"\"\n        mock_settings.export.include_document_title = False\n\n        page = MagicMock(spec=Page)\n        page.id = 22222\n        page.title = \"Fallback Page\"\n        page.html = \"<h1>Fallback Page</h1>\"\n        page.labels = []\n        page.ancestors = []\n        page.attachments = []\n\n        # editor2 has a macro but with a different ID\n        page.editor2 = '''<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<ac:structured-macro ac:name=\"plantuml\" ac:macro-id=\"different-id\">\n    <ac:plain-text-body><![CDATA[{\"umlDefinition\":\"@startuml\\\\nwrong\\\\n@enduml\"}]]></ac:plain-text-body>\n</ac:structured-macro>'''\n\n        # body.storage has the correct content\n        page.body_storage = (\n            '<ac:structured-macro ac:name=\"plantuml\">'\n            \"<ac:plain-text-body>\"\n            \"<![CDATA[@startuml\\nCorrect from storage\\n@enduml]]>\"\n            \"</ac:plain-text-body>\"\n            \"</ac:structured-macro>\"\n        )\n\n        converter = Page.Converter(page)\n\n        # View element references an ID not found in editor2\n        html = '<div data-macro-name=\"plantuml\" data-macro-id=\"nonexistent-id\"></div>'\n        el = BeautifulSoup(html, \"html.parser\").find(\"div\")\n\n        result = converter.convert_plantuml(el, \"\", [])\n\n        assert \"```plantuml\" in result\n        assert \"Correct from storage\" in result\n\n    @patch(\"confluence_markdown_exporter.confluence.settings\")\n    def test_convert_plantuml_invalid_json_falls_through(\n        self, mock_settings: MagicMock\n    ) -> None:\n        \"\"\"Test that invalid JSON in editor2 falls through to storage.\"\"\"\n        mock_settings.export.include_document_title = False\n\n        page = MagicMock(spec=Page)\n        page.id = 33333\n        page.title = \"Invalid JSON Page\"\n        page.html = \"<h1>Invalid JSON Page</h1>\"\n        page.labels = []\n        page.ancestors = []\n        page.attachments = []\n\n        page.editor2 = '''<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<ac:structured-macro ac:name=\"plantuml\" ac:macro-id=\"json-error-id\">\n    <ac:plain-text-body><![CDATA[{invalid json}]]></ac:plain-text-body>\n</ac:structured-macro>'''\n\n        page.body_storage = (\n            '<ac:structured-macro ac:name=\"plantuml\">'\n            \"<ac:plain-text-body>\"\n            \"<![CDATA[@startuml\\nFallback content\\n@enduml]]>\"\n            \"</ac:plain-text-body>\"\n            \"</ac:structured-macro>\"\n        )\n\n        converter = Page.Converter(page)\n\n        html = '<div data-macro-name=\"plantuml\" data-macro-id=\"json-error-id\"></div>'\n        el = BeautifulSoup(html, \"html.parser\").find(\"div\")\n\n        result = converter.convert_plantuml(el, \"\", [])\n\n        assert \"```plantuml\" in result\n        assert \"Fallback content\" in result\n\n    @patch(\"confluence_markdown_exporter.confluence.settings\")\n    def test_convert_span_dispatches_plantuml(\n        self, mock_settings: MagicMock, mock_server_page: MagicMock\n    ) -> None:\n        \"\"\"Test that convert_span dispatches plantuml macros on Server.\"\"\"\n        mock_settings.export.include_document_title = False\n        mock_settings.export.convert_text_highlights = False\n        mock_settings.export.convert_font_colors = False\n\n        converter = Page.Converter(mock_server_page)\n\n        html = (\n            '<span class=\"plantuml-svg-image conf-macro output-inline\" '\n            'data-hasbody=\"true\" data-macro-name=\"plantuml\">'\n            \"<svg>...</svg>\"\n            \"</span>\"\n        )\n        el = BeautifulSoup(html, \"html.parser\").find(\"span\")\n\n        result = converter.convert_span(el, \"svg text\", [])\n\n        assert \"```plantuml\" in result\n        assert \"@startuml\" in result\n\n"
  },
  {
    "path": "tests/unit/test_template_placeholders.py",
    "content": "\"\"\"Test that <template> placeholders are escaped for Obsidian compatibility.\"\"\"\n\nfrom __future__ import annotations\n\nfrom typing import TYPE_CHECKING\n\nimport pytest\n\nif TYPE_CHECKING:\n    from confluence_markdown_exporter.confluence import Page\n\n\nclass TestTemplatePlaceholderEscaping:\n    \"\"\"Test that angle-bracket template placeholders are escaped for Obsidian.\"\"\"\n\n    @pytest.fixture\n    def converter(self) -> Page.Converter:\n        from confluence_markdown_exporter.confluence import Page\n\n        class MockPage:\n            def __init__(self) -> None:\n                self.id = \"test-page\"\n                self.title = \"Test Page\"\n                self.html = \"\"\n                self.labels = []\n                self.ancestors = []\n\n            def get_attachment_by_file_id(self, file_id: str) -> None:\n                return None\n\n        return Page.Converter(MockPage())\n\n    def test_multi_word_placeholder_escaped(self, converter: Page.Converter) -> None:\n        result = converter._escape_template_placeholders(\"Replace <medical device> here.\")\n        assert result == \"Replace \\\\<medical device\\\\> here.\"\n\n    def test_allcaps_placeholder_escaped(self, converter: Page.Converter) -> None:\n        result = converter._escape_template_placeholders(\n            \"Page: Literature Search Report: <TOPIC>\"\n        )\n        assert result == \"Page: Literature Search Report: \\\\<TOPIC\\\\>\"\n\n    def test_complex_placeholder_escaped(self, converter: Page.Converter) -> None:\n        text = \"the <(e.g., clinical performance or state of the art)> of <medical device>.\"\n        result = converter._escape_template_placeholders(text)\n        assert \"\\\\<(e.g., clinical performance or state of the art)\\\\>\" in result\n        assert \"\\\\<medical device\\\\>\" in result\n\n    def test_placeholder_with_slash_in_name_escaped(self, converter: Page.Converter) -> None:\n        result = converter._escape_template_placeholders(\n            \"the <medical device/equivalent device> here\"\n        )\n        assert \"\\\\<medical device/equivalent device\\\\>\" in result\n\n    def test_fake_closing_tag_placeholder_escaped(self, converter: Page.Converter) -> None:\n        result = converter._escape_template_placeholders(\"use the </insert excerpt> function\")\n        assert \"\\\\</insert excerpt\\\\>\" in result\n\n    def test_br_tag_preserved(self, converter: Page.Converter) -> None:\n        result = converter._escape_template_placeholders(\"text<br/>more text\")\n        assert result == \"text<br/>more text\"\n\n    def test_br_with_space_preserved(self, converter: Page.Converter) -> None:\n        result = converter._escape_template_placeholders(\"text<br />more text\")\n        assert result == \"text<br />more text\"\n\n    def test_br_uppercase_preserved(self, converter: Page.Converter) -> None:\n        result = converter._escape_template_placeholders(\"text<BR/>more text\")\n        assert result == \"text<BR/>more text\"\n\n    def test_closing_html_tag_preserved(self, converter: Page.Converter) -> None:\n        result = converter._escape_template_placeholders(\"</div>\")\n        assert result == \"</div>\"\n\n    def test_inline_code_not_modified(self, converter: Page.Converter) -> None:\n        result = converter._escape_template_placeholders(\"Use `<TOPIC>` here.\")\n        assert result == \"Use `<TOPIC>` here.\"\n\n    def test_fenced_code_block_not_modified(self, converter: Page.Converter) -> None:\n        text = \"before\\n```\\n<TOPIC>\\n<medical device>\\n```\\nafter\"\n        result = converter._escape_template_placeholders(text)\n        assert \"<TOPIC>\" in result\n        assert \"<medical device>\" in result\n        assert \"\\\\<TOPIC\\\\>\" not in result\n\n    def test_tilde_fenced_code_block_not_modified(self, converter: Page.Converter) -> None:\n        text = \"before\\n~~~\\n<TOPIC>\\n~~~\\nafter\"\n        result = converter._escape_template_placeholders(text)\n        assert \"<TOPIC>\" in result\n\n    def test_text_outside_code_block_still_escaped(self, converter: Page.Converter) -> None:\n        text = \"Replace <TOPIC> here.\\n```\\n<TOPIC>\\n```\\nAlso <medical device>.\"\n        result = converter._escape_template_placeholders(text)\n        lines = result.split(\"\\n\")\n        assert \"\\\\<TOPIC\\\\>\" in lines[0]\n        assert \"<TOPIC>\" in lines[2]\n        assert \"\\\\<medical device\\\\>\" in lines[4]\n\n    def test_https_autolink_preserved(self, converter: Page.Converter) -> None:\n        result = converter._escape_template_placeholders(\n            \"URL: <https://api.airamed.de/v1/udi>.\"\n        )\n        assert result == \"URL: <https://api.airamed.de/v1/udi>.\"\n\n    def test_http_autolink_preserved(self, converter: Page.Converter) -> None:\n        result = converter._escape_template_placeholders(\"see <http://example.com/path?q=1>\")\n        assert result == \"see <http://example.com/path?q=1>\"\n\n    def test_mailto_autolink_preserved(self, converter: Page.Converter) -> None:\n        result = converter._escape_template_placeholders(\"contact <mailto:foo@bar.com>\")\n        assert result == \"contact <mailto:foo@bar.com>\"\n\n    def test_email_autolink_preserved(self, converter: Page.Converter) -> None:\n        result = converter._escape_template_placeholders(\"contact <foo@bar.com> now\")\n        assert result == \"contact <foo@bar.com> now\"\n\n    def test_autolink_with_space_still_escaped(self, converter: Page.Converter) -> None:\n        # Not a valid autolink (contains whitespace) — treat as placeholder\n        result = converter._escape_template_placeholders(\"<https://x y>\")\n        assert result == \"\\\\<https://x y\\\\>\"\n"
  },
  {
    "path": "tests/unit/utils/__init__.py",
    "content": "\"\"\"Unit tests for utils module.\"\"\"\n"
  },
  {
    "path": "tests/unit/utils/test_app_data_store_env.py",
    "content": "\"\"\"Tests for ENV var override support in AppSettings.\"\"\"\n\nimport os\nimport tempfile\nfrom pathlib import Path\nfrom unittest.mock import patch\n\nimport pytest\n\nfrom confluence_markdown_exporter.utils.app_data_store import AppSettings\nfrom confluence_markdown_exporter.utils.app_data_store import ConfigModel\nfrom confluence_markdown_exporter.utils.app_data_store import ExportConfig\nfrom confluence_markdown_exporter.utils.app_data_store import get_settings\nfrom confluence_markdown_exporter.utils.app_data_store import load_app_data\n\n\nclass TestEnvVarOverrides:\n    \"\"\"Verify that CME_ env vars override stored config values without persisting.\"\"\"\n\n    def test_log_level_env_override(self) -> None:\n        \"\"\"CME_EXPORT__LOG_LEVEL overrides stored log_level.\"\"\"\n        with patch.dict(os.environ, {\"CME_EXPORT__LOG_LEVEL\": \"DEBUG\"}):\n            settings = get_settings()\n        assert settings.export.log_level == \"DEBUG\"\n\n    def test_output_path_env_override(self) -> None:\n        \"\"\"CME_EXPORT__OUTPUT_PATH overrides stored output_path.\"\"\"\n        with patch.dict(os.environ, {\"CME_EXPORT__OUTPUT_PATH\": \"/some/custom/export\"}):\n            settings = get_settings()\n        assert settings.export.output_path == Path(\"/some/custom/export\")\n\n    def test_max_workers_env_override(self) -> None:\n        \"\"\"CME_CONNECTION_CONFIG__MAX_WORKERS overrides stored max_workers.\"\"\"\n        with patch.dict(os.environ, {\"CME_CONNECTION_CONFIG__MAX_WORKERS\": \"3\"}):\n            settings = get_settings()\n        assert settings.connection_config.max_workers == 3\n\n    def test_verify_ssl_env_override_false(self) -> None:\n        \"\"\"CME_CONNECTION_CONFIG__VERIFY_SSL=false sets verify_ssl to False.\"\"\"\n        with patch.dict(os.environ, {\"CME_CONNECTION_CONFIG__VERIFY_SSL\": \"false\"}):\n            settings = get_settings()\n        assert settings.connection_config.verify_ssl is False\n\n    def test_skip_unchanged_env_override(self) -> None:\n        \"\"\"CME_EXPORT__SKIP_UNCHANGED=false sets skip_unchanged to False.\"\"\"\n        with patch.dict(os.environ, {\"CME_EXPORT__SKIP_UNCHANGED\": \"false\"}):\n            settings = get_settings()\n        assert settings.export.skip_unchanged is False\n\n    def test_save_log_to_file_default_false(self) -> None:\n        \"\"\"save_log_to_file defaults to False so existing behavior is preserved.\"\"\"\n        assert ExportConfig().save_log_to_file is False\n\n    def test_save_log_to_file_env_override(self) -> None:\n        \"\"\"CME_EXPORT__SAVE_LOG_TO_FILE=true sets save_log_to_file to True.\"\"\"\n        with patch.dict(os.environ, {\"CME_EXPORT__SAVE_LOG_TO_FILE\": \"true\"}):\n            settings = get_settings()\n        assert settings.export.save_log_to_file is True\n\n    def test_attachments_export_env_override(self) -> None:\n        \"\"\"CME_EXPORT__ATTACHMENTS_EXPORT overrides attachments_export.\"\"\"\n        with patch.dict(os.environ, {\"CME_EXPORT__ATTACHMENTS_EXPORT\": \"all\"}):\n            settings = get_settings()\n        assert settings.export.attachments_export == \"all\"\n\n    def test_comments_export_env_override(self) -> None:\n        \"\"\"CME_EXPORT__COMMENTS_EXPORT overrides comments_export.\"\"\"\n        with patch.dict(os.environ, {\"CME_EXPORT__COMMENTS_EXPORT\": \"all\"}):\n            settings = get_settings()\n        assert settings.export.comments_export == \"all\"\n\n    def test_confluence_url_in_frontmatter_env_override(self) -> None:\n        \"\"\"CME_EXPORT__CONFLUENCE_URL_IN_FRONTMATTER overrides confluence_url_in_frontmatter.\"\"\"\n        with patch.dict(os.environ, {\"CME_EXPORT__CONFLUENCE_URL_IN_FRONTMATTER\": \"both\"}):\n            settings = get_settings()\n        assert settings.export.confluence_url_in_frontmatter == \"both\"\n\n    def test_page_metadata_in_frontmatter_env_override(self) -> None:\n        \"\"\"CME_EXPORT__PAGE_METADATA_IN_FRONTMATTER=true sets page_metadata_in_frontmatter.\"\"\"\n        with patch.dict(os.environ, {\"CME_EXPORT__PAGE_METADATA_IN_FRONTMATTER\": \"true\"}):\n            settings = get_settings()\n        assert settings.export.page_metadata_in_frontmatter is True\n\n    def test_env_var_does_not_persist(self) -> None:\n        \"\"\"ENV var override is session-only and does not alter the JSON config file.\"\"\"\n        with tempfile.TemporaryDirectory() as tmpdir:\n            config_path = Path(tmpdir) / \"app_data.json\"\n            with patch.dict(\n                os.environ,\n                {\n                    \"CME_CONFIG_PATH\": str(config_path),\n                    \"CME_EXPORT__LOG_LEVEL\": \"ERROR\",\n                },\n            ):\n                settings = get_settings()\n                assert settings.export.log_level == \"ERROR\"\n                # Config file should not exist (no write triggered by get_settings)\n                assert not config_path.exists() or (\n                    \"ERROR\" not in config_path.read_text()\n                )\n\n    def test_file_config_used_without_env_override(self) -> None:\n        \"\"\"Without ENV var, the stored file config value is returned.\"\"\"\n        import confluence_markdown_exporter.utils.app_data_store as ads\n\n        stored = ConfigModel()\n        stored.export.log_level = \"WARNING\"  # type: ignore[assignment]\n\n        with patch.object(ads, \"APP_CONFIG_PATH\") as mock_path:\n            mock_path.exists.return_value = True\n            mock_path.read_text.return_value = stored.model_dump_json()\n\n            # Ensure no override is set\n            env = {k: v for k, v in os.environ.items() if k != \"CME_EXPORT__LOG_LEVEL\"}\n            with patch.dict(os.environ, env, clear=True):\n                settings = get_settings()\n        assert settings.export.log_level == \"WARNING\"\n\n    def test_env_override_takes_precedence_over_file(self) -> None:\n        \"\"\"ENV var overrides a value that differs in the stored config file.\"\"\"\n        import confluence_markdown_exporter.utils.app_data_store as ads\n\n        stored = ConfigModel()\n        stored.export.log_level = \"WARNING\"  # type: ignore[assignment]\n\n        with patch.object(ads, \"APP_CONFIG_PATH\") as mock_path:\n            mock_path.exists.return_value = True\n            mock_path.read_text.return_value = stored.model_dump_json()\n\n            with patch.dict(os.environ, {\"CME_EXPORT__LOG_LEVEL\": \"DEBUG\"}):\n                settings = get_settings()\n        assert settings.export.log_level == \"DEBUG\"\n\n    def test_multiple_env_overrides(self) -> None:\n        \"\"\"Multiple ENV vars can be overridden simultaneously.\"\"\"\n        with patch.dict(\n            os.environ,\n            {\n                \"CME_EXPORT__LOG_LEVEL\": \"ERROR\",\n                \"CME_EXPORT__FILENAME_LENGTH\": \"100\",\n                \"CME_CONNECTION_CONFIG__TIMEOUT\": \"60\",\n                \"CME_CONNECTION_CONFIG__USE_V2_API\": \"true\",\n            },\n        ):\n            settings = get_settings()\n        assert settings.export.log_level == \"ERROR\"\n        assert settings.export.filename_length == 100\n        assert settings.connection_config.timeout == 60\n        assert settings.connection_config.use_v2_api is True\n\n    def test_page_href_env_override(self) -> None:\n        \"\"\"CME_EXPORT__PAGE_HREF overrides page_href.\"\"\"\n        with patch.dict(os.environ, {\"CME_EXPORT__PAGE_HREF\": \"absolute\"}):\n            settings = get_settings()\n        assert settings.export.page_href == \"absolute\"\n\n    def test_attachment_href_env_override(self) -> None:\n        \"\"\"CME_EXPORT__ATTACHMENT_HREF overrides attachment_href.\"\"\"\n        with patch.dict(os.environ, {\"CME_EXPORT__ATTACHMENT_HREF\": \"absolute\"}):\n            settings = get_settings()\n        assert settings.export.attachment_href == \"absolute\"\n\n    def test_cleanup_stale_env_override(self) -> None:\n        \"\"\"CME_EXPORT__CLEANUP_STALE=false disables cleanup_stale.\"\"\"\n        with patch.dict(os.environ, {\"CME_EXPORT__CLEANUP_STALE\": \"false\"}):\n            settings = get_settings()\n        assert settings.export.cleanup_stale is False\n\n    def test_backoff_and_retry_env_override(self) -> None:\n        \"\"\"CME_CONNECTION_CONFIG__BACKOFF_AND_RETRY=false disables retry.\"\"\"\n        with patch.dict(os.environ, {\"CME_CONNECTION_CONFIG__BACKOFF_AND_RETRY\": \"false\"}):\n            settings = get_settings()\n        assert settings.connection_config.backoff_and_retry is False\n\n    def test_max_backoff_seconds_env_override(self) -> None:\n        \"\"\"CME_CONNECTION_CONFIG__MAX_BACKOFF_SECONDS overrides max_backoff_seconds.\"\"\"\n        with patch.dict(os.environ, {\"CME_CONNECTION_CONFIG__MAX_BACKOFF_SECONDS\": \"120\"}):\n            settings = get_settings()\n        assert settings.connection_config.max_backoff_seconds == 120\n\n    def test_enable_jira_enrichment_env_override(self) -> None:\n        \"\"\"CME_EXPORT__ENABLE_JIRA_ENRICHMENT=false disables Jira enrichment.\"\"\"\n        with patch.dict(os.environ, {\"CME_EXPORT__ENABLE_JIRA_ENRICHMENT\": \"false\"}):\n            settings = get_settings()\n        assert settings.export.enable_jira_enrichment is False\n\n    def test_lockfile_name_env_override(self) -> None:\n        \"\"\"CME_EXPORT__LOCKFILE_NAME overrides lockfile_name.\"\"\"\n        with patch.dict(os.environ, {\"CME_EXPORT__LOCKFILE_NAME\": \"my-lock.json\"}):\n            settings = get_settings()\n        assert settings.export.lockfile_name == \"my-lock.json\"\n\n    def test_existence_check_batch_size_env_override(self) -> None:\n        \"\"\"CME_EXPORT__EXISTENCE_CHECK_BATCH_SIZE overrides the batch size.\"\"\"\n        with patch.dict(os.environ, {\"CME_EXPORT__EXISTENCE_CHECK_BATCH_SIZE\": \"50\"}):\n            settings = get_settings()\n        assert settings.export.existence_check_batch_size == 50\n\n    def test_app_settings_is_base_settings_subclass(self) -> None:\n        \"\"\"AppSettings is a BaseSettings subclass.\"\"\"\n        from pydantic_settings import BaseSettings\n\n        assert issubclass(AppSettings, BaseSettings)\n\n    def test_invalid_log_level_env_var_raises(self) -> None:\n        \"\"\"An invalid log level value raises a validation error.\"\"\"\n        from pydantic import ValidationError\n\n        with patch.dict(os.environ, {\"CME_EXPORT__LOG_LEVEL\": \"INVALID\"}), pytest.raises(\n            ValidationError\n        ):\n            get_settings()\n\n\nclass TestLoadAppData:\n    \"\"\"Tests for load_app_data robustness.\"\"\"\n\n    def test_empty_config_file_returns_defaults(self) -> None:\n        \"\"\"Empty config file must not raise JSONDecodeError.\"\"\"\n        import confluence_markdown_exporter.utils.app_data_store as ads\n\n        with patch.object(ads, \"APP_CONFIG_PATH\") as mock_path:\n            mock_path.exists.return_value = True\n            mock_path.read_text.return_value = \"\"\n            result = load_app_data()\n        assert isinstance(result, dict)\n\n    def test_invalid_json_config_file_returns_defaults(self) -> None:\n        \"\"\"Corrupt config file must not raise JSONDecodeError.\"\"\"\n        import confluence_markdown_exporter.utils.app_data_store as ads\n\n        with patch.object(ads, \"APP_CONFIG_PATH\") as mock_path:\n            mock_path.exists.return_value = True\n            mock_path.read_text.return_value = \"not json {\"\n            result = load_app_data()\n        assert isinstance(result, dict)\n\n\nclass TestAttachmentPathMigration:\n    \"\"\"Test migration of attachment_path templates that omit {attachment_extension}.\"\"\"\n\n    def test_title_without_extension_gets_migrated(self) -> None:\n        \"\"\"{attachment_title} alone is migrated to include {attachment_extension}.\"\"\"\n        config = ExportConfig(attachment_path=\"{space_name}/{attachment_title}\")\n        assert config.attachment_path == \"{space_name}/{attachment_title}{attachment_extension}\"\n\n    def test_title_with_other_path_segments_migrated(self) -> None:\n        \"\"\"Migration works regardless of surrounding path segments.\"\"\"\n        config = ExportConfig(attachment_path=\"{page_title}/{attachment_title}\")\n        assert config.attachment_path == \"{page_title}/{attachment_title}{attachment_extension}\"\n\n    def test_title_already_has_extension_not_changed(self) -> None:\n        \"\"\"Template already containing {attachment_extension} is left unchanged.\"\"\"\n        original = \"{space_name}/{attachment_title}{attachment_extension}\"\n        config = ExportConfig(attachment_path=original)\n        assert config.attachment_path == original\n\n    def test_no_attachment_title_not_changed(self) -> None:\n        \"\"\"Default template without {attachment_title} is left unchanged.\"\"\"\n        original = \"{space_name}/attachments/{attachment_file_id}{attachment_extension}\"\n        config = ExportConfig(attachment_path=original)\n        assert config.attachment_path == original\n\n    def test_migration_via_env_var(self) -> None:\n        \"\"\"Migration also applies when the template comes from an ENV var.\"\"\"\n        with patch.dict(\n            os.environ,\n            {\"CME_EXPORT__ATTACHMENT_PATH\": \"{space_name}/attachments/{attachment_title}\"},\n        ):\n            settings = get_settings()\n        assert settings.export.attachment_path == (\n            \"{space_name}/attachments/{attachment_title}{attachment_extension}\"\n        )\n\n\nclass TestAttachmentsExportMigration:\n    \"\"\"Migration of legacy attachment_export_all bool to attachments_export literal.\"\"\"\n\n    def test_legacy_false_maps_to_referenced(self) -> None:\n        \"\"\"attachment_export_all=False migrates to attachments_export='referenced'.\"\"\"\n        config = ExportConfig.model_validate({\"attachment_export_all\": False})\n        assert config.attachments_export == \"referenced\"\n\n    def test_legacy_true_maps_to_all(self) -> None:\n        \"\"\"attachment_export_all=True migrates to attachments_export='all'.\"\"\"\n        config = ExportConfig.model_validate({\"attachment_export_all\": True})\n        assert config.attachments_export == \"all\"\n\n    def test_new_field_takes_precedence_over_old(self) -> None:\n        \"\"\"When both are present, the explicit new value wins and old is dropped.\"\"\"\n        config = ExportConfig.model_validate(\n            {\"attachment_export_all\": True, \"attachments_export\": \"disabled\"}\n        )\n        assert config.attachments_export == \"disabled\"\n\n\nclass TestCommentsExportMigration:\n    \"\"\"Migration of legacy inline_comments bool to comments_export literal.\"\"\"\n\n    def test_legacy_true_maps_to_inline(self) -> None:\n        \"\"\"inline_comments=True migrates to comments_export='inline'.\"\"\"\n        config = ExportConfig.model_validate({\"inline_comments\": True})\n        assert config.comments_export == \"inline\"\n\n    def test_legacy_false_maps_to_none(self) -> None:\n        \"\"\"inline_comments=False migrates to comments_export='none'.\"\"\"\n        config = ExportConfig.model_validate({\"inline_comments\": False})\n        assert config.comments_export == \"none\"\n\n    def test_new_field_takes_precedence_over_old(self) -> None:\n        \"\"\"When both are present, the explicit new value wins and old is dropped.\"\"\"\n        config = ExportConfig.model_validate(\n            {\"inline_comments\": True, \"comments_export\": \"footer\"}\n        )\n        assert config.comments_export == \"footer\"\n\n    def test_legacy_key_does_not_appear_on_model(self) -> None:\n        \"\"\"The legacy key is consumed during migration and is not set on the model.\"\"\"\n        config = ExportConfig.model_validate({\"inline_comments\": True})\n        assert not hasattr(config, \"inline_comments\")\n"
  },
  {
    "path": "tests/unit/utils/test_drawio_converter.py",
    "content": "\"\"\"Tests for DrawIO converter functionality.\"\"\"\n\nfrom pathlib import Path\n\nfrom confluence_markdown_exporter.utils.drawio_converter import extract_mermaid_data\nfrom confluence_markdown_exporter.utils.drawio_converter import format_mermaid_markdown\nfrom confluence_markdown_exporter.utils.drawio_converter import load_and_parse_drawio\nfrom confluence_markdown_exporter.utils.drawio_converter import load_drawio_file\nfrom confluence_markdown_exporter.utils.drawio_converter import parse_mermaid_json\n\n\nclass TestLoadDrawioFile:\n    \"\"\"Test DrawIO file loading.\"\"\"\n\n    def test_load_existing_file(self, tmp_path: Path) -> None:\n        \"\"\"Test loading an existing DrawIO file.\"\"\"\n        test_content = \"<mxfile><diagram>test</diagram></mxfile>\"\n        test_file = tmp_path / \"test.drawio\"\n        test_file.write_text(test_content)\n\n        result = load_drawio_file(test_file)\n        assert result == test_content\n\n    def test_load_nonexistent_file(self, tmp_path: Path) -> None:\n        \"\"\"Test loading a nonexistent file returns None.\"\"\"\n        nonexistent = tmp_path / \"nonexistent.drawio\"\n        result = load_drawio_file(nonexistent)\n        assert result is None\n\n\nclass TestExtractMermaidData:\n    \"\"\"Test mermaid data extraction from XML.\"\"\"\n\n    def test_extract_valid_mermaid_data(self) -> None:\n        \"\"\"Test extracting valid mermaid data.\"\"\"\n        # XML parser preserves case, so use UserObject and mermaidData\n        xml_content = \"\"\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<mxfile>\n  <diagram>\n    <mxGraphModel>\n      <root>\n        <UserObject mermaidData='{\"data\": \"graph TB\\\\n  A --> B\"}' />\n      </root>\n    </mxGraphModel>\n  </diagram>\n</mxfile>\"\"\"\n        result = extract_mermaid_data(xml_content)\n        assert result is not None\n        assert \"graph TB\" in result\n\n    def test_extract_no_mermaid_data(self) -> None:\n        \"\"\"Test extraction when no mermaid data exists.\"\"\"\n        xml_content = \"\"\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<mxfile>\n  <diagram>\n    <mxGraphModel>\n      <root>\n        <UserObject />\n      </root>\n    </mxGraphModel>\n  </diagram>\n</mxfile>\"\"\"\n        result = extract_mermaid_data(xml_content)\n        assert result is None\n\n    def test_extract_invalid_xml(self) -> None:\n        \"\"\"Test extraction with invalid XML returns None.\"\"\"\n        xml_content = \"<invalid>xml\"\n        result = extract_mermaid_data(xml_content)\n        assert result is None\n\n\nclass TestParseMermaidJson:\n    \"\"\"Test mermaid JSON parsing.\"\"\"\n\n    def test_parse_json_with_data_field(self) -> None:\n        \"\"\"Test parsing JSON with 'data' field.\"\"\"\n        json_data = '{\"data\": \"graph TB\\\\n  A --> B\"}'\n        result = parse_mermaid_json(json_data)\n        assert result == \"graph TB\\n  A --> B\"\n\n    def test_parse_plain_diagram(self) -> None:\n        \"\"\"Test parsing plain diagram string.\"\"\"\n        diagram = \"graph TB\\n  A --> B\"\n        result = parse_mermaid_json(diagram)\n        assert result == diagram\n\n    def test_parse_malformed_json(self) -> None:\n        \"\"\"Test parsing malformed JSON returns input as-is.\"\"\"\n        malformed = '{\"incomplete\": '\n        result = parse_mermaid_json(malformed)\n        assert result == malformed\n\n\nclass TestFormatMermaidMarkdown:\n    \"\"\"Test mermaid markdown formatting.\"\"\"\n\n    def test_format_diagram(self) -> None:\n        \"\"\"Test formatting a diagram as markdown.\"\"\"\n        diagram = \"graph TB\\n  A --> B\"\n        result = format_mermaid_markdown(diagram)\n        assert result == \"```mermaid\\ngraph TB\\n  A --> B\\n```\"\n\n\nclass TestLoadAndParseDrawio:\n    \"\"\"Integration tests for full DrawIO parsing.\"\"\"\n\n    def test_full_pipeline(self, tmp_path: Path) -> None:\n        \"\"\"Test full pipeline from file to markdown.\"\"\"\n        # XML parser preserves case, so use UserObject and mermaidData\n        mermaid_data = '{\"data\": \"graph TB\\\\n    A[Start]\\\\n    B[End]\\\\n    A --> B\"}'\n        xml_content = f\"\"\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<mxfile>\n  <diagram>\n    <mxGraphModel>\n      <root>\n        <UserObject mermaidData='{mermaid_data}' />\n      </root>\n    </mxGraphModel>\n  </diagram>\n</mxfile>\"\"\"\n        test_file = tmp_path / \"test.drawio\"\n        test_file.write_text(xml_content)\n\n        result = load_and_parse_drawio(test_file)\n        assert result is not None\n        assert \"```mermaid\" in result\n        assert \"graph TB\" in result\n        assert \"A[Start]\" in result\n        assert \"B[End]\" in result\n\n    def test_nonexistent_file(self, tmp_path: Path) -> None:\n        \"\"\"Test with nonexistent file returns None.\"\"\"\n        result = load_and_parse_drawio(tmp_path / \"nonexistent.drawio\")\n        assert result is None\n\n    def test_file_without_mermaid_data(self, tmp_path: Path) -> None:\n        \"\"\"Test file without mermaid data returns None.\"\"\"\n        xml_content = \"\"\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<mxfile>\n  <diagram>\n    <mxGraphModel>\n      <root>\n        <mxCell />\n      </root>\n    </mxGraphModel>\n  </diagram>\n</mxfile>\"\"\"\n        test_file = tmp_path / \"test.drawio\"\n        test_file.write_text(xml_content)\n\n        result = load_and_parse_drawio(test_file)\n        assert result is None\n"
  },
  {
    "path": "tests/unit/utils/test_export.py",
    "content": "\"\"\"Unit tests for export module.\"\"\"\n\nimport tempfile\nfrom pathlib import Path\nfrom unittest.mock import MagicMock\nfrom unittest.mock import patch\n\nimport pytest\n\nfrom confluence_markdown_exporter.utils.export import escape_character_class\nfrom confluence_markdown_exporter.utils.export import github_heading_slug\nfrom confluence_markdown_exporter.utils.export import parse_encode_setting\nfrom confluence_markdown_exporter.utils.export import sanitize_filename\nfrom confluence_markdown_exporter.utils.export import sanitize_key\nfrom confluence_markdown_exporter.utils.export import save_file\n\n\nclass TestParseEncodeSetting:\n    \"\"\"Test cases for parse_encode_setting function.\"\"\"\n\n    def test_empty_string(self) -> None:\n        \"\"\"Test parsing empty string returns empty dict.\"\"\"\n        result = parse_encode_setting(\"\")\n        assert result == {}\n\n    def test_simple_mapping(self) -> None:\n        \"\"\"Test parsing simple character mapping.\"\"\"\n        result = parse_encode_setting('\" \":\"%2D\",\"-\":\"%2D\"')\n        expected = {\" \": \"%2D\", \"-\": \"%2D\"}\n        assert result == expected\n\n    def test_mixed_mapping(self) -> None:\n        \"\"\"Test parsing mixed character mapping.\"\"\"\n        result = parse_encode_setting('\" \":\"dash\",\"-\":\"%2D\"')\n        expected = {\" \": \"dash\", \"-\": \"%2D\"}\n        assert result == expected\n\n    def test_equals_mapping(self) -> None:\n        \"\"\"Test parsing equals sign mapping.\"\"\"\n        result = parse_encode_setting('\"=\":\" equals \"')\n        expected = {\"=\": \" equals \"}\n        assert result == expected\n\n    def test_special_characters(self) -> None:\n        \"\"\"Test parsing special characters.\"\"\"\n        result = parse_encode_setting('\"\\\\\"\":\" quote \",\"\\\\\\\\\":\" backslash \"')\n        expected = {'\"': \" quote \", \"\\\\\": \" backslash \"}\n        assert result == expected\n\n    def test_invalid_json(self) -> None:\n        \"\"\"Test that invalid JSON returns empty dict.\"\"\"\n        result = parse_encode_setting(\"invalid json\")\n        assert result == {}\n\n    def test_non_dict_json(self) -> None:\n        \"\"\"Test that non-dict JSON returns empty dict.\"\"\"\n        result = parse_encode_setting('\"this is a string\"')\n        assert result == {}\n\n    def test_malformed_json(self) -> None:\n        \"\"\"Test that malformed JSON returns empty dict.\"\"\"\n        result = parse_encode_setting('\"key\":\"value\",')\n        assert result == {}\n\n\nclass TestSaveFile:\n    \"\"\"Test cases for save_file function.\"\"\"\n\n    def test_save_string_content(self) -> None:\n        \"\"\"Test saving string content to file.\"\"\"\n        with tempfile.TemporaryDirectory() as temp_dir:\n            file_path = Path(temp_dir) / \"test.txt\"\n            content = \"Hello, World!\"\n\n            save_file(file_path, content)\n\n            assert file_path.exists()\n            assert file_path.read_text(encoding=\"utf-8\") == content\n\n    def test_save_bytes_content(self) -> None:\n        \"\"\"Test saving bytes content to file.\"\"\"\n        with tempfile.TemporaryDirectory() as temp_dir:\n            file_path = Path(temp_dir) / \"test.bin\"\n            content = b\"Binary content\"\n\n            save_file(file_path, content)\n\n            assert file_path.exists()\n            assert file_path.read_bytes() == content\n\n    def test_create_parent_directories(self) -> None:\n        \"\"\"Test that parent directories are created when needed.\"\"\"\n        with tempfile.TemporaryDirectory() as temp_dir:\n            file_path = Path(temp_dir) / \"subdir\" / \"nested\" / \"test.txt\"\n            content = \"Test content\"\n\n            save_file(file_path, content)\n\n            assert file_path.exists()\n            assert file_path.read_text(encoding=\"utf-8\") == content\n\n    def test_overwrite_existing_file(self) -> None:\n        \"\"\"Test overwriting an existing file.\"\"\"\n        with tempfile.TemporaryDirectory() as temp_dir:\n            file_path = Path(temp_dir) / \"test.txt\"\n            original_content = \"Original content\"\n            new_content = \"New content\"\n\n            save_file(file_path, original_content)\n            save_file(file_path, new_content)\n\n            assert file_path.read_text(encoding=\"utf-8\") == new_content\n\n    def test_invalid_content_type(self) -> None:\n        \"\"\"Test that invalid content type raises TypeError.\"\"\"\n        with tempfile.TemporaryDirectory() as temp_dir:\n            file_path = Path(temp_dir) / \"test.txt\"\n\n            with pytest.raises(TypeError, match=r\"Content must be either a string or bytes\\.\"):\n                save_file(file_path, 123)  # type: ignore[arg-type]\n\n\nclass TestSanitizeFilename:\n    \"\"\"Test cases for sanitize_filename function.\"\"\"\n\n    @patch(\"confluence_markdown_exporter.utils.export.export_options\")\n    def test_no_encoding_specified(self, mock_export_options: MagicMock) -> None:\n        \"\"\"Test sanitizing filename with no encoding specified.\"\"\"\n        mock_export_options.filename_encoding = \"\"\n        mock_export_options.filename_length = 255\n        mock_export_options.filename_lowercase = False\n\n        result = sanitize_filename(\"Test File.txt\")\n        assert result == \"Test File.txt\"\n\n    @patch(\"confluence_markdown_exporter.utils.export.export_options\")\n    def test_with_encoding_mapping(self, mock_export_options: MagicMock) -> None:\n        \"\"\"Test sanitizing filename with encoding mapping.\"\"\"\n        mock_export_options.filename_encoding = '\" \":\"_\",\":\":\"_\"'\n        mock_export_options.filename_length = 255\n        mock_export_options.filename_lowercase = False\n\n        result = sanitize_filename(\"Test File: Name.txt\")\n        assert result == \"Test_File__Name.txt\"\n\n    @patch(\"confluence_markdown_exporter.utils.export.export_options\")\n    def test_with_encoding_mapping_lowercase(self, mock_export_options: MagicMock) -> None:\n        \"\"\"Test sanitizing filename with encoding mapping.\"\"\"\n        mock_export_options.filename_encoding = '\" \":\"_\",\":\":\"_\"'\n        mock_export_options.filename_length = 255\n        mock_export_options.filename_lowercase = True\n\n        result = sanitize_filename(\"Test File: Name.txt\")\n        assert result == \"test_file__name.txt\"\n\n    @patch(\"confluence_markdown_exporter.utils.export.export_options\")\n    def test_trim_trailing_spaces_and_dots(self, mock_export_options: MagicMock) -> None:\n        \"\"\"Test that trailing spaces and dots are trimmed.\"\"\"\n        mock_export_options.filename_encoding = \"\"\n        mock_export_options.filename_length = 255\n        mock_export_options.filename_lowercase = False\n\n        result = sanitize_filename(\"filename . . \")\n        assert result == \"filename\"\n\n    @patch(\"confluence_markdown_exporter.utils.export.export_options\")\n    def test_reserved_windows_names(self, mock_export_options: MagicMock) -> None:\n        \"\"\"Test that reserved Windows names are handled.\"\"\"\n        mock_export_options.filename_encoding = \"\"\n        mock_export_options.filename_length = 255\n        mock_export_options.filename_lowercase = False\n\n        reserved_names = [\"CON\", \"PRN\", \"AUX\", \"NUL\", \"COM1\", \"LPT1\"]\n        for name in reserved_names:\n            result = sanitize_filename(name)\n            assert result == f\"{name}_\"\n\n            # Test case insensitive\n            result = sanitize_filename(name.lower())\n            assert result == f\"{name.lower()}_\"\n\n    @patch(\"confluence_markdown_exporter.utils.export.export_options\")\n    def test_filename_length_limit(self, mock_export_options: MagicMock) -> None:\n        \"\"\"Test that filename length is limited.\"\"\"\n        mock_export_options.filename_encoding = \"\"\n        mock_export_options.filename_length = 10\n\n        long_filename = \"very_long_filename_that_exceeds_limit\"\n        result = sanitize_filename(long_filename)\n        assert len(result) == 10\n        assert result == long_filename[:10]\n\n    @patch(\"confluence_markdown_exporter.utils.export.export_options\")\n    def test_complex_filename_sanitization(self, mock_export_options: MagicMock) -> None:\n        \"\"\"Test complex filename sanitization with multiple rules.\"\"\"\n        mock_export_options.filename_encoding = '\" \":\"_\",\"?\":\"_\",\":\":\"_\"'\n        mock_export_options.filename_length = 50\n        mock_export_options.filename_lowercase = False\n\n        filename = \"My Document: What? How?  . .\"\n        result = sanitize_filename(filename)\n        # Character replacements happen first, then rstrip of spaces and dots\n        assert result == \"My_Document__What__How___._\"\n\n    @patch(\"confluence_markdown_exporter.utils.export.export_options\")\n    def test_control_characters_removed(self, mock_export_options: MagicMock) -> None:\n        \"\"\"Control characters (e.g. backspace) should be stripped.\"\"\"\n        mock_export_options.filename_encoding = \"\"\n        mock_export_options.filename_length = 255\n\n        result = sanitize_filename(\"on-pr\\x08emise\")\n        assert result == \"on-premise\"\n\n    @patch(\"confluence_markdown_exporter.utils.export.export_options\")\n    def test_multiple_control_characters(self, mock_export_options: MagicMock) -> None:\n        \"\"\"Multiple control characters should all be stripped.\"\"\"\n        mock_export_options.filename_encoding = \"\"\n        mock_export_options.filename_length = 255\n\n        result = sanitize_filename(\"test\\x00\\x08\\x1fname\")\n        assert result == \"testname\"\n\n\nclass TestSanitizeKey:\n    \"\"\"Test cases for sanitize_key function.\"\"\"\n\n    def test_basic_string(self) -> None:\n        \"\"\"Test sanitizing basic string.\"\"\"\n        result = sanitize_key(\"Test String\")\n        assert result == \"test_string\"\n\n    def test_special_characters(self) -> None:\n        \"\"\"Test sanitizing string with special characters.\"\"\"\n        result = sanitize_key(\"Test-Key: With @ Special % Characters!\")\n        assert result == \"test_key_with_special_characters\"\n\n    def test_multiple_underscores_collapse(self) -> None:\n        \"\"\"Test that multiple consecutive underscores are collapsed.\"\"\"\n        result = sanitize_key(\"test___multiple___underscores\")\n        assert result == \"test_multiple_underscores\"\n\n    def test_trim_leading_trailing_underscores(self) -> None:\n        \"\"\"Test that leading and trailing underscores are trimmed.\"\"\"\n        result = sanitize_key(\"__test_key__\")\n        assert result == \"test_key\"\n\n    def test_starts_with_number(self) -> None:\n        \"\"\"Test that string starting with number gets key_ prefix.\"\"\"\n        result = sanitize_key(\"123test\")\n        assert result == \"key_123test\"\n\n    def test_starts_with_special_character(self) -> None:\n        \"\"\"Test that string starting with special character becomes valid after processing.\"\"\"\n        result = sanitize_key(\"@test\")\n        # \"@test\" -> \"@test\" (lowercase) -> \"_test\" (replace @) -> \"test\" (strip _)\n        # Since \"test\" starts with 't' (a letter), no key_ prefix is added\n        assert result == \"test\"\n\n    def test_custom_connector(self) -> None:\n        \"\"\"Test using custom connector character.\"\"\"\n        result = sanitize_key(\"Test String\", connector=\"-\")\n        assert result == \"test-string\"\n\n    def test_already_valid_key(self) -> None:\n        \"\"\"Test that already valid key remains unchanged.\"\"\"\n        result = sanitize_key(\"valid_key\")\n        assert result == \"valid_key\"\n\n    def test_empty_string(self) -> None:\n        \"\"\"Test sanitizing empty string.\"\"\"\n        result = sanitize_key(\"\")\n        assert result == \"key_\"\n\n    def test_only_special_characters(self) -> None:\n        \"\"\"Test string with only special characters.\"\"\"\n        result = sanitize_key(\"@#$%\")\n        assert result == \"key_\"\n\n\nclass TestGithubHeadingSlug:\n    \"\"\"Test cases for github_heading_slug function.\"\"\"\n\n    def test_leading_hyphen_preserved(self) -> None:\n        \"\"\"Heading starting with hyphen keeps it — the reported bug.\"\"\"\n        assert github_heading_slug(\"- Final State\") == \"-final-state\"\n\n    def test_plain_heading(self) -> None:\n        assert github_heading_slug(\"Final State\") == \"final-state\"\n\n    def test_uppercase(self) -> None:\n        assert github_heading_slug(\"Hello World\") == \"hello-world\"\n\n    def test_special_chars_removed(self) -> None:\n        assert github_heading_slug(\"Hello, World!\") == \"hello-world\"\n\n    def test_multiple_spaces_collapsed(self) -> None:\n        assert github_heading_slug(\"Hello  World\") == \"hello-world\"\n\n    def test_trailing_hyphen(self) -> None:\n        assert github_heading_slug(\"Hello -\") == \"hello-\"\n\n    def test_empty_string(self) -> None:\n        assert github_heading_slug(\"\") == \"\"\n\n\nclass TestEscapeCharacterClass:\n    \"\"\"Test cases for escape_character_class function.\"\"\"\n\n    def test_escape_backslash(self) -> None:\n        \"\"\"Test escaping backslash character.\"\"\"\n        result = escape_character_class(\"\\\\\")\n        assert result == \"\\\\\\\\\"\n\n    def test_escape_dash(self) -> None:\n        \"\"\"Test escaping dash character.\"\"\"\n        result = escape_character_class(\"-\")\n        assert result == \"\\\\-\"\n\n    def test_escape_right_bracket(self) -> None:\n        \"\"\"Test escaping right bracket character.\"\"\"\n        result = escape_character_class(\"]\")\n        assert result == \"\\\\]\"\n\n    def test_escape_caret(self) -> None:\n        \"\"\"Test escaping caret character.\"\"\"\n        result = escape_character_class(\"^\")\n        assert result == \"\\\\^\"\n\n    def test_escape_multiple_characters(self) -> None:\n        \"\"\"Test escaping multiple special characters.\"\"\"\n        result = escape_character_class(\"\\\\-]^\")\n        assert result == \"\\\\\\\\\\\\-\\\\]\\\\^\"\n\n    def test_no_special_characters(self) -> None:\n        \"\"\"Test string with no special characters.\"\"\"\n        result = escape_character_class(\"abc123\")\n        assert result == \"abc123\"\n\n    def test_mixed_characters(self) -> None:\n        \"\"\"Test string with mix of special and normal characters.\"\"\"\n        result = escape_character_class(\"a-b]c^d\\\\e\")\n        assert result == \"a\\\\-b\\\\]c\\\\^d\\\\\\\\e\"\n\n    def test_empty_string(self) -> None:\n        \"\"\"Test escaping empty string.\"\"\"\n        result = escape_character_class(\"\")\n        assert result == \"\"\n"
  },
  {
    "path": "tests/unit/utils/test_lockfile.py",
    "content": "\"\"\"Unit tests for lockfile module.\"\"\"\n\nimport json\nimport tempfile\nfrom pathlib import Path\nfrom unittest.mock import MagicMock\nfrom unittest.mock import patch\n\nimport pytest\n\nfrom confluence_markdown_exporter.utils.lockfile import AttachmentEntry\nfrom confluence_markdown_exporter.utils.lockfile import ConfluenceLock\nfrom confluence_markdown_exporter.utils.lockfile import LockfileManager\nfrom confluence_markdown_exporter.utils.lockfile import OrgEntry\nfrom confluence_markdown_exporter.utils.lockfile import PageEntry\nfrom confluence_markdown_exporter.utils.lockfile import SpaceEntry\n\nLOCKFILE_FILENAME = \"confluence-lock.json\"\n_TEST_BASE_URL = \"https://test.atlassian.net\"\n_TEST_SPACE_KEY = \"TEST\"\n\n\ndef _make_mock_page(\n    page_id: int,\n    version_number: int,\n    export_path: str,\n    *,\n    base_url: str = _TEST_BASE_URL,\n    space_key: str = _TEST_SPACE_KEY,\n) -> MagicMock:\n    \"\"\"Create a mock page/descendant with the attributes used by LockfileManager.\"\"\"\n    page = MagicMock()\n    page.id = page_id\n    page.version.number = version_number\n    page.export_path = Path(export_path)\n    page.title = f\"Page {page_id}\"\n    page.base_url = base_url\n    page.space.key = space_key\n    return page\n\n\ndef _lock_with_pages(\n    pages: dict,\n    *,\n    base_url: str = _TEST_BASE_URL,\n    space_key: str = _TEST_SPACE_KEY,\n) -> ConfluenceLock:\n    \"\"\"Build a ConfluenceLock with pages nested under the given org/space.\"\"\"\n    return ConfluenceLock(\n        orgs={\n            base_url: OrgEntry(\n                spaces={space_key: SpaceEntry(pages=pages)}\n            )\n        }\n    )\n\n\ndef _lock_data(\n    pages: dict,\n    *,\n    base_url: str = _TEST_BASE_URL,\n    space_key: str = _TEST_SPACE_KEY,\n) -> dict:\n    \"\"\"Build a lockfile JSON-compatible dict with pages nested under org/space.\"\"\"\n    return {\n        \"lockfile_version\": 2,\n        \"last_export\": \"2025-01-01T00:00:00+00:00\",\n        \"orgs\": {\n            base_url: {\n                \"spaces\": {\n                    space_key: {\"pages\": pages}\n                }\n            }\n        },\n    }\n\n\n@pytest.fixture(autouse=True)\ndef _reset_lockfile_manager() -> None:\n    \"\"\"Reset LockfileManager class state before each test.\"\"\"\n    LockfileManager._lockfile_path = None\n    LockfileManager._lock = None\n    LockfileManager._output_path = None\n    LockfileManager._all_entries_snapshot = {}\n    LockfileManager._seen_page_ids = set()\n\n\nclass TestLockfileManagerInit:\n    \"\"\"Test cases for LockfileManager.init.\"\"\"\n\n    @patch(\"confluence_markdown_exporter.utils.app_data_store.get_settings\")\n    def test_init_creates_empty_lock_when_no_lockfile(\n        self,\n        mock_get_settings: MagicMock,\n    ) -> None:\n        \"\"\"When lockfile does not exist, init creates an empty lock.\"\"\"\n        with tempfile.TemporaryDirectory() as tmp:\n            mock_get_settings.return_value.export.output_path = Path(tmp)\n            mock_get_settings.return_value.export.lockfile_name = LOCKFILE_FILENAME\n\n            LockfileManager.init()\n\n            assert LockfileManager._lock is not None\n            assert LockfileManager._lock.orgs == {}\n            assert LockfileManager._lockfile_path == Path(tmp) / LOCKFILE_FILENAME\n\n    @patch(\"confluence_markdown_exporter.utils.app_data_store.get_settings\")\n    def test_init_loads_existing_lockfile(\n        self,\n        mock_get_settings: MagicMock,\n    ) -> None:\n        \"\"\"When lockfile exists, init loads its contents.\"\"\"\n        with tempfile.TemporaryDirectory() as tmp:\n            mock_get_settings.return_value.export.output_path = Path(tmp)\n            mock_get_settings.return_value.export.lockfile_name = LOCKFILE_FILENAME\n            lockfile_path = Path(tmp) / LOCKFILE_FILENAME\n            data = _lock_data(\n                {\"100\": {\"title\": \"Page A\", \"version\": 3, \"export_path\": \"space/Page A.md\"}}\n            )\n            lockfile_path.write_text(json.dumps(data), encoding=\"utf-8\")\n\n            LockfileManager.init()\n\n            assert LockfileManager._lock is not None\n            entry = LockfileManager._lock.get_page(\"100\")\n            assert entry is not None\n            assert entry.version == 3\n\n    @patch(\"confluence_markdown_exporter.utils.app_data_store.get_settings\")\n    def test_init_snapshots_all_entries(\n        self,\n        mock_get_settings: MagicMock,\n    ) -> None:\n        \"\"\"Init snapshots all lockfile entries for moved-page detection.\"\"\"\n        with tempfile.TemporaryDirectory() as tmp:\n            mock_get_settings.return_value.export.output_path = Path(tmp)\n            mock_get_settings.return_value.export.lockfile_name = LOCKFILE_FILENAME\n            lockfile_path = Path(tmp) / LOCKFILE_FILENAME\n            data = _lock_data({\n                \"100\": {\"title\": \"A\", \"version\": 1, \"export_path\": \"a.md\"},\n                \"200\": {\"title\": \"B\", \"version\": 2, \"export_path\": \"b.md\"},\n            })\n            lockfile_path.write_text(json.dumps(data), encoding=\"utf-8\")\n\n            LockfileManager.init()\n\n            assert set(LockfileManager._all_entries_snapshot.keys()) == {\"100\", \"200\"}\n            assert LockfileManager._seen_page_ids == set()\n\n    @patch(\"confluence_markdown_exporter.utils.app_data_store.get_settings\")\n    def test_init_discards_v1_lockfile(\n        self,\n        mock_get_settings: MagicMock,\n    ) -> None:\n        \"\"\"A v1 lockfile (flat pages dict) is discarded and replaced with an empty lock.\"\"\"\n        with tempfile.TemporaryDirectory() as tmp:\n            mock_get_settings.return_value.export.output_path = Path(tmp)\n            mock_get_settings.return_value.export.lockfile_name = LOCKFILE_FILENAME\n            lockfile_path = Path(tmp) / LOCKFILE_FILENAME\n            v1_data = {\n                \"lockfile_version\": 1,\n                \"last_export\": \"2025-01-01T00:00:00+00:00\",\n                \"pages\": {\n                    \"100\": {\"title\": \"Old Page\", \"version\": 1, \"export_path\": \"old.md\"},\n                },\n            }\n            lockfile_path.write_text(json.dumps(v1_data), encoding=\"utf-8\")\n\n            LockfileManager.init()\n\n            assert LockfileManager._lock is not None\n            assert LockfileManager._lock.orgs == {}\n\n\nclass TestLockfileManagerRecordPage:\n    \"\"\"Test cases for LockfileManager.record_page.\"\"\"\n\n    def test_record_page_creates_lockfile(self) -> None:\n        \"\"\"record_page creates the lockfile on disk and writes the page entry.\"\"\"\n        with tempfile.TemporaryDirectory() as tmp:\n            lockfile_path = Path(tmp) / LOCKFILE_FILENAME\n            LockfileManager._lockfile_path = lockfile_path\n            LockfileManager._lock = ConfluenceLock()\n\n            page = _make_mock_page(page_id=100, version_number=1, export_path=\"space/Page A.md\")\n            LockfileManager.record_page(page)\n\n            assert lockfile_path.exists()\n            saved = json.loads(lockfile_path.read_text(encoding=\"utf-8\"))\n            pages = saved[\"orgs\"][_TEST_BASE_URL][\"spaces\"][_TEST_SPACE_KEY][\"pages\"]\n            assert \"100\" in pages\n            assert pages[\"100\"][\"version\"] == 1\n\n    def test_record_page_does_nothing_when_not_initialized(self) -> None:\n        \"\"\"record_page is a no-op when LockfileManager has not been initialized.\"\"\"\n        page = _make_mock_page(page_id=100, version_number=1, export_path=\"space/Page A.md\")\n\n        # Should not raise\n        LockfileManager.record_page(page)\n\n    def test_record_page_updates_existing_entry(self) -> None:\n        \"\"\"record_page updates an existing page entry with the new version.\"\"\"\n        with tempfile.TemporaryDirectory() as tmp:\n            lockfile_path = Path(tmp) / LOCKFILE_FILENAME\n            LockfileManager._lockfile_path = lockfile_path\n            LockfileManager._lock = _lock_with_pages({\n                \"100\": PageEntry(title=\"Page A\", version=1, export_path=\"space/Page A.md\"),\n            })\n\n            page = _make_mock_page(page_id=100, version_number=2, export_path=\"space/Page A.md\")\n            LockfileManager.record_page(page)\n\n            saved = json.loads(lockfile_path.read_text(encoding=\"utf-8\"))\n            pages = saved[\"orgs\"][_TEST_BASE_URL][\"spaces\"][_TEST_SPACE_KEY][\"pages\"]\n            assert pages[\"100\"][\"version\"] == 2\n\n    def test_record_page_adds_to_seen_page_ids(self) -> None:\n        \"\"\"record_page adds the page ID to the seen set.\"\"\"\n        with tempfile.TemporaryDirectory() as tmp:\n            lockfile_path = Path(tmp) / LOCKFILE_FILENAME\n            LockfileManager._lockfile_path = lockfile_path\n            LockfileManager._lock = ConfluenceLock()\n\n            page = _make_mock_page(page_id=100, version_number=1, export_path=\"a.md\")\n            LockfileManager.record_page(page)\n\n            assert \"100\" in LockfileManager._seen_page_ids\n\n    def test_record_page_across_multiple_orgs_and_spaces(self) -> None:\n        \"\"\"Pages from different orgs and spaces are stored independently.\"\"\"\n        with tempfile.TemporaryDirectory() as tmp:\n            lockfile_path = Path(tmp) / LOCKFILE_FILENAME\n            LockfileManager._lockfile_path = lockfile_path\n            LockfileManager._lock = ConfluenceLock()\n\n            page_a = _make_mock_page(\n                100, 1, \"a.md\", base_url=\"https://org-a.atlassian.net\", space_key=\"AAA\"\n            )\n            page_b = _make_mock_page(\n                200, 1, \"b.md\", base_url=\"https://org-b.atlassian.net\", space_key=\"BBB\"\n            )\n            LockfileManager.record_page(page_a)\n            LockfileManager.record_page(page_b)\n\n            saved = json.loads(lockfile_path.read_text(encoding=\"utf-8\"))\n            assert \"100\" in saved[\"orgs\"][\"https://org-a.atlassian.net\"][\"spaces\"][\"AAA\"][\"pages\"]\n            assert \"200\" in saved[\"orgs\"][\"https://org-b.atlassian.net\"][\"spaces\"][\"BBB\"][\"pages\"]\n\n\nclass TestLockfileManagerShouldExport:\n    \"\"\"Test cases for LockfileManager.should_export.\"\"\"\n\n    def test_page_not_in_lockfile_should_export(self) -> None:\n        \"\"\"A page not present in the lockfile should be exported.\"\"\"\n        LockfileManager._lock = _lock_with_pages({\n            \"999\": PageEntry(title=\"Other\", version=1, export_path=\"other.md\"),\n        })\n\n        page = _make_mock_page(page_id=123, version_number=1, export_path=\"space/New.md\")\n        assert LockfileManager.should_export(page) is True\n\n    def test_page_in_lockfile_same_version_same_path_should_not_export(self) -> None:\n        \"\"\"A page with same version and same path should NOT be exported.\"\"\"\n        LockfileManager._lock = _lock_with_pages({\n            \"123\": PageEntry(title=\"Page A\", version=5, export_path=\"space/Page A.md\"),\n        })\n\n        page = _make_mock_page(page_id=123, version_number=5, export_path=\"space/Page A.md\")\n        assert LockfileManager.should_export(page) is False\n\n    def test_page_in_lockfile_different_version_should_export(self) -> None:\n        \"\"\"A page whose version has changed should be exported.\"\"\"\n        LockfileManager._lock = _lock_with_pages({\n            \"123\": PageEntry(title=\"Page A\", version=5, export_path=\"space/Page A.md\"),\n        })\n\n        page = _make_mock_page(page_id=123, version_number=6, export_path=\"space/Page A.md\")\n        assert LockfileManager.should_export(page) is True\n\n    def test_page_in_lockfile_different_export_path_should_export(self) -> None:\n        \"\"\"A page whose export path has changed (file moved) should be exported.\"\"\"\n        LockfileManager._lock = _lock_with_pages({\n            \"123\": PageEntry(title=\"Page A\", version=5, export_path=\"old/Page A.md\"),\n        })\n\n        page = _make_mock_page(page_id=123, version_number=5, export_path=\"new/Page A.md\")\n        assert LockfileManager.should_export(page) is True\n\n    def test_lock_is_none_should_export(self) -> None:\n        \"\"\"When lockfile manager is not initialized, all pages should be exported.\"\"\"\n        assert LockfileManager._lock is None\n\n        page = _make_mock_page(page_id=123, version_number=1, export_path=\"space/Page A.md\")\n        assert LockfileManager.should_export(page) is True\n\n    def test_missing_output_file_should_export(self) -> None:\n        \"\"\"A page whose output file no longer exists on disk should be re-exported.\"\"\"\n        with tempfile.TemporaryDirectory() as tmp:\n            output = Path(tmp)\n            LockfileManager._output_path = output\n            LockfileManager._lock = _lock_with_pages({\n                \"123\": PageEntry(title=\"Page A\", version=5, export_path=\"space/Page A.md\"),\n            })\n\n            # File does NOT exist on disk\n            page = _make_mock_page(page_id=123, version_number=5, export_path=\"space/Page A.md\")\n            assert LockfileManager.should_export(page) is True\n\n    def test_existing_output_file_unchanged_should_not_export(self) -> None:\n        \"\"\"A page whose output file exists and is up-to-date should NOT be re-exported.\"\"\"\n        with tempfile.TemporaryDirectory() as tmp:\n            output = Path(tmp)\n            md_file = output / \"space\" / \"Page A.md\"\n            md_file.parent.mkdir(parents=True)\n            md_file.write_text(\"content\")\n\n            LockfileManager._output_path = output\n            LockfileManager._lock = _lock_with_pages({\n                \"123\": PageEntry(title=\"Page A\", version=5, export_path=\"space/Page A.md\"),\n            })\n\n            page = _make_mock_page(page_id=123, version_number=5, export_path=\"space/Page A.md\")\n            assert LockfileManager.should_export(page) is False\n\n\nclass TestLockfileManagerMarkSeen:\n    \"\"\"Test cases for LockfileManager.mark_seen.\"\"\"\n\n    def test_mark_seen_adds_page_ids(self) -> None:\n        \"\"\"mark_seen adds page IDs to the seen set.\"\"\"\n        LockfileManager.mark_seen([100, 200, 300])\n        assert LockfileManager._seen_page_ids == {\"100\", \"200\", \"300\"}\n\n    def test_mark_seen_accumulates(self) -> None:\n        \"\"\"mark_seen accumulates across multiple calls.\"\"\"\n        LockfileManager.mark_seen([100])\n        LockfileManager.mark_seen([200])\n        assert LockfileManager._seen_page_ids == {\"100\", \"200\"}\n\n\nclass TestLockfileManagerCleanup:\n    \"\"\"Test cases for LockfileManager.cleanup.\"\"\"\n\n    def test_cleanup_noop_when_not_initialized(self) -> None:\n        \"\"\"Cleanup does nothing when not initialized.\"\"\"\n        LockfileManager.remove_pages(set())  # Should not raise\n\n    def test_cleanup_deletes_file_for_removed_page(self) -> None:\n        \"\"\"Pages deleted from Confluence have their files removed.\"\"\"\n        with tempfile.TemporaryDirectory() as tmp:\n            output = Path(tmp)\n            md_file = output / \"space\" / \"Removed.md\"\n            md_file.parent.mkdir(parents=True)\n            md_file.write_text(\"content\")\n\n            lockfile_path = output / LOCKFILE_FILENAME\n            LockfileManager._output_path = output\n            LockfileManager._lockfile_path = lockfile_path\n            LockfileManager._lock = _lock_with_pages({\n                \"100\": PageEntry(title=\"Removed\", version=1, export_path=\"space/Removed.md\"),\n            })\n            LockfileManager._all_entries_snapshot = dict(LockfileManager._lock.all_pages())\n            LockfileManager._seen_page_ids = set()  # page 100 not seen\n\n            LockfileManager.remove_pages({\"100\"})\n\n            assert not md_file.exists()\n\n    def test_cleanup_removes_entry_from_lockfile(self) -> None:\n        \"\"\"Deleted pages are removed from the lockfile.\"\"\"\n        with tempfile.TemporaryDirectory() as tmp:\n            output = Path(tmp)\n            lockfile_path = output / LOCKFILE_FILENAME\n            LockfileManager._output_path = output\n            LockfileManager._lockfile_path = lockfile_path\n            LockfileManager._lock = _lock_with_pages({\n                \"100\": PageEntry(title=\"Removed\", version=1, export_path=\"space/Removed.md\"),\n                \"200\": PageEntry(title=\"Kept\", version=1, export_path=\"space/Kept.md\"),\n            })\n            LockfileManager._all_entries_snapshot = dict(LockfileManager._lock.all_pages())\n            LockfileManager._seen_page_ids = {\"200\"}\n\n            LockfileManager.remove_pages({\"100\"})\n\n            saved = json.loads(lockfile_path.read_text(encoding=\"utf-8\"))\n            pages = saved[\"orgs\"][_TEST_BASE_URL][\"spaces\"][_TEST_SPACE_KEY][\"pages\"]\n            assert \"100\" not in pages\n            assert \"200\" in pages\n\n    def test_cleanup_deletes_old_file_for_moved_page(self) -> None:\n        \"\"\"When a page's export_path changes, the old file is deleted.\"\"\"\n        with tempfile.TemporaryDirectory() as tmp:\n            output = Path(tmp)\n            old_file = output / \"old\" / \"Page.md\"\n            old_file.parent.mkdir(parents=True)\n            old_file.write_text(\"old content\")\n\n            lockfile_path = output / LOCKFILE_FILENAME\n            LockfileManager._output_path = output\n            LockfileManager._lockfile_path = lockfile_path\n            LockfileManager._all_entries_snapshot = {\n                \"100\": PageEntry(title=\"Page\", version=1, export_path=\"old/Page.md\"),\n            }\n            LockfileManager._lock = _lock_with_pages({\n                \"100\": PageEntry(title=\"Page\", version=2, export_path=\"new/Page.md\"),\n            })\n            LockfileManager._seen_page_ids = {\"100\"}\n\n            LockfileManager.remove_pages(set())\n\n            assert not old_file.exists()\n\n    def test_cleanup_keeps_page_existing_on_confluence(self) -> None:\n        \"\"\"Unseen pages that still exist on Confluence are kept.\"\"\"\n        with tempfile.TemporaryDirectory() as tmp:\n            output = Path(tmp)\n            md_file = output / \"space\" / \"Still.md\"\n            md_file.parent.mkdir(parents=True)\n            md_file.write_text(\"content\")\n\n            lockfile_path = output / LOCKFILE_FILENAME\n            LockfileManager._output_path = output\n            LockfileManager._lockfile_path = lockfile_path\n            LockfileManager._lock = _lock_with_pages({\n                \"100\": PageEntry(title=\"Still\", version=1, export_path=\"space/Still.md\"),\n            })\n            LockfileManager._all_entries_snapshot = dict(LockfileManager._lock.all_pages())\n            LockfileManager._seen_page_ids = set()\n\n            LockfileManager.remove_pages(set())\n\n            assert md_file.exists()\n            assert LockfileManager._lock.get_page(\"100\") is not None\n\n    def test_cleanup_keeps_unchanged_seen_pages(self) -> None:\n        \"\"\"Pages that were seen during export are not checked via API.\"\"\"\n        with tempfile.TemporaryDirectory() as tmp:\n            output = Path(tmp)\n            lockfile_path = output / LOCKFILE_FILENAME\n            LockfileManager._output_path = output\n            LockfileManager._lockfile_path = lockfile_path\n            LockfileManager._lock = _lock_with_pages({\n                \"100\": PageEntry(title=\"Seen\", version=1, export_path=\"a.md\"),\n            })\n            LockfileManager._all_entries_snapshot = dict(LockfileManager._lock.all_pages())\n            LockfileManager._seen_page_ids = {\"100\"}\n\n            LockfileManager.remove_pages(set())\n            # fetch_deleted_page_ids is never called — all pages were seen\n\n    def test_cleanup_handles_already_deleted_file(self) -> None:\n        \"\"\"Cleanup does not fail when the file is already gone.\"\"\"\n        with tempfile.TemporaryDirectory() as tmp:\n            output = Path(tmp)\n            lockfile_path = output / LOCKFILE_FILENAME\n            LockfileManager._output_path = output\n            LockfileManager._lockfile_path = lockfile_path\n            LockfileManager._lock = _lock_with_pages({\n                \"100\": PageEntry(title=\"Gone\", version=1, export_path=\"space/Gone.md\"),\n            })\n            LockfileManager._all_entries_snapshot = dict(LockfileManager._lock.all_pages())\n            LockfileManager._seen_page_ids = set()\n\n            LockfileManager.remove_pages({\"100\"})  # Should not raise\n\n    def test_cleanup_api_failure_keeps_pages(self) -> None:\n        \"\"\"When API check fails, pages are kept (safe default).\"\"\"\n        with tempfile.TemporaryDirectory() as tmp:\n            output = Path(tmp)\n            md_file = output / \"space\" / \"Safe.md\"\n            md_file.parent.mkdir(parents=True)\n            md_file.write_text(\"content\")\n\n            lockfile_path = output / LOCKFILE_FILENAME\n            LockfileManager._output_path = output\n            LockfileManager._lockfile_path = lockfile_path\n            LockfileManager._lock = _lock_with_pages({\n                \"100\": PageEntry(title=\"Safe\", version=1, export_path=\"space/Safe.md\"),\n            })\n            LockfileManager._all_entries_snapshot = dict(LockfileManager._lock.all_pages())\n            LockfileManager._seen_page_ids = set()\n\n            # Pass empty set: safe default — don't delete anything on API failure\n            LockfileManager.remove_pages(set())\n\n            assert md_file.exists()\n            assert LockfileManager._lock.get_page(\"100\") is not None\n\n\nclass TestFetchDeletedPageIds:\n    \"\"\"Test cases for fetch_deleted_page_ids.\"\"\"\n\n    def test_empty_input_returns_empty(self) -> None:\n        \"\"\"Empty list returns empty set.\"\"\"\n        from confluence_markdown_exporter.confluence import fetch_deleted_page_ids\n\n        result = fetch_deleted_page_ids([], _TEST_BASE_URL)\n        assert result == set()\n\n    @patch(\"confluence_markdown_exporter.confluence.settings\")\n    @patch(\"confluence_markdown_exporter.confluence.get_thread_confluence\")\n    def test_returns_deleted_ids(\n        self, mock_get_client: MagicMock, mock_settings: MagicMock\n    ) -> None:\n        \"\"\"Returns IDs that no longer exist on Confluence.\"\"\"\n        mock_settings.connection_config.use_v2_api = True\n        mock_settings.export.existence_check_batch_size = 250\n        mock_client = MagicMock()\n        mock_client.get.return_value = {\n            \"results\": [{\"id\": \"100\"}, {\"id\": \"300\"}],\n        }\n        mock_get_client.return_value = mock_client\n\n        from confluence_markdown_exporter.confluence import fetch_deleted_page_ids\n\n        result = fetch_deleted_page_ids([\"100\", \"200\", \"300\"], _TEST_BASE_URL)\n        assert result == {\"200\"}\n\n    @patch(\"confluence_markdown_exporter.confluence.settings\")\n    @patch(\"confluence_markdown_exporter.confluence.get_thread_confluence\")\n    def test_api_error_returns_no_deleted_ids(\n        self, mock_get_client: MagicMock, mock_settings: MagicMock\n    ) -> None:\n        \"\"\"On API error, returns empty set (safe: don't delete anything).\"\"\"\n        mock_settings.connection_config.use_v2_api = True\n        mock_settings.export.existence_check_batch_size = 250\n        mock_client = MagicMock()\n        mock_client.get.side_effect = Exception(\"Network error\")\n        mock_get_client.return_value = mock_client\n\n        from confluence_markdown_exporter.confluence import fetch_deleted_page_ids\n\n        result = fetch_deleted_page_ids([\"100\", \"200\"], _TEST_BASE_URL)\n        assert result == set()\n\n    @patch(\"confluence_markdown_exporter.confluence.settings\")\n    @patch(\"confluence_markdown_exporter.confluence.get_thread_confluence\")\n    def test_batches_large_sets(\n        self, mock_get_client: MagicMock, mock_settings: MagicMock\n    ) -> None:\n        \"\"\"300 IDs are split into 2 v2-API batches of 250.\"\"\"\n        mock_settings.connection_config.use_v2_api = True\n        mock_settings.export.existence_check_batch_size = 250\n        ids = [str(i) for i in range(300)]\n        mock_client = MagicMock()\n        mock_client.get.return_value = {\"results\": []}\n        mock_get_client.return_value = mock_client\n\n        from confluence_markdown_exporter.confluence import fetch_deleted_page_ids\n\n        fetch_deleted_page_ids(ids, _TEST_BASE_URL)\n\n        assert mock_client.get.call_count == 2\n\n\nclass TestConfluenceLockSave:\n    \"\"\"Test cases for ConfluenceLock.save.\"\"\"\n\n    def test_save_is_atomic_on_success(self) -> None:\n        \"\"\"After save, the file contains valid, complete JSON.\"\"\"\n        with tempfile.TemporaryDirectory() as tmp:\n            lockfile_path = Path(tmp) / \"confluence-lock.json\"\n            lock = _lock_with_pages({\n                \"100\": PageEntry(title=\"Page A\", version=1, export_path=\"space/Page A.md\"),\n            })\n\n            lock.save(lockfile_path)\n\n            content = lockfile_path.read_text(encoding=\"utf-8\")\n            data = json.loads(content)\n            pages = data[\"orgs\"][_TEST_BASE_URL][\"spaces\"][_TEST_SPACE_KEY][\"pages\"]\n            assert pages[\"100\"][\"version\"] == 1\n            tmp_files = list(Path(tmp).glob(\"*.tmp\"))\n            assert tmp_files == []\n\n    def test_save_windows_permission_error_fallback(self) -> None:\n        \"\"\"On Windows, PermissionError from replace falls back to unlink + rename.\"\"\"\n        with tempfile.TemporaryDirectory() as tmp:\n            lockfile_path = Path(tmp) / \"confluence-lock.json\"\n            lock = _lock_with_pages({\n                \"100\": PageEntry(title=\"Page A\", version=1, export_path=\"space/Page A.md\"),\n            })\n\n            with patch(\n                \"confluence_markdown_exporter.utils.lockfile.Path.replace\",\n                side_effect=PermissionError(\"WinError 5\"),\n            ):\n                lock.save(lockfile_path)\n\n            content = lockfile_path.read_text(encoding=\"utf-8\")\n            data = json.loads(content)\n            pages = data[\"orgs\"][_TEST_BASE_URL][\"spaces\"][_TEST_SPACE_KEY][\"pages\"]\n            assert \"100\" in pages\n            tmp_files = list(Path(tmp).glob(\"*.tmp\"))\n            assert tmp_files == []\n\n    def test_save_cleans_up_tmp_on_error(self) -> None:\n        \"\"\"When writing fails, no .tmp files are left behind.\"\"\"\n        with tempfile.TemporaryDirectory() as tmp:\n            lockfile_path = Path(tmp) / \"confluence-lock.json\"\n            lock = _lock_with_pages({\n                \"100\": PageEntry(title=\"Page A\", version=1, export_path=\"space/Page A.md\"),\n            })\n\n            with (\n                patch(\n                    \"confluence_markdown_exporter.utils.lockfile.Path.replace\",\n                    side_effect=OSError(\"disk error\"),\n                ),\n                pytest.raises(OSError, match=\"disk error\"),\n            ):\n                lock.save(lockfile_path)\n\n            tmp_files = list(Path(tmp).glob(\"*.tmp\"))\n            assert tmp_files == []\n\n    def test_save_preserves_original_on_error(self) -> None:\n        \"\"\"When writing fails, the original lockfile is not corrupted.\"\"\"\n        with tempfile.TemporaryDirectory() as tmp:\n            lockfile_path = Path(tmp) / \"confluence-lock.json\"\n            original_data = _lock_data({\n                \"100\": {\"title\": \"Page A\", \"version\": 1, \"export_path\": \"space/Page A.md\"},\n            })\n            lockfile_path.write_text(json.dumps(original_data), encoding=\"utf-8\")\n\n            lock = _lock_with_pages({\n                \"200\": PageEntry(title=\"Page B\", version=1, export_path=\"space/Page B.md\"),\n            })\n\n            with (\n                patch(\n                    \"confluence_markdown_exporter.utils.lockfile.Path.replace\",\n                    side_effect=OSError(\"disk error\"),\n                ),\n                pytest.raises(OSError, match=\"disk error\"),\n            ):\n                lock.save(lockfile_path)\n\n            content = lockfile_path.read_text(encoding=\"utf-8\")\n            data = json.loads(content)\n            pages = data[\"orgs\"][_TEST_BASE_URL][\"spaces\"][_TEST_SPACE_KEY][\"pages\"]\n            assert \"100\" in pages\n            assert \"200\" not in pages\n\n    def test_save_with_delete_ids(self) -> None:\n        \"\"\"Save removes entries specified in delete_ids.\"\"\"\n        with tempfile.TemporaryDirectory() as tmp:\n            lockfile_path = Path(tmp) / \"confluence-lock.json\"\n            lock = _lock_with_pages({\n                \"100\": PageEntry(title=\"A\", version=1, export_path=\"a.md\"),\n                \"200\": PageEntry(title=\"B\", version=1, export_path=\"b.md\"),\n            })\n\n            lock.save(lockfile_path, delete_ids={\"100\"})\n\n            saved = json.loads(lockfile_path.read_text(encoding=\"utf-8\"))\n            pages = saved[\"orgs\"][_TEST_BASE_URL][\"spaces\"][_TEST_SPACE_KEY][\"pages\"]\n            assert \"100\" not in pages\n            assert \"200\" in pages\n\n\nclass TestConfluenceLockSaveSortsKeys:\n    \"\"\"Test cases for sorted key output in ConfluenceLock.save.\"\"\"\n\n    def test_save_sorts_page_keys(self) -> None:\n        \"\"\"Pages in the saved lockfile should be sorted by page ID.\"\"\"\n        with tempfile.TemporaryDirectory() as tmp:\n            lockfile_path = Path(tmp) / \"confluence-lock.json\"\n            lock = _lock_with_pages({\n                \"999\": PageEntry(title=\"Page C\", version=1, export_path=\"c.md\"),\n                \"123\": PageEntry(title=\"Page A\", version=2, export_path=\"a.md\"),\n                \"456\": PageEntry(title=\"Page B\", version=1, export_path=\"b.md\"),\n            })\n\n            lock.save(lockfile_path)\n\n            content = lockfile_path.read_text(encoding=\"utf-8\")\n            data = json.loads(content)\n            pages = data[\"orgs\"][_TEST_BASE_URL][\"spaces\"][_TEST_SPACE_KEY][\"pages\"]\n            page_ids = list(pages.keys())\n            assert page_ids == [\"123\", \"456\", \"999\"]\n\n    def test_save_preserves_model_field_order(self) -> None:\n        \"\"\"Top-level keys should follow the model field order.\"\"\"\n        with tempfile.TemporaryDirectory() as tmp:\n            lockfile_path = Path(tmp) / \"confluence-lock.json\"\n            lock = _lock_with_pages({\n                \"100\": PageEntry(title=\"Page A\", version=1, export_path=\"a.md\"),\n            })\n\n            lock.save(lockfile_path)\n\n            content = lockfile_path.read_text(encoding=\"utf-8\")\n            data = json.loads(content)\n            keys = list(data.keys())\n            assert keys == [\"lockfile_version\", \"last_export\", \"orgs\"]\n\n    def test_save_sorts_spaces_and_orgs(self) -> None:\n        \"\"\"Orgs and spaces within the saved lockfile should be sorted.\"\"\"\n        with tempfile.TemporaryDirectory() as tmp:\n            lockfile_path = Path(tmp) / \"confluence-lock.json\"\n            lock = ConfluenceLock(\n                orgs={\n                    \"https://z-org.atlassian.net\": OrgEntry(\n                        spaces={\n                            \"ZZZ\": SpaceEntry(\n                                pages={\"1\": PageEntry(title=\"P\", version=1, export_path=\"p.md\")}\n                            ),\n                            \"AAA\": SpaceEntry(pages={}),\n                        }\n                    ),\n                    \"https://a-org.atlassian.net\": OrgEntry(spaces={}),\n                }\n            )\n\n            lock.save(lockfile_path)\n\n            data = json.loads(lockfile_path.read_text(encoding=\"utf-8\"))\n            org_keys = list(data[\"orgs\"].keys())\n            assert org_keys == [\"https://a-org.atlassian.net\", \"https://z-org.atlassian.net\"]\n            space_keys = list(data[\"orgs\"][\"https://z-org.atlassian.net\"][\"spaces\"].keys())\n            assert space_keys == [\"AAA\", \"ZZZ\"]\n\n\nclass TestAttachmentEntryTracking:\n    \"\"\"Tests for attachment tracking in the lock file.\"\"\"\n\n    def test_page_entry_stores_attachments(self) -> None:\n        \"\"\"PageEntry persists attachment entries keyed by attachment ID.\"\"\"\n        entry = PageEntry(\n            title=\"Page\",\n            version=1,\n            export_path=\"a.md\",\n            attachments={\n                \"att1\": AttachmentEntry(version=3, path=\"space/attachments/uuid-a.png\"),\n            },\n        )\n        assert entry.attachments[\"att1\"].version == 3\n        assert entry.attachments[\"att1\"].path == \"space/attachments/uuid-a.png\"\n\n    def test_page_entry_attachments_default_empty(self) -> None:\n        \"\"\"PageEntry.attachments defaults to empty dict (backward-compatible).\"\"\"\n        entry = PageEntry(title=\"Page\", version=1, export_path=\"a.md\")\n        assert entry.attachments == {}\n\n    def test_lock_file_roundtrip_with_attachments(self) -> None:\n        \"\"\"Attachment entries survive a JSON save/load cycle.\"\"\"\n        with tempfile.TemporaryDirectory() as tmp:\n            lockfile_path = Path(tmp) / \"confluence-lock.json\"\n            lock = _lock_with_pages({\n                \"100\": PageEntry(\n                    title=\"Page A\",\n                    version=1,\n                    export_path=\"a.md\",\n                    attachments={\n                        \"att1\": AttachmentEntry(version=2, path=\"space/attachments/file.png\"),\n                    },\n                ),\n            })\n\n            lock.save(lockfile_path)\n\n            saved = json.loads(lockfile_path.read_text(encoding=\"utf-8\"))\n            org = saved[\"orgs\"][_TEST_BASE_URL][\"spaces\"][_TEST_SPACE_KEY]\n            att = org[\"pages\"][\"100\"][\"attachments\"][\"att1\"]\n            assert att[\"version\"] == 2\n            assert att[\"path\"] == \"space/attachments/file.png\"\n\n    def test_lock_file_missing_attachments_field_loads_as_empty(self) -> None:\n        \"\"\"Old lock files without 'attachments' field load without error.\"\"\"\n        with tempfile.TemporaryDirectory() as tmp:\n            lockfile_path = Path(tmp) / \"confluence-lock.json\"\n            old_format = _lock_data({\n                \"100\": {\"title\": \"Page A\", \"version\": 3, \"export_path\": \"a.md\"},\n            })\n            lockfile_path.write_text(json.dumps(old_format), encoding=\"utf-8\")\n\n            lock = ConfluenceLock.load(lockfile_path)\n\n            entry = lock.get_page(\"100\")\n            assert entry is not None\n            assert entry.attachments == {}\n\n    def test_record_page_stores_attachment_entries(self) -> None:\n        \"\"\"record_page persists attachment entries to the lock file.\"\"\"\n        with tempfile.TemporaryDirectory() as tmp:\n            lockfile_path = Path(tmp) / LOCKFILE_FILENAME\n            LockfileManager._lockfile_path = lockfile_path\n            LockfileManager._lock = ConfluenceLock()\n\n            page = _make_mock_page(page_id=100, version_number=1, export_path=\"a.md\")\n            attachment_entries = {\n                \"att42\": AttachmentEntry(version=5, path=\"space/attachments/abc.png\"),\n            }\n            LockfileManager.record_page(page, attachment_entries)\n\n            saved = json.loads(lockfile_path.read_text(encoding=\"utf-8\"))\n            pages = saved[\"orgs\"][_TEST_BASE_URL][\"spaces\"][_TEST_SPACE_KEY][\"pages\"]\n            att = pages[\"100\"][\"attachments\"][\"att42\"]\n            assert att[\"version\"] == 5\n            assert att[\"path\"] == \"space/attachments/abc.png\"\n\n    def test_get_page_attachment_entries_returns_entries(self) -> None:\n        \"\"\"get_page_attachment_entries returns the stored attachment dict for a page.\"\"\"\n        LockfileManager._lock = _lock_with_pages({\n            \"100\": PageEntry(\n                title=\"Page\",\n                version=1,\n                export_path=\"a.md\",\n                attachments={\n                    \"att1\": AttachmentEntry(version=2, path=\"space/attachments/x.png\"),\n                },\n            ),\n        })\n\n        entries = LockfileManager.get_page_attachment_entries(\"100\")\n        assert \"att1\" in entries\n        assert entries[\"att1\"].version == 2\n\n    def test_get_page_attachment_entries_returns_empty_for_unknown_page(self) -> None:\n        \"\"\"get_page_attachment_entries returns {} for a page not in the lock.\"\"\"\n        LockfileManager._lock = _lock_with_pages({})\n        assert LockfileManager.get_page_attachment_entries(\"999\") == {}\n\n    def test_get_page_attachment_entries_returns_empty_when_not_initialized(self) -> None:\n        \"\"\"get_page_attachment_entries returns {} when the manager is not initialized.\"\"\"\n        assert LockfileManager._lock is None\n        assert LockfileManager.get_page_attachment_entries(\"100\") == {}\n"
  },
  {
    "path": "tests/unit/utils/test_measure_time.py",
    "content": "\"\"\"Unit tests for the measure_time module.\"\"\"\n\nimport logging\nimport time\nfrom datetime import datetime\nfrom unittest.mock import patch\n\nimport pytest\n\nfrom confluence_markdown_exporter.utils.measure_time import measure\nfrom confluence_markdown_exporter.utils.measure_time import measure_time\n\n\nclass TestMeasureTime:\n    \"\"\"Test cases for measure_time decorator.\"\"\"\n\n    def test_measure_time_decorator_logs(self, caplog: pytest.LogCaptureFixture) -> None:\n        \"\"\"Test that measure_time decorator logs execution time.\"\"\"\n        logger_name = \"confluence_markdown_exporter.utils.measure_time\"\n        caplog.set_level(logging.INFO, logger=logger_name)\n\n        @measure_time\n        def test_function(x: int, y: int) -> int:\n            time.sleep(0.01)\n            return x + y\n\n        result = test_function(2, 3)\n        assert result == 5\n\n        log_messages = [record.message for record in caplog.records]\n        assert len(log_messages) == 1\n        assert \"Function 'test_function' took\" in log_messages[0]\n        assert \"seconds to execute\" in log_messages[0]\n\n    def test_measure_time_with_exception(self, caplog: pytest.LogCaptureFixture) -> None:\n        \"\"\"Test that measure_time decorator handles exceptions properly.\"\"\"\n        logger_name = \"confluence_markdown_exporter.utils.measure_time\"\n        caplog.set_level(logging.INFO, logger=logger_name)\n\n        @measure_time\n        def failing_function() -> None:\n            msg = \"Test error\"\n            raise ValueError(msg)\n\n        with pytest.raises(ValueError, match=\"Test error\"):\n            failing_function()\n\n        # The decorator should not log on exception (it only logs on success)\n        log_messages = [record.message for record in caplog.records]\n        assert len(log_messages) == 0\n\n    def test_measure_time_with_return_value(self) -> None:\n        \"\"\"Test that measure_time decorator preserves return values.\"\"\"\n\n        @measure_time\n        def function_with_return() -> str:\n            return \"test_result\"\n\n        result = function_with_return()\n        assert result == \"test_result\"\n\n    def test_measure_time_with_args_kwargs(self) -> None:\n        \"\"\"Test that measure_time decorator works with args and kwargs.\"\"\"\n\n        @measure_time\n        def function_with_params(a: int, b: int, c: int = 3) -> int:\n            return a + b + c\n\n        result = function_with_params(1, 2, c=4)\n        assert result == 7\n\n\nclass TestMeasureContextManager:\n    \"\"\"Test cases for measure context manager.\"\"\"\n\n    def test_measure_success(self) -> None:\n        \"\"\"Test measure context manager completes successfully.\"\"\"\n        with measure(\"Test Operation\"):\n            time.sleep(0.01)\n\n    def test_measure_with_exception(self) -> None:\n        \"\"\"Test measure context manager re-raises exceptions.\"\"\"\n\n        def failing_operation() -> None:\n            msg = \"Test error\"\n            raise ValueError(msg)\n\n        with pytest.raises(ValueError, match=\"Test error\"), measure(\"Failing Operation\"):\n            failing_operation()\n\n    def test_measure_debug_logs_start(self, caplog: pytest.LogCaptureFixture) -> None:\n        \"\"\"Test that measure logs the start time at DEBUG level.\"\"\"\n        logger_name = \"confluence_markdown_exporter.utils.measure_time\"\n        caplog.set_level(logging.DEBUG, logger=logger_name)\n\n        with measure(\"Debug Operation\"):\n            pass\n\n        debug_messages = [r.message for r in caplog.records if r.levelno == logging.DEBUG]\n        assert any(\"Started at\" in m for m in debug_messages)\n\n    @patch(\"confluence_markdown_exporter.utils.measure_time.datetime\")\n    def test_measure_timing_calculation(self, mock_datetime: pytest.MonkeyPatch) -> None:\n        \"\"\"Test that measure context manager does not suppress exceptions on timing.\"\"\"\n        start_time = datetime(2023, 1, 1, 12, 0, 0)\n        end_time = datetime(2023, 1, 1, 12, 0, 5)\n\n        mock_datetime.now.side_effect = [start_time, end_time]\n\n        with measure(\"Timed Operation\"):\n            pass\n\n    def test_measure_no_exception_propagation(self) -> None:\n        \"\"\"Test that measure context manager doesn't suppress exceptions.\"\"\"\n\n        class CustomError(Exception):\n            pass\n\n        def raise_error() -> None:\n            msg = \"Custom error message\"\n            raise CustomError(msg)\n\n        with pytest.raises(CustomError), measure(\"Exception Test\"):\n            raise_error()\n"
  },
  {
    "path": "tests/unit/utils/test_page_registry.py",
    "content": "\"\"\"Tests for PageTitleRegistry collision detection.\"\"\"\n\nfrom __future__ import annotations\n\nimport pytest\n\nfrom confluence_markdown_exporter.utils.page_registry import PageTitleRegistry\n\n\n@pytest.fixture(autouse=True)\ndef _clean_registry() -> None:\n    PageTitleRegistry.reset()\n    yield\n    PageTitleRegistry.reset()\n\n\ndef test_unique_title_not_ambiguous() -> None:\n    PageTitleRegistry.register(1, \"Shared Title\")\n    assert PageTitleRegistry.is_ambiguous(\"Shared Title\") is False\n\n\ndef test_two_pages_same_title_ambiguous() -> None:\n    PageTitleRegistry.register(1, \"Shared Title\")\n    PageTitleRegistry.register(2, \"Shared Title\")\n    assert PageTitleRegistry.is_ambiguous(\"Shared Title\") is True\n\n\ndef test_unknown_title_not_ambiguous() -> None:\n    assert PageTitleRegistry.is_ambiguous(\"Never Seen\") is False\n\n\ndef test_re_register_same_id_does_not_inflate_count() -> None:\n    PageTitleRegistry.register(1, \"Shared Title\")\n    PageTitleRegistry.register(1, \"Shared Title\")\n    PageTitleRegistry.register(1, \"Shared Title\")\n    assert PageTitleRegistry.is_ambiguous(\"Shared Title\") is False\n    assert PageTitleRegistry.title_count(\"Shared Title\") == 1\n\n\ndef test_renaming_page_updates_counts() -> None:\n    PageTitleRegistry.register(1, \"Old Title\")\n    PageTitleRegistry.register(2, \"Old Title\")\n    assert PageTitleRegistry.is_ambiguous(\"Old Title\") is True\n\n    PageTitleRegistry.register(1, \"New Title\")\n    assert PageTitleRegistry.is_ambiguous(\"Old Title\") is False\n    assert PageTitleRegistry.title_count(\"Old Title\") == 1\n    assert PageTitleRegistry.title_count(\"New Title\") == 1\n\n\ndef test_reset_clears_state() -> None:\n    PageTitleRegistry.register(1, \"X\")\n    PageTitleRegistry.register(2, \"X\")\n    PageTitleRegistry.reset()\n    assert PageTitleRegistry.is_ambiguous(\"X\") is False\n    assert PageTitleRegistry.title_count(\"X\") == 0\n\n\ndef test_blank_inputs_ignored() -> None:\n    PageTitleRegistry.register(0, \"X\")\n    PageTitleRegistry.register(1, \"\")\n    assert PageTitleRegistry.title_count(\"X\") == 0\n    assert PageTitleRegistry.title_count(\"\") == 0\n"
  },
  {
    "path": "tests/unit/utils/test_rich_console.py",
    "content": "\"\"\"Tests for the logging helpers in rich_console.\"\"\"\n\nimport logging\nfrom pathlib import Path\n\nfrom confluence_markdown_exporter.utils.rich_console import setup_logging\n\n\ndef test_setup_logging_writes_to_file(tmp_path: Path) -> None:\n    \"\"\"When a log_file is given, log records are also written to that file.\"\"\"\n    log_file = tmp_path / \"cme.log\"\n    setup_logging(\"DEBUG\", log_file=log_file)\n\n    logger = logging.getLogger(\"cme.test\")\n    logger.debug(\"a debug message\")\n    logger.info(\"an info message\")\n\n    for handler in logging.getLogger().handlers:\n        handler.flush()\n\n    contents = log_file.read_text(encoding=\"utf-8\")\n    assert \"a debug message\" in contents\n    assert \"an info message\" in contents\n\n\ndef test_setup_logging_without_file_does_not_create_one(tmp_path: Path) -> None:\n    \"\"\"Default invocation does not create a log file.\"\"\"\n    log_file = tmp_path / \"cme.log\"\n    setup_logging(\"INFO\")\n\n    logging.getLogger(\"cme.test\").info(\"hello\")\n\n    assert not log_file.exists()\n"
  },
  {
    "path": "tests/unit/utils/test_table_converter.py",
    "content": "\"\"\"Tests for the table_converter module.\"\"\"\n\nfrom bs4 import BeautifulSoup\n\nfrom confluence_markdown_exporter.utils.table_converter import TableConverter\n\n\nclass TestTableConverter:\n    \"\"\"Test TableConverter class.\"\"\"\n\n    def test_pipe_character_in_cell(self) -> None:\n        \"\"\"Test that pipe characters are escaped in table cells.\"\"\"\n        html = \"\"\"\n        <table>\n            <tr>\n                <th>Column 1</th>\n                <th>Column 2</th>\n            </tr>\n            <tr>\n                <td>Value with | pipe</td>\n                <td>Normal value</td>\n            </tr>\n        </table>\n        \"\"\"\n        BeautifulSoup(html, \"html.parser\")\n        converter = TableConverter()\n        result = converter.convert(html)\n\n        # The pipe character should be escaped\n        assert \"\\\\|\" in result\n        # The result should still have proper table structure\n        assert \"Column 1\" in result\n        assert \"Column 2\" in result\n        assert \"Value with\" in result\n        assert \"pipe\" in result\n\n    def test_multiple_pipes_in_cell(self) -> None:\n        \"\"\"Test that multiple pipe characters are escaped in table cells.\"\"\"\n        html = \"\"\"\n        <table>\n            <tr>\n                <th>Header</th>\n            </tr>\n            <tr>\n                <td>Value | with | multiple | pipes</td>\n            </tr>\n        </table>\n        \"\"\"\n        BeautifulSoup(html, \"html.parser\")\n        converter = TableConverter()\n        result = converter.convert(html)\n\n        # All pipe characters should be escaped (3 pipes in the content)\n        assert result.count(\"\\\\|\") == 3\n        assert \"Value\" in result\n        assert \"with\" in result\n        assert \"multiple\" in result\n        assert \"pipes\" in result\n\n    def test_pipe_character_in_header(self) -> None:\n        \"\"\"Test that pipe characters are escaped in table header cells.\"\"\"\n        html = \"\"\"\n        <table>\n            <tr>\n                <th>Column | 1</th>\n                <th>Column | 2</th>\n            </tr>\n            <tr>\n                <td>Value 1</td>\n                <td>Value 2</td>\n            </tr>\n        </table>\n        \"\"\"\n        converter = TableConverter()\n        result = converter.convert(html)\n\n        # The pipe characters in headers should be escaped (2 pipes)\n        assert result.count(\"\\\\|\") == 2\n        assert \"Column\" in result\n        assert \"Value 1\" in result\n        assert \"Value 2\" in result\n\n    def test_table_without_pipes(self) -> None:\n        \"\"\"Test normal table conversion without pipe characters.\"\"\"\n        html = \"\"\"\n        <table>\n            <tr>\n                <th>Name</th>\n                <th>Age</th>\n            </tr>\n            <tr>\n                <td>John</td>\n                <td>30</td>\n            </tr>\n        </table>\n        \"\"\"\n        converter = TableConverter()\n        result = converter.convert(html)\n\n        assert \"Name\" in result\n        assert \"Age\" in result\n        assert \"John\" in result\n        assert \"30\" in result\n        # Should have proper table structure\n        assert \"|\" in result\n        assert \"---\" in result\n        # Should have no escaped pipes\n        assert \"\\\\|\" not in result\n\n    def test_convert_p_bool_parent_tags_no_crash(self) -> None:\n        \"\"\"convert_p must not crash when markdownify passes bool instead of set.\"\"\"\n        converter = TableConverter()\n        el = BeautifulSoup(\"<p>text.</p>\", \"html.parser\").p\n        assert el is not None\n        result = converter.convert_p(el, \"text.\", parent_tags=False)  # type: ignore[arg-type]\n        assert \"text.\" in result\n\n    def test_convert_ol_bool_parent_tags_no_crash(self) -> None:\n        \"\"\"convert_ol must not crash when markdownify passes bool instead of set.\"\"\"\n        converter = TableConverter()\n        el = BeautifulSoup(\"<ol><li>item</li></ol>\", \"html.parser\").ol\n        assert el is not None\n        result = converter.convert_ol(el, \"item\", parent_tags=False)  # type: ignore[arg-type]\n        assert \"item\" in result\n\n    def test_convert_ul_bool_parent_tags_no_crash(self) -> None:\n        \"\"\"convert_ul must not crash when markdownify passes bool instead of set.\"\"\"\n        converter = TableConverter()\n        el = BeautifulSoup(\"<ul><li>item</li></ul>\", \"html.parser\").ul\n        assert el is not None\n        result = converter.convert_ul(el, \"item\", parent_tags=False)  # type: ignore[arg-type]\n        assert \"item\" in result\n\n    def test_single_item_ul_in_cell_strips_list_symbol(self) -> None:\n        \"\"\"Single-item ul in a table cell should not render a leading '- '.\"\"\"\n        html = \"\"\"\n        <table>\n            <tr>\n                <th>Header</th>\n            </tr>\n            <tr>\n                <td><ul><li>Only item</li></ul></td>\n            </tr>\n        </table>\n        \"\"\"\n        converter = TableConverter()\n        result = converter.convert(html)\n\n        assert \"Only item\" in result\n        assert \"- Only item\" not in result\n\n    def test_multi_item_ul_in_cell_keeps_list_symbols(self) -> None:\n        \"\"\"Multi-item ul in a table cell should still render with '- ' prefixes.\"\"\"\n        html = \"\"\"\n        <table>\n            <tr>\n                <th>Header</th>\n            </tr>\n            <tr>\n                <td><ul><li>First</li><li>Second</li></ul></td>\n            </tr>\n        </table>\n        \"\"\"\n        converter = TableConverter()\n        result = converter.convert(html)\n\n        assert \"- First\" in result\n        assert \"- Second\" in result\n\n    def test_ol_in_cell_with_empty_paragraph_shows_number(self) -> None:\n        \"\"\"Ol with empty <p> in a table cell should show the CSS-implicit number.\"\"\"\n        html = \"\"\"\n        <table>\n            <tr><th>Header</th></tr>\n            <tr><td><ol start=\"1\"><li><p></p></li></ol></td></tr>\n        </table>\n        \"\"\"\n        converter = TableConverter()\n        result = converter.convert(html)\n        assert \"1\" in result\n\n    def test_ol_in_cell_with_empty_paragraph_respects_start(self) -> None:\n        \"\"\"Ol with start attribute and empty <p> should use the start number.\"\"\"\n        html = \"\"\"\n        <table>\n            <tr><th>Header</th></tr>\n            <tr><td><ol start=\"3\"><li><p></p></li></ol></td></tr>\n        </table>\n        \"\"\"\n        converter = TableConverter()\n        result = converter.convert(html)\n        assert \"3\" in result\n\n    def test_ol_in_cell_with_content(self) -> None:\n        \"\"\"Ol with text content in a table cell should number each item.\"\"\"\n        html = \"\"\"\n        <table>\n            <tr><th>Header</th></tr>\n            <tr><td><ol start=\"1\"><li><p>alpha</p></li><li><p>beta</p></li></ol></td></tr>\n        </table>\n        \"\"\"\n        converter = TableConverter()\n        result = converter.convert(html)\n        assert \"1. alpha\" in result\n        assert \"2. beta\" in result\n        assert \"<br>\" in result\n\n    def test_ul_in_cell_with_paragraph_items(self) -> None:\n        \"\"\"Ul with <p>-wrapped items in a table cell should use '- ' bullet syntax.\"\"\"\n        html = \"\"\"\n        <table>\n            <tr><th>Header</th></tr>\n            <tr><td><ul><li><p>First</p></li><li><p>Second</p></li><li><p>Third</p></li></ul></td></tr>\n        </table>\n        \"\"\"\n        converter = TableConverter()\n        result = converter.convert(html)\n        assert \"- First\" in result\n        assert \"<br>- Second\" in result\n        assert \"<br>- Third\" in result\n\n    def test_td_detection_still_works_with_set_parent_tags(self) -> None:\n        \"\"\"set-based parent_tags (markdownify 1.x) must still trigger td-specific behaviour.\"\"\"\n        converter = TableConverter()\n        el = BeautifulSoup(\"<p>text.</p>\", \"html.parser\").p\n        assert el is not None\n        result = converter.convert_p(el, \"text.\", {\"td\", \"_inline\"})  # type: ignore[arg-type]\n        assert result.endswith(\"<br/>\")\n\n"
  },
  {
    "path": "tests/unit/utils/test_type_converter.py",
    "content": "\"\"\"Unit tests for type_converter module.\"\"\"\n\nimport pytest\n\nfrom confluence_markdown_exporter.utils.type_converter import str_to_bool\n\n\nclass TestStrToBool:\n    \"\"\"Test cases for str_to_bool function.\"\"\"\n\n    def test_true_values(self) -> None:\n        \"\"\"Test that various true values are converted correctly.\"\"\"\n        true_values = [\"true\", \"True\", \"TRUE\", \"1\", \"yes\", \"Yes\", \"YES\", \"on\", \"On\", \"ON\"]\n        for value in true_values:\n            assert str_to_bool(value) is True, f\"Failed for value: {value}\"\n\n    def test_false_values(self) -> None:\n        \"\"\"Test that various false values are converted correctly.\"\"\"\n        false_values = [\n            \"false\",\n            \"False\",\n            \"FALSE\",\n            \"0\",\n            \"no\",\n            \"No\",\n            \"NO\",\n            \"off\",\n            \"Off\",\n            \"OFF\",\n        ]\n        for value in false_values:\n            assert str_to_bool(value) is False, f\"Failed for value: {value}\"\n\n    def test_whitespace_handling(self) -> None:\n        \"\"\"Test that whitespace is properly stripped.\"\"\"\n        assert str_to_bool(\"  true  \") is True\n        assert str_to_bool(\"\\tfalse\\t\") is False\n        assert str_to_bool(\"\\n1\\n\") is True\n        assert str_to_bool(\"  0  \") is False\n\n    def test_invalid_values(self) -> None:\n        \"\"\"Test that invalid values raise ValueError.\"\"\"\n        invalid_values = [\"maybe\", \"2\", \"invalid\", \"\", \"true false\", \"truthy\"]\n        for value in invalid_values:\n            with pytest.raises(ValueError, match=f\"Invalid boolean string: '{value}'\"):\n                str_to_bool(value)\n\n    def test_empty_string(self) -> None:\n        \"\"\"Test that empty string raises ValueError.\"\"\"\n        with pytest.raises(ValueError, match=\"Invalid boolean string: ''\"):\n            str_to_bool(\"\")\n\n    def test_none_handling(self) -> None:\n        \"\"\"Test behavior with None (should raise AttributeError for strip method).\"\"\"\n        with pytest.raises(AttributeError):\n            str_to_bool(None)  # type: ignore[arg-type]\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"extends\": \"@docusaurus/tsconfig\",\n  \"compilerOptions\": {\n    \"baseUrl\": \".\"\n  },\n  \"exclude\": [\"build\", \".docusaurus\", \"node_modules\"]\n}\n"
  }
]