main 303989bb4f0e cached
91 files
617.7 KB
147.7k tokens
845 symbols
1 requests
Download .txt
Showing preview only (649K chars total). Download the full file or copy to clipboard to get everything.
Repository: Spenhouet/confluence-markdown-exporter
Branch: main
Commit: 303989bb4f0e
Files: 91
Total size: 617.7 KB

Directory structure:
gitextract_yof8yoxc/

├── .dockerignore
├── .github/
│   ├── FUNDING.yml
│   ├── ISSUE_TEMPLATE/
│   │   ├── 1_bug_report.yaml
│   │   ├── 2_feature_request.yaml
│   │   ├── 3_question.yaml
│   │   └── config.yml
│   ├── PULL_REQUEST_TEMPLATE.md
│   ├── dependabot.yml
│   └── workflows/
│       ├── docker-build.yml
│       ├── docker-publish.yml
│       ├── docs.yml
│       ├── python-build.yml
│       ├── python-publish.yml
│       └── release.yml
├── .gitignore
├── .python-version
├── .vscode/
│   ├── extensions.json
│   ├── launch.json
│   ├── settings.json
│   └── tasks.json
├── CONTRIBUTING.md
├── Dockerfile
├── LICENSE
├── README.md
├── confluence_markdown_exporter/
│   ├── __init__.py
│   ├── api_clients.py
│   ├── config.py
│   ├── confluence.py
│   ├── main.py
│   └── utils/
│       ├── __init__.py
│       ├── app_data_store.py
│       ├── config_interactive.py
│       ├── drawio_converter.py
│       ├── export.py
│       ├── lockfile.py
│       ├── measure_time.py
│       ├── page_registry.py
│       ├── rich_console.py
│       ├── table_converter.py
│       └── type_converter.py
├── docs/
│   ├── compatibility.md
│   ├── configuration/
│   │   ├── authentication.md
│   │   ├── ci.md
│   │   ├── index.md
│   │   ├── options.md
│   │   └── target-systems.md
│   ├── contributing.md
│   ├── docker.md
│   ├── features.md
│   ├── installation.md
│   ├── intro.md
│   ├── troubleshooting.md
│   └── usage.md
├── docusaurus.config.ts
├── package.json
├── pyproject.toml
├── scripts/
│   ├── build-versions.mjs
│   └── bump-docs-version.sh
├── sidebars.ts
├── src/
│   ├── components/
│   │   ├── HomepageFeatures/
│   │   │   ├── index.tsx
│   │   │   └── styles.module.css
│   │   └── quickstart/
│   │       └── index.tsx
│   ├── css/
│   │   └── custom.css
│   └── pages/
│       ├── index.module.css
│       └── index.tsx
├── tests/
│   ├── __init__.py
│   ├── conftest.py
│   ├── integration/
│   │   ├── __init__.py
│   │   └── test_cli_integration.py
│   └── unit/
│       ├── __init__.py
│       ├── test_alert_conversion.py
│       ├── test_api_clients.py
│       ├── test_confluence.py
│       ├── test_emoticon_conversion.py
│       ├── test_include_macro_conversion.py
│       ├── test_main.py
│       ├── test_nbsp_fix.py
│       ├── test_plantuml_code_block_detection.py
│       ├── test_plantuml_conversion.py
│       ├── test_template_placeholders.py
│       └── utils/
│           ├── __init__.py
│           ├── test_app_data_store_env.py
│           ├── test_drawio_converter.py
│           ├── test_export.py
│           ├── test_lockfile.py
│           ├── test_measure_time.py
│           ├── test_page_registry.py
│           ├── test_rich_console.py
│           ├── test_table_converter.py
│           └── test_type_converter.py
└── tsconfig.json

================================================
FILE CONTENTS
================================================

================================================
FILE: .dockerignore
================================================
.git
.github
.claude
.venv
dist
build
*.egg-info
__pycache__
.pytest_cache
.ruff_cache
.mypy_cache
node_modules
tests
scratch
AIRAscore
.vscode
.idea
*.log
.DS_Store


================================================
FILE: .github/FUNDING.yml
================================================
# These are supported funding model platforms

github: Spenhouet


================================================
FILE: .github/ISSUE_TEMPLATE/1_bug_report.yaml
================================================
name: Bug report
description: Report an error or unexpected behavior
labels: ["bug"]
body:
  - type: markdown
    attributes:
      value: |
        Thank you for taking the time to report an issue! We're glad to have you involved with confluence-markdown-exporter.

        **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)).**

  - type: markdown
    attributes:
      value: |
        ### Diagnostic info
        Run `cme bugreport` and paste the full output in the **Diagnostic info** field below.
        This command prints your version, system details, and configuration — with all secrets automatically redacted.

  - type: textarea
    attributes:
      label: Description
      description: |
        A clear and concise description of the bug, including a minimal reproducible example.

        Be sure to include the command you invoked (e.g., `cme pages https://company.atlassian.net/wiki/spaces/KEY/pages/123/Title`).
    validations:
      required: true

  - type: textarea
    attributes:
      label: Diagnostic info
      description: |
        Paste the output of `cme bugreport` here.
        This includes your version, Python/OS info, and configuration with secrets redacted.
      placeholder: |
        ## Bug Report Diagnostic Info

        ### Version
        confluence-markdown-exporter x.y.z

        ### System
        Python: ...
        Platform: ...
        Architecture: ...

        ### Config
        Config file: ...
        ```yaml
        ...
        ```
      render: markdown
    validations:
      required: false

  - type: input
    attributes:
      label: Version
      description: |
        What version of confluence-markdown-exporter are you using?
        (Already included in `cme bugreport` output — fill in here only if you didn't run that command.)
      placeholder: e.g., confluence-markdown-exporter 4.0.3
    validations:
      required: false

  - type: input
    attributes:
      label: Confluence Version
      description: |
        What Confluence version are you using? Include whether it's Cloud or Server/Data Center.
        Example: `Confluence Cloud` or `Confluence Server 7.19.2`
      placeholder: e.g., Confluence Cloud or Confluence Server 7.19.2
    validations:
      required: false

  - type: input
    attributes:
      label: Jira Version
      description: |
        What Jira version are you using (or not)? Include whether it's Cloud or Server/Data Center.
        Example: `Jira Cloud` or `Jira Server 8.20.5`
      placeholder: e.g., Jira Cloud or Jira Server 8.20.5
    validations:
      required: false


================================================
FILE: .github/ISSUE_TEMPLATE/2_feature_request.yaml
================================================
name: Feature request
description: Suggest a new feature or enhancement
labels: ["enhancement"]
body:
  - type: markdown
    attributes:
      value: |
        Thank you for taking the time to suggest a feature! We're glad to have you involved with confluence-markdown-exporter.

        **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)).**

  - type: textarea
    attributes:
      label: Problem Description
      description: |
        A clear and concise description of the problem or limitation you're experiencing.

        What is the use case? What workflow or task would this feature enable or improve?
    validations:
      required: true

  - type: textarea
    attributes:
      label: Proposed Solution
      description: |
        A clear and concise description of what you want to happen.

        How do you envision this feature working? What would the ideal implementation look like?

        If you have ideas about commands, options, or configuration, please include examples:
        ```bash
        # Example command or usage
        confluence-markdown-exporter <your-suggested-command>
        ```
    validations:
      required: true

  - type: textarea
    attributes:
      label: Alternatives Considered
      description: |
        A clear and concise description of any alternative solutions or features you've considered.

        Are there workarounds you're currently using? What other tools or approaches have you tried?
    validations:
      required: false

  - type: textarea
    attributes:
      label: Use Cases
      description: |
        Describe specific scenarios where this feature would be helpful.

        Please provide concrete examples of how you (or others) would use this feature in practice.
    validations:
      required: false


================================================
FILE: .github/ISSUE_TEMPLATE/3_question.yaml
================================================
name: Question
description: Ask a question about confluence-markdown-exporter
labels: ["question"]
body:
  - type: textarea
    attributes:
      label: Question
      description: Describe your question in detail.
    validations:
      required: true

  - type: input
    attributes:
      label: Version
      description: What version of confluence-markdown-exporter are you using? (see `confluence-markdown-exporter version`)
      placeholder: e.g., confluence-markdown-exporter 3.0.3
    validations:
      required: false

  - type: input
    attributes:
      label: Confluence Version
      description: |
        What Confluence version are you using? Include whether it's Cloud or Server/Data Center.
        Example: `Confluence Cloud` or `Confluence Server 7.19.2`
      placeholder: e.g., Confluence Cloud or Confluence Server 7.19.2
    validations:
      required: false

  - type: input
    attributes:
      label: Jira Version
      description: |
        What Jira version are you using (or not)? Include whether it's Cloud or Server/Data Center.
        Example: `Jira Cloud` or `Jira Server 8.20.5`
      placeholder: e.g., Jira Cloud or Jira Server 8.20.5
    validations:
      required: false


================================================
FILE: .github/ISSUE_TEMPLATE/config.yml
================================================
blank_issues_enabled: false
contact_links:
  - name: Documentation
    url: https://github.com/Spenhouet/confluence-markdown-exporter#readme
    about: Read the project documentation and README


================================================
FILE: .github/PULL_REQUEST_TEMPLATE.md
================================================
<!--
Thank you for contributing to confluence-markdown-exporter! To help us out with reviewing, please consider the following:

- Does this pull request include a summary of the change? (See below.)
- Does this pull request include a descriptive title?
- Does this pull request include references to any relevant issues?
-->

## Summary

<!-- What's the purpose of the change? What does it do, and why? -->

## Test Plan

<!-- How was it tested? -->


================================================
FILE: .github/dependabot.yml
================================================
version: 2
updates:
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "daily"
    groups:
      actions:
        patterns:
          - "*"


================================================
FILE: .github/workflows/docker-build.yml
================================================
name: Build Docker image

on:
  pull_request:
    branches: [main]
    paths:
      - Dockerfile
      - .dockerignore
      - .github/workflows/docker-build.yml
      - pyproject.toml
      - uv.lock
      - confluence_markdown_exporter/**
  # Also build on push to main so the GHA cache is primed on the default
  # branch. Tag-triggered publish runs fall back to the default branch's
  # cache, which would otherwise stay cold until the first release.
  push:
    branches: [main]
    paths:
      - Dockerfile
      - .dockerignore
      - .github/workflows/docker-build.yml
      - pyproject.toml
      - uv.lock
      - confluence_markdown_exporter/**

permissions:
  contents: read

jobs:
  build:
    name: Build image (PR verification)
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v6

      - name: Set up QEMU
        uses: docker/setup-qemu-action@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v4

      - name: Build (no push)
        uses: docker/build-push-action@v7
        with:
          context: .
          file: ./Dockerfile
          platforms: linux/amd64,linux/arm64
          push: false
          cache-from: type=gha
          cache-to: type=gha,mode=max,ignore-error=true


================================================
FILE: .github/workflows/docker-publish.yml
================================================
name: Publish Docker image

on:
  workflow_call:
    inputs:
      version:
        description: "Release version to publish (e.g. 5.1.0)"
        required: true
        type: string
  workflow_dispatch:
    inputs:
      version:
        description: "Release version to publish (e.g. 5.1.0). Must match an existing git tag."
        required: true
        type: string

permissions:
  contents: read

jobs:
  publish:
    name: Publish image to Docker Hub
    runs-on: ubuntu-latest
    environment:
      name: dockerhub
      url: https://hub.docker.com/r/${{ vars.DOCKERHUB_IMAGE || 'spenhouet/confluence-markdown-exporter' }}
    env:
      IMAGE_NAME: ${{ vars.DOCKERHUB_IMAGE || 'spenhouet/confluence-markdown-exporter' }}
    steps:
      - name: Checkout release tag
        uses: actions/checkout@v6
        with:
          ref: ${{ inputs.version }}

      - name: Set up QEMU
        uses: docker/setup-qemu-action@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v4

      - name: Log in to Docker Hub
        uses: docker/login-action@v4
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v6
        with:
          images: ${{ env.IMAGE_NAME }}
          tags: |
            type=semver,pattern={{version}},value=${{ inputs.version }}
            type=semver,pattern={{major}}.{{minor}},value=${{ inputs.version }}
            type=semver,pattern={{major}},value=${{ inputs.version }}
            type=raw,value=latest
          labels: |
            org.opencontainers.image.title=confluence-markdown-exporter
            org.opencontainers.image.description=Export Confluence pages to Markdown
            org.opencontainers.image.url=https://github.com/${{ github.repository }}
            org.opencontainers.image.source=https://github.com/${{ github.repository }}
            org.opencontainers.image.version=${{ inputs.version }}
            org.opencontainers.image.licenses=MIT

      - name: Build and push
        uses: docker/build-push-action@v7
        with:
          context: .
          file: ./Dockerfile
          platforms: linux/amd64,linux/arm64
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max,ignore-error=true
          provenance: true

      - name: Update Docker Hub description
        uses: peter-evans/dockerhub-description@v5
        continue-on-error: true
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}
          repository: ${{ env.IMAGE_NAME }}
          short-description: Export Confluence pages to Markdown (CLI)
          readme-filepath: ./README.md


================================================
FILE: .github/workflows/docs.yml
================================================
name: Deploy docs

on:
  push:
    branches: [main]
    paths:
      - "docs/**"
      - "versioned_docs/**"
      - "versioned_sidebars/**"
      - "versions.json"
      - "src/**"
      - "static/**"
      - "docusaurus.config.ts"
      - "sidebars.ts"
      - "tsconfig.json"
      - "package.json"
      - "package-lock.json"
      - ".github/workflows/docs.yml"
  workflow_dispatch:

permissions:
  contents: read
  pages: write
  id-token: write

concurrency:
  group: pages
  cancel-in-progress: false

jobs:
  build:
    name: Build docs
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
        with:
          fetch-depth: 0

      - name: Setup Node
        uses: actions/setup-node@v6
        with:
          node-version: 20
          cache: npm

      - name: Install dependencies
        run: npm ci

      - name: Build site (with versioned docs from git tags)
        run: npm run build:versioned

      - name: Upload artifact
        uses: actions/upload-pages-artifact@v5
        with:
          path: build

  deploy:
    name: Deploy to GitHub Pages
    needs: build
    runs-on: ubuntu-latest
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    steps:
      - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@v5


================================================
FILE: .github/workflows/python-build.yml
================================================
name: Build Python package

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    name: Test, lint and build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6

      - name: Install uv
        uses: astral-sh/setup-uv@v7
        with:
          enable-cache: true

      - name: Install dependencies
        run: uv sync --locked --all-groups

      - name: Run linting with ruff
        run: uv run ruff check

      - name: Run tests with pytest
        run: uv run pytest

      - name: Test build (with sources for development)
        run: uv build

      - name: Test build (without sources for publication)
        run: |
          rm -rf dist/
          uv build --no-sources

      - name: Test package installation and import
        run: |
          uv run --with dist/*.whl --no-project -- python -c "import confluence_markdown_exporter; print('Package imports successfully')"

      - name: Test CLI commands
        run: |
          uv run --with dist/*.whl --no-project confluence-markdown-exporter --help
          uv run --with dist/*.whl --no-project cme --help

      - name: Upload build artifacts for inspection
        uses: actions/upload-artifact@v7
        with:
          name: build-artifacts
          path: dist/
          retention-days: 5


================================================
FILE: .github/workflows/python-publish.yml
================================================
name: Publish Python package

on:
  workflow_call:
    inputs:
      version:
        description: "Release version to publish (e.g. 5.1.0)"
        required: true
        type: string
  workflow_dispatch:
    inputs:
      version:
        description: "Release version to publish (e.g. 5.1.0). Must match an existing git tag."
        required: true
        type: string

permissions:
  contents: write
  id-token: write
  attestations: write

jobs:
  publish:
    name: Publish to PyPI
    runs-on: ubuntu-latest
    environment:
      name: release
      url: https://pypi.org/p/confluence-markdown-exporter
    steps:
      - name: Checkout release tag
        uses: actions/checkout@v6
        with:
          ref: ${{ inputs.version }}

      - name: Install uv
        uses: astral-sh/setup-uv@v7
        with:
          enable-cache: true

      - name: Install dependencies
        run: uv sync --locked --all-groups

      - name: Build distributions
        run: uv build --no-sources

      - name: Generate artifact attestations
        uses: actions/attest-build-provenance@v4.1.0
        with:
          subject-path: "dist/*"

      - name: Publish to PyPI
        run: uv publish

      - name: Sign the distributions with Sigstore
        uses: sigstore/gh-action-sigstore-python@v3.3.0
        with:
          inputs: >-
            ./dist/*.tar.gz
            ./dist/*.whl

      - name: Upload signed artifacts to GitHub Release
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          gh release upload "${{ inputs.version }}" dist/** \
            --repo "$GITHUB_REPOSITORY"


================================================
FILE: .github/workflows/release.yml
================================================
name: Release

on:
  workflow_dispatch:
    inputs:
      version_bump:
        description: "Version bump type"
        required: true
        default: "patch"
        type: choice
        options:
          - patch
          - minor
          - major
          - alpha
          - beta
          - rc
      custom_version:
        description: "Custom version (leave empty to use bump type)"
        required: false
        type: string

permissions:
  contents: write
  id-token: write
  attestations: write

jobs:
  release:
    name: Bump version and create release
    runs-on: ubuntu-latest
    permissions:
      contents: write
    outputs:
      version: ${{ steps.export-version.outputs.value }}
    steps:
      - uses: actions/checkout@v6
        with:
          token: ${{ secrets.GITHUB_TOKEN }}

      - name: Install uv
        uses: astral-sh/setup-uv@v7
        with:
          enable-cache: true

      - name: Install dependencies
        run: uv sync --locked --all-groups

      - name: Update version (custom)
        if: ${{ github.event.inputs.custom_version != '' }}
        run: |
          uv version ${{ github.event.inputs.custom_version }}
          echo "NEW_VERSION=${{ github.event.inputs.custom_version }}" >> $GITHUB_ENV

      - name: Update version (bump)
        if: ${{ github.event.inputs.custom_version == '' }}
        run: |
          NEW_VERSION=$(uv version --bump ${{ github.event.inputs.version_bump }} | awk '{print $NF}')
          echo "NEW_VERSION=${NEW_VERSION}" >> $GITHUB_ENV

      - name: Export version as job output
        id: export-version
        run: echo "value=${NEW_VERSION}" >> "$GITHUB_OUTPUT"

      - name: Test build with new version
        run: |
          uv build --no-sources
          uv run --with dist/*.whl --no-project -- python -c "import confluence_markdown_exporter; print('Package imports successfully')"

      - name: Update version references in README and docs
        run: scripts/bump-docs-version.sh "${{ env.NEW_VERSION }}"

      - name: Commit version update
        run: |
          git config --local user.email "action@github.com"
          git config --local user.name "GitHub Action"
          # -u: stage modifications to tracked files only; never add untracked files.
          git add -u pyproject.toml uv.lock README.md docs src
          git diff --cached --quiet || git commit -m "Bump version to ${{ env.NEW_VERSION }}"
          git push

      - name: Create release tag
        run: |
          git tag "${{ env.NEW_VERSION }}"
          git push origin "${{ env.NEW_VERSION }}"

      - name: Create and publish GitHub Release
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          gh release create "${{ env.NEW_VERSION }}" \
            --title "Release ${{ env.NEW_VERSION }}" \
            --generate-notes

  publish-python:
    name: Publish Python package
    needs: release
    uses: ./.github/workflows/python-publish.yml
    with:
      version: ${{ needs.release.outputs.version }}
    secrets: inherit

  publish-docker:
    name: Publish Docker image
    needs: release
    uses: ./.github/workflows/docker-publish.yml
    with:
      version: ${{ needs.release.outputs.version }}
    secrets: inherit


================================================
FILE: .gitignore
================================================
### Custom ###

**/*.env
scratch/
log/
.ssh/

_tmp/*
*.tar.gz
*.sh~

*.zip
*.jpg

### LLM Agents ###
# The source stays vendor agnostic
.claude/

### Virtual Environments ###
.venv/
.venv-*/

# Created by https://www.gitignore.io/api/code,python
# Edit at https://www.gitignore.io/?templates=code,python

### Code ###
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json

### Docusaurus ###
node_modules/
.docusaurus/
.docusaurus-faster/
docs-build/
# Versioned docs are generated at build time from git tags by scripts/build-versions.mjs
versioned_docs/
versioned_sidebars/
versions.json

### Python ###
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class

# C extensions
*.so

# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST

# PyInstaller
#  Usually these files are written by a python script from a template
#  before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec

# Installer logs
pip-log.txt
pip-delete-this-directory.txt

# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
.pytest_cache/

# Translations
*.mo
*.pot

# Scrapy stuff:
.scrapy

# Sphinx documentation
docs/_build/

# PyBuilder
target/

# pipenv
#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
#   However, in case of collaboration, if having platform-specific dependencies or dependencies
#   having no cross-platform support, pipenv may install dependencies that don't work, or not
#   install all needed dependencies.
#Pipfile.lock

# celery beat schedule file
celerybeat-schedule

# SageMath parsed files
*.sage.py

# Spyder project settings
.spyderproject
.spyproject

# Rope project settings
.ropeproject

# Mr Developer
.mr.developer.cfg
.project
.pydevproject

# mkdocs documentation
/site

# mypy
.mypy_cache/
.dmypy.json
dmypy.json

# Pyre type checker
.pyre/

# End of https://www.gitignore.io/api/code,python


# Beads / Dolt files (added by bd init)
.dolt/
*.db
.beads-credential-key


================================================
FILE: .python-version
================================================
3.10.12


================================================
FILE: .vscode/extensions.json
================================================
{
  // See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations.
  // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp
  // List of extensions which should be recommended for users of this workspace.
  "recommendations": [
    "astral-sh.ty",
    "charliermarsh.ruff",
    "github.vscode-github-actions",
    "ms-python.python",
    "njpwerner.autodocstring",
  ],
  // List of extensions recommended by VS Code that should not be recommended for users of this workspace.
  "unwantedRecommendations": []
}

================================================
FILE: .vscode/launch.json
================================================
{
  // Use IntelliSense to learn about possible attributes.
  // Hover to view descriptions of existing attributes.
  // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Python: Current File",
      "type": "debugpy",
      "request": "launch",
      "program": "${file}",
      "justMyCode": false,
      "console": "integratedTerminal",
      "cwd": "${workspaceFolder}",
      "env": {
        "PYTHONPATH": "${workspaceRoot}"
      }
    },
    {
      "name": "Python: Export Page(s)",
      "type": "debugpy",
      "request": "launch",
      "program": "${workspaceFolder}/confluence_markdown_exporter/main.py",
      "justMyCode": false,
      "args": [
        "pages",
        "<page-url>"
      ],
      "console": "integratedTerminal",
      "cwd": "${workspaceFolder}",
      "env": {
        "PYTHONPATH": "${workspaceRoot}",
        "CME_CONFIG_PATH": "scratch/cme_config.json",
        "CME_EXPORT__LOG_LEVEL": "DEBUG",
        "CME_EXPORT__OUTPUT_PATH": "scratch"
      }
    },
    {
      "name": "Python: Export Page(s) with Descendants",
      "type": "debugpy",
      "request": "launch",
      "program": "${workspaceFolder}/confluence_markdown_exporter/main.py",
      "justMyCode": false,
      "args": [
        "pages-with-descendants",
        "<page-url>"
      ],
      "console": "integratedTerminal",
      "cwd": "${workspaceFolder}",
      "env": {
        "PYTHONPATH": "${workspaceRoot}",
        "CME_CONFIG_PATH": "scratch/cme_config.json",
        "CME_EXPORT__LOG_LEVEL": "DEBUG",
        "CME_EXPORT__OUTPUT_PATH": "scratch"
      }
    },
    {
      "name": "Python: Export Space(s)",
      "type": "debugpy",
      "request": "launch",
      "program": "${workspaceFolder}/confluence_markdown_exporter/main.py",
      "justMyCode": false,
      "args": [
        "spaces",
        "<space-url>"
      ],
      "console": "integratedTerminal",
      "cwd": "${workspaceFolder}",
      "env": {
        "PYTHONPATH": "${workspaceRoot}",
        "CME_CONFIG_PATH": "scratch/cme_config.json",
        "CME_EXPORT__LOG_LEVEL": "DEBUG",
        "CME_EXPORT__OUTPUT_PATH": "scratch"
      }
    },
    {
      "name": "Python: Export Org(s)",
      "type": "debugpy",
      "request": "launch",
      "program": "${workspaceFolder}/confluence_markdown_exporter/main.py",
      "justMyCode": false,
      "args": [
        "orgs",
        "<base-url>"
      ],
      "console": "integratedTerminal",
      "cwd": "${workspaceFolder}",
      "env": {
        "PYTHONPATH": "${workspaceRoot}",
        "CME_CONFIG_PATH": "scratch/cme_config.json",
        "CME_EXPORT__LOG_LEVEL": "DEBUG",
        "CME_EXPORT__OUTPUT_PATH": "scratch"
      }
    },
    {
      "name": "Python: Config (Interactive)",
      "type": "debugpy",
      "request": "launch",
      "program": "${workspaceFolder}/confluence_markdown_exporter/main.py",
      "justMyCode": false,
      "args": [
        "config"
      ],
      "console": "integratedTerminal",
      "cwd": "${workspaceFolder}",
      "env": {
        "PYTHONPATH": "${workspaceRoot}",
        "CME_CONFIG_PATH": "scratch/cme_config.json",
        "CME_EXPORT__LOG_LEVEL": "DEBUG"
      }
    }
  ]
}

================================================
FILE: .vscode/settings.json
================================================
{
  "files.eol": "\n",
  "editor.formatOnSave": true,
  "autoDocstring.docstringFormat": "google",
  "autoDocstring.startOnNewLine": true,
  "python.testing.unittestEnabled": false,
  "python.testing.pytestEnabled": true,
  "python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python",
  "jupyter.notebookFileRoot": "${workspaceFolder}",
  "task.autoDetect": "off",
  "[python]": {
    "editor.defaultFormatter": "charliermarsh.ruff",
    "editor.codeActionsOnSave": {
      "source.fixAll": "explicit",
      "source.organizeImports": "explicit"
    }
  },
  "[json]": {
    "editor.defaultFormatter": "vscode.json-language-features"
  },
  "jupyter.debugJustMyCode": false,
  "debugpy.debugJustMyCode": false,
  "[markdown]": {
    "diffEditor.ignoreTrimWhitespace": false,
    "editor.unicodeHighlight.ambiguousCharacters": false,
    "editor.unicodeHighlight.invisibleCharacters": false,
    "editor.wordWrap": "on",
    "editor.quickSuggestions": {
      "comments": "off",
      "strings": "off",
      "other": "on"
    },
    "editor.fontLigatures": true,
    "editor.glyphMargin": false,
    "editor.minimap.enabled": false,
    "editor.wrappingIndent": "indent",
    "editor.overviewRulerBorder": false,
    "editor.lineHeight": 24,
    "editor.renderWhitespace": "none",
    "editor.suggest.showSnippets": false,
    "editor.tabSize": 2,
    "editor.wordBasedSuggestions": "off",
    "files.autoSave": "onFocusChange",
    "files.insertFinalNewline": true,
  },
  "markdown.updateLinksOnFileMove.enabled": "prompt",
  "markdown.validate.enabled": true,
}

================================================
FILE: .vscode/tasks.json
================================================
{
  "version": "2.0.0",
  "tasks": []
}

================================================
FILE: CONTRIBUTING.md
================================================
# Contributing

Any contribution is welcome! This document provides guidelines for contributing to the confluence-markdown-exporter project.

## Table of Contents

- [Getting Started](#getting-started)
- [Development Workflow](#development-workflow)
- [Testing](#testing)
- [Code Quality](#code-quality)
- [Release Process](#release-process)
- [Pull Request Guidelines](#pull-request-guidelines)

## Getting Started

### Prerequisites

- Python 3.10 or higher
- Git
- `uv` (Python package manager)
- `jq` (for JSON processing)

### Install jq

```bash
sudo apt-get install jq
```

### Install `uv`

Following the [uv installation guide](https://docs.astral.sh/uv/getting-started/installation):

```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
```

Add shell completion (optional):

```bash
echo 'eval "$(uv generate-shell-completion bash)"' >> ~/.bashrc
```

### Project Setup

1. **Fork and Clone the Repository**

   ```bash
   git clone https://github.com/Spenhouet/confluence-markdown-exporter.git
   cd confluence-markdown-exporter
   ```

2. **Install Dependencies**

   ```bash
   uv sync --all-groups
   ```

   This will:

   - Create a virtual environment
   - Install all dependencies (including development dependencies via dependency groups)
   - Install the project in editable mode

3. **Verify Installation**

   ```bash
   uv run confluence-markdown-exporter --help
   uv run cme --help
   ```

## Development Workflow

### Running the Application

```bash
# Run with uv (recommended)
uv run confluence-markdown-exporter [commands]
uv run cme [commands]

# Or activate the virtual environment
source .venv/bin/activate
confluence-markdown-exporter [commands]
```

### Adding Dependencies

```bash
# Add runtime dependency
uv add package-name

# Add development dependency (to dev group)
uv add --group dev package-name

# Add to custom dependency group
uv add --group group-name package-name
```

### Updating Dependencies

```bash
# Update all dependencies
uv sync --upgrade

# Update specific dependency
uv sync --upgrade-package package-name
```

## Testing

We use `pytest` for testing. Tests are located in the `tests/` directory.

### Running Tests

```bash
# Run all tests
uv run pytest

# Run tests with verbose output
uv run pytest -v

# Run specific test file
uv run pytest tests/test_basic.py

# Run specific test
uv run pytest tests/test_basic.py::test_package_imports
```

### Writing Tests

1. **Create test files** in the `tests/` directory with the prefix `test_`
2. **Follow naming conventions**: `test_*.py` files, `test_*` functions
3. **Use descriptive test names** that explain what is being tested
4. **Add docstrings** to explain complex test scenarios

Example test structure:

```python
def test_feature_description() -> None:
    """Test that the feature works as expected."""
    # Arrange
    input_data = "test input"

    # Act
    result = function_under_test(input_data)

    # Assert
    assert result == expected_output
```

## Code Quality

### Linting with Ruff

We use `ruff` for Python linting and code formatting.

```bash
# Check code quality
uv run ruff check

# Auto-fix issues where possible
uv run ruff check --fix

# Check specific files or directories
uv run ruff check confluence_markdown_exporter/
uv run ruff check tests/
```

### Code Style Guidelines

- **Line length**: Maximum 100 characters
- **Docstring style**: Google docstring convention
- **Import formatting**: One import per line (enforced by ruff)
- **Type hints**: Use type annotations for new code

### Pre-commit Workflow

Before committing:

1. **Run linting**: `uv run ruff check`
2. **Run tests**: `uv run pytest`
3. **Fix any issues** before committing

## Release Process

> [!NOTE]
> Only relevant for maintainers.

### Automated Release

We use GitHub Actions for automated releases:

1. **Trigger Release Workflow**

   - Go to GitHub Actions tab
   - Run "Release" workflow
   - Choose version bump type (patch/minor/major) or specify custom version

2. **Automated Steps**
   - Updates version in `pyproject.toml`
   - Runs tests and builds
   - Creates Git tag
   - Publishes to PyPI
   - Creates GitHub release with auto-generated notes
   - Publishes the multi-arch Docker image to Docker Hub

## Pull Request Guidelines

### Before Submitting

1. **Create a feature branch**

   ```bash
   git checkout -b feature/your-feature-name
   ```

2. **Run the full test suite**

   ```bash
   uv run ruff check
   uv run pytest
   uv build --no-sources  # Test build
   ```

3. **Update documentation** if needed

### PR Requirements

- ✅ **All tests pass** (verified by CI)
- ✅ **Code passes linting** (ruff check)
- ✅ **Descriptive PR title** and description
- ✅ **Reference related issues** if applicable
- ✅ **Update tests** for new functionality
- ✅ **Update documentation** for user-facing changes

## Development Environment

### Recommended Tools

- **IDE**: VS Code with Python extension
- **Git client**: Command line or your preferred GUI
- **Terminal**: Any modern terminal with shell completion

### VS Code Extensions

Recommended extensions for development:

- Python (Microsoft)
- Ruff (Astral Software)
- GitLens (GitKraken)
- markdownlint (David Anson)

### Project Structure

```text
confluence-markdown-exporter/
├── .github/workflows/      # CI/CD workflows
├── confluence_markdown_exporter/  # Main package
│   ├── __init__.py
│   ├── main.py            # CLI entry point
│   ├── confluence.py      # Core functionality
│   ├── api_clients.py     # API integrations
│   └── utils/             # Utility modules
├── tests/                 # Test suite
├── .ruff.toml            # Ruff configuration
├── pyproject.toml        # Project configuration
├── uv.lock              # Dependency lock file
└── CONTRIBUTING.md       # This file
```

## Getting Help

- **GitHub Issues**: For bug reports and feature requests
- **GitHub Discussions**: For questions and general discussion
- **Documentation**: Check the README and code comments

Thank you for contributing to confluence-markdown-exporter! 🚀


================================================
FILE: Dockerfile
================================================
# syntax=docker/dockerfile:1.7

# ---- builder ---------------------------------------------------------------
FROM python:3.12-slim AS builder

ARG TARGETARCH

COPY --from=ghcr.io/astral-sh/uv:0.8 /uv /uvx /usr/local/bin/

ENV UV_LINK_MODE=copy \
    UV_COMPILE_BYTECODE=1 \
    UV_PYTHON_DOWNLOADS=never

WORKDIR /app

# Install runtime dependencies only. This layer is cached unless uv.lock or
# pyproject.toml change. Metadata is bind-mounted so it does not get baked
# into the layer and invalidate it on unrelated edits.
RUN --mount=type=cache,target=/root/.cache/uv,id=uv-$TARGETARCH \
    --mount=type=bind,source=uv.lock,target=uv.lock \
    --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
    --mount=type=bind,source=README.md,target=README.md \
    uv sync --locked --no-install-project --no-editable --no-dev

# Install the project itself into the venv. Invalidates on source edits.
COPY pyproject.toml uv.lock README.md ./
COPY confluence_markdown_exporter ./confluence_markdown_exporter
RUN --mount=type=cache,target=/root/.cache/uv,id=uv-$TARGETARCH \
    uv sync --locked --no-editable --no-dev

# ---- runtime ---------------------------------------------------------------
FROM python:3.12-slim AS runtime

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PATH="/app/.venv/bin:$PATH" \
    HOME=/data/config \
    XDG_CONFIG_HOME=/data/config \
    CME_CONFIG_PATH=/data/config/app_data.json \
    CME_EXPORT__OUTPUT_PATH=/data/output

RUN groupadd --system --gid 1000 cme \
    && useradd  --system --uid 1000 --gid cme --home-dir /data/config --shell /usr/sbin/nologin cme \
    && mkdir -p /data/output /data/config \
    && chown -R cme:cme /data

# Copy only the venv, not the source. `--no-editable` made the install
# self-contained so the source tree is not needed at runtime.
COPY --from=builder /app/.venv /app/.venv

USER cme
WORKDIR /data/output

ENTRYPOINT ["confluence-markdown-exporter"]
CMD ["--help"]


================================================
FILE: LICENSE
================================================
MIT License

Copyright (c) 2025 Sebastian Penhouet

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


================================================
FILE: README.md
================================================
<p align="center">
  <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>
</p>
<p align="center">
    <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>
</p>
<p align="center">
  <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>
  <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>
  <a href="https://pypi.org/project/confluence-markdown-exporter" target="_blank">
    <img src="https://img.shields.io/pypi/v/confluence-markdown-exporter?color=%2334D058&label=PyPI%20package" alt="PyPI version">
   </a>
  <a href="https://hub.docker.com/r/spenhouet/confluence-markdown-exporter" target="_blank">
    <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">
   </a>
  <a href="https://spenhouet.github.io/confluence-markdown-exporter/" target="_blank">
    <img src="https://img.shields.io/badge/docs-online-blue" alt="Documentation">
   </a>
</p>

## What it does

Exports 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.

Supported targets include Obsidian, Gollum, Azure DevOps (ADO) wikis, Foam, Dendron, and anything else that consumes Markdown.

Full feature list, configuration reference, and target-system presets live in the **[documentation site](https://spenhouet.github.io/confluence-markdown-exporter/)**.

## Quickstart

### 1. Install

**macOS and Linux**

```bash
curl -LsSf uvx.sh/confluence-markdown-exporter/install.sh | sh
```

**Windows**

```powershell
powershell -ExecutionPolicy ByPass -c "irm https://uvx.sh/confluence-markdown-exporter/install.ps1 | iex"
```

Installing a specific version:

```bash
curl -LsSf uvx.sh/confluence-markdown-exporter/5.1.1/install.sh | sh
```

Alternative 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).

> **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.

### 2. Authenticate

Set Confluence credentials interactively (URL, username, API token / PAT):

```sh
cme config edit auth.confluence
```

See [Authentication](https://spenhouet.github.io/confluence-markdown-exporter/configuration/authentication) for token scopes and Jira setup.

### 3. Export

```sh
# A single page
cme pages <page-url>

# A page and all its descendants
cme pages-with-descendants <page-url>

# An entire space
cme spaces <space-url>

# Every space of an organisation
cme orgs <base-url>
```

Output goes to the configured `export.output_path` (current directory by default).

## Documentation

The full documentation lives at **<https://spenhouet.github.io/confluence-markdown-exporter/>** and includes:

- [Installation](https://spenhouet.github.io/confluence-markdown-exporter/installation) (curl / PowerShell / pip / uv)
- [Usage guide](https://spenhouet.github.io/confluence-markdown-exporter/usage): pages, descendants, spaces, orgs, output layout
- [Feature list](https://spenhouet.github.io/confluence-markdown-exporter/features): supported Confluence content, macros, and add-ons
- [Configuration](https://spenhouet.github.io/confluence-markdown-exporter/configuration): config commands, ENV vars, full option reference
- [Target-system presets](https://spenhouet.github.io/confluence-markdown-exporter/configuration/target-systems): Obsidian, Azure DevOps, …
- [Docker](https://spenhouet.github.io/confluence-markdown-exporter/docker): prebuilt images for non-interactive / CI use
- [CI / non-interactive use](https://spenhouet.github.io/confluence-markdown-exporter/configuration/ci)
- [Compatibility](https://spenhouet.github.io/confluence-markdown-exporter/compatibility) and [Troubleshooting](https://spenhouet.github.io/confluence-markdown-exporter/troubleshooting)

## Contributing

If you would like to contribute, please read [our contribution guideline](CONTRIBUTING.md).

## License

This tool is an open source project released under the [MIT License](LICENSE).


================================================
FILE: confluence_markdown_exporter/__init__.py
================================================
"""Confluence Markdown Exporter package."""

try:
    from importlib.metadata import version

    __version__ = version("confluence-markdown-exporter")
except Exception:  # noqa: BLE001
    # fallback if package not installed or metadata not available
    __version__ = "unknown"


================================================
FILE: confluence_markdown_exporter/api_clients.py
================================================
import logging
import re
import urllib.parse
from threading import Lock
from threading import local
from typing import Annotated

import requests
from atlassian import Confluence as ConfluenceApiSdk
from atlassian import Jira as JiraApiSdk
from pydantic import AfterValidator
from pydantic import BaseModel

from confluence_markdown_exporter.utils.app_data_store import ApiDetails
from confluence_markdown_exporter.utils.app_data_store import AtlassianSdkConnectionConfig
from confluence_markdown_exporter.utils.app_data_store import get_settings
from confluence_markdown_exporter.utils.app_data_store import normalize_instance_url
from confluence_markdown_exporter.utils.app_data_store import set_setting_with_keys

logger = logging.getLogger(__name__)

# URL-keyed caches for API clients
_confluence_clients: dict[str, ConfluenceApiSdk] = {}
_jira_clients: dict[str, JiraApiSdk] = {}
_clients_lock = Lock()

# Thread-local storage for per-URL Confluence clients (one per worker thread per URL)
_thread_local = local()

_CLOUD_DOMAIN = ".atlassian.net"
_GATEWAY_PREFIX = "https://api.atlassian.com/ex"


def parse_gateway_url(url: str) -> tuple[str, str] | None:
    m = re.search(r"https://api\.atlassian\.com/ex/(confluence|jira)/([^/?#]+)", url)
    return (m.group(1).lower(), m.group(2)) if m else None


def build_gateway_url(service: str, cloud_id: str) -> str:
    return f"{_GATEWAY_PREFIX}/{service.lower()}/{cloud_id}"


def ensure_service_gateway_url(url: str, service: str | None = None) -> str:
    """Ensure the gateway URL uses the specified service.

    ``https://api.atlassian.com/ex/confluence/{cloudId}``
    becomes ``https://api.atlassian.com/ex/jira/{cloudId}``.
    Non-gateway URLs are returned as-is.
    """
    if parsed := parse_gateway_url(url):
        return build_gateway_url(service or parsed[0], parsed[1])

    return url


def _is_standard_atlassian_cloud_url(url: str) -> bool:
    """Return True if *url* looks like a standard Atlassian Cloud instance URL."""
    try:
        hostname = urllib.parse.urlparse(url).hostname or ""
        return hostname.endswith(_CLOUD_DOMAIN)
    except Exception:  # noqa: BLE001
        return False


def _try_fetch_cloud_id(base_url: str) -> str | None:
    """Try to fetch the Atlassian Cloud ID from the public tenant info endpoint.

    Returns the cloud ID string, or None if the fetch fails (e.g. for Server instances).
    """
    try:
        resp = requests.get(f"{base_url}/_edge/tenant_info", timeout=5)
        if resp.ok:
            return resp.json().get("cloudId")
    except Exception as e:  # noqa: BLE001
        logger.debug("Could not fetch Cloud ID from %s/_edge/tenant_info: %s", base_url, e)
    return None


def _get_confluence_sdk_url(base_url: str, auth: ApiDetails) -> str:
    """Return the SDK URL for Confluence, using the API gateway when a Cloud ID is configured."""
    if auth.cloud_id:
        return f"{_GATEWAY_PREFIX}/confluence/{auth.cloud_id}"
    return base_url


def _get_jira_sdk_url(base_url: str, auth: ApiDetails) -> str:
    """Return the SDK URL for Jira, using the API gateway when a Cloud ID is configured."""
    if auth.cloud_id:
        return f"{_GATEWAY_PREFIX}/jira/{auth.cloud_id}"
    return base_url


def _decode_url_part(v: str | None) -> None | str:
    if v is None or v == "":
        return None
    return urllib.parse.unquote_plus(v)


class ConfluenceRef(BaseModel):
    space_key: Annotated[str, AfterValidator(_decode_url_part)]
    page_id: int | None = None
    page_title: Annotated[str | None, AfterValidator(_decode_url_part)] = None


# 1) Cloud [/wiki]/spaces/{space_key}[/pages/{page_id}[/{page_title}]]
_CLOUD_URL_RE = re.compile(
    r"^(?:/ex/confluence/[^/]+)?(?:/wiki)?/spaces/"
    r"(?P<space_key>[A-Za-z0-9_~-]+)"
    r"(?:/pages/(?P<page_id>\d+)(?:/(?P<page_title>[^/?#]+))?)?"
    r"(?:/(?!pages/)[^/?#]+)?/?$"
)

# 2) Server [/display]/{space_key}[/{page_title}]
_SERVER_URL_RE = re.compile(
    r"^(?:/display)?"
    r"/(?P<space_key>[A-Za-z0-9._-]+)"
    r"(?:/(?P<page_title>[^/?#]+))?/?$"
)


def parse_confluence_path(path: str) -> ConfluenceRef | None:
    """Parse only the path portion of a Confluence URL and return a ConfluenceRef dict.

    Matching order:
      1) Cloud [/wiki]/spaces/{space_key}[/pages/{page_id}[/{page_title}]]
      2) Server [/display]/{space_key}[/{page_title}]
    """
    if not path:
        return None
    if not path.startswith("/"):
        path = "/" + path
    path = path.rstrip("/")

    if m := _CLOUD_URL_RE.match(path) or _SERVER_URL_RE.match(path):
        return ConfluenceRef.model_validate(m.groupdict())

    return None


class AuthNotConfiguredError(BaseException):
    """Raised when a connection attempt fails and no valid auth is configured for the URL.

    Inherits from BaseException (not Exception) so that broad ``except Exception`` handlers
    in export loops do not accidentally swallow it — it must propagate to the app boundary.
    """

    def __init__(self, url: str, service: str = "Confluence") -> None:
        self.url = url
        self.service = service
        super().__init__(f"No valid authentication configured for {service} at {url}")


class JiraAuthenticationError(Exception):
    """Raised when a Jira API response indicates an authentication failure."""


def _jira_auth_failure_hook(
    response: requests.Response, *_args: object, **_kwargs: object
) -> requests.Response:
    """Raise JiraAuthenticationError when Jira signals authentication failure."""
    if response.headers.get("X-Seraph-Loginreason") == "AUTHENTICATED_FAILED":
        msg = f"Jira authentication failed for request to {response.url}"
        raise JiraAuthenticationError(msg)
    return response


def response_hook(
    response: requests.Response, *_args: object, **_kwargs: object
) -> requests.Response:
    """Log response headers when requests fail."""
    if not response.ok:
        logger.warning(
            "Request to %s failed with status %s. Response headers: %s",
            response.url,
            response.status_code,
            dict(response.headers),
        )
    return response


class ApiClientFactory:
    """Factory for creating authenticated Confluence and Jira API clients with retry config."""

    def __init__(self, connection_config: AtlassianSdkConnectionConfig) -> None:
        # Reconstruct as the base SDK type so model_dump() only yields SDK-compatible fields,
        # even when a ConnectionConfig subclass is passed.
        self.connection_config = AtlassianSdkConnectionConfig.model_validate(
            connection_config.model_dump()
        )

    def create_confluence(self, url: str, auth: ApiDetails) -> ConfluenceApiSdk:
        try:
            instance = ConfluenceApiSdk(
                url=url,
                username=auth.username.get_secret_value() if auth.api_token else None,
                password=auth.api_token.get_secret_value() if auth.api_token else None,
                token=auth.pat.get_secret_value() if auth.pat else None,
                **self.connection_config.model_dump(),
            )
            instance.get_all_spaces(limit=1)
        except Exception as e:
            msg = f"Confluence connection failed: {e}"
            raise ConnectionError(msg) from e
        return instance

    def create_jira(self, url: str, auth: ApiDetails) -> JiraApiSdk:
        try:
            instance = JiraApiSdk(
                url=url,
                username=auth.username.get_secret_value() if auth.api_token else None,
                password=auth.api_token.get_secret_value() if auth.api_token else None,
                token=auth.pat.get_secret_value() if auth.pat else None,
                **self.connection_config.model_dump(),
            )
            instance.get_all_projects()
        except Exception as e:
            msg = f"Jira connection failed: {e}"
            raise ConnectionError(msg) from e
        return instance


def get_confluence_instance(url: str) -> ConfluenceApiSdk:
    """Get authenticated Confluence API client for *url*.

    Creates a new client if one doesn't exist for that URL yet and caches it.
    Prompts for auth config on connection failure.

    When the configured auth for *url* includes a Cloud ID, 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.  For standard Atlassian Cloud instances
    (``.atlassian.net``) the Cloud ID is fetched and stored automatically on first connection.
    """
    url = normalize_instance_url(ensure_service_gateway_url(url, "confluence"))
    with _clients_lock:
        if url in _confluence_clients:
            logger.debug("Confluence client cache hit for %s", url)
            return _confluence_clients[url]

    settings = get_settings()

    auth = settings.auth.get_instance(url)
    if auth is None:
        raise AuthNotConfiguredError(url, "Confluence")

    logger.debug("Creating new Confluence client for %s", url)

    # Auto-fetch and store the Cloud ID for standard Atlassian Cloud instances
    if not auth.cloud_id and _is_standard_atlassian_cloud_url(url):
        cloud_id = _try_fetch_cloud_id(url)
        if cloud_id:
            logger.info("Auto-fetched Atlassian Cloud ID for %s — storing in config", url)
            set_setting_with_keys(["auth", "confluence", url, "cloud_id"], cloud_id)
            settings = get_settings()

    auth = settings.auth.get_instance(url) or ApiDetails()
    sdk_url = _get_confluence_sdk_url(url, auth)
    try:
        client = ApiClientFactory(settings.connection_config).create_confluence(sdk_url, auth)
        logger.info("Connected to Confluence at %s", sdk_url)
    except ConnectionError as e:
        logger.exception("[red bold]Confluence authentication failed for %s.[/red bold]", url)
        raise AuthNotConfiguredError(url, "Confluence") from e

    if settings.export.log_level == "DEBUG":
        client.session.hooks["response"] = [response_hook]

    with _clients_lock:
        _confluence_clients[url] = client
    return client


def get_thread_confluence(base_url: str) -> ConfluenceApiSdk:
    """Get or create a thread-local Confluence client for *base_url*.

    The atlassian-python-api Confluence client uses requests.Session, which is
    NOT thread-safe.  Each worker thread keeps its own dict of clients keyed by
    base URL so that multi-instance exports are also thread-safe.
    """
    base_url = normalize_instance_url(base_url)
    if not hasattr(_thread_local, "clients"):
        _thread_local.clients = {}
    if base_url not in _thread_local.clients:
        logger.debug("Initializing thread-local Confluence client for %s", base_url)
        _thread_local.clients[base_url] = get_confluence_instance(base_url)
    return _thread_local.clients[base_url]


def get_jira_instance(url: str) -> JiraApiSdk:
    """Get authenticated Jira API client for *url*.

    Creates a new client if one doesn't exist for that URL yet and caches it.

    When the input is a Confluence gateway URL (``/ex/confluence/{cloudId}``), it is
    automatically converted to the Jira gateway URL (``/ex/jira/{cloudId}``) before
    auth lookup and SDK connection.  This handles the common case where the caller
    derives the Jira URL from a Confluence page's ``base_url``.

    When the configured auth for *url* includes a Cloud ID, API calls are routed through
    the Atlassian API gateway (``https://api.atlassian.com/ex/jira/{cloud_id}``).
    For standard Atlassian Cloud instances the Cloud ID is fetched and stored automatically.
    """
    # Always work with the Jira gateway URL, even if the caller passed the Confluence one.
    url = normalize_instance_url(ensure_service_gateway_url(url, "jira"))
    settings = get_settings()

    if not settings.export.enable_jira_enrichment:
        msg = "Jira API client was requested eventhough Jira enrichment is disabled."
        raise RuntimeWarning(msg)

    with _clients_lock:
        if url in _jira_clients:
            logger.debug("Jira client cache hit for %s", url)
            return _jira_clients[url]

    auth = settings.auth.get_jira_instance(url)
    if auth is None:
        raise AuthNotConfiguredError(url, "Jira")

    logger.debug("Creating new Jira client for %s", url)

    # Auto-fetch and store the Cloud ID for standard Atlassian Cloud instances
    if not auth.cloud_id and _is_standard_atlassian_cloud_url(url):
        cloud_id = _try_fetch_cloud_id(url)
        if cloud_id:
            logger.info("Auto-fetched Atlassian Cloud ID for %s — storing in config", url)
            set_setting_with_keys(["auth", "jira", url, "cloud_id"], cloud_id)
            settings = get_settings()

    auth = settings.auth.get_jira_instance(url) or auth
    sdk_url = _get_jira_sdk_url(url, auth)
    try:
        client = ApiClientFactory(settings.connection_config).create_jira(sdk_url, auth)
        logger.info("Connected to Jira at %s", sdk_url)
    except ConnectionError as e:
        logger.exception("[red bold]Jira authentication failed for %s.[/red bold]", url)
        raise AuthNotConfiguredError(url, "Jira") from e

    client.session.hooks["response"].append(_jira_auth_failure_hook)

    if settings.export.log_level == "DEBUG":
        client.session.hooks["response"].append(response_hook)

    with _clients_lock:
        _jira_clients[url] = client
    return client


def invalidate_confluence_client(url: str) -> None:
    """Remove a cached Confluence client so the next call creates a fresh one."""
    with _clients_lock:
        _confluence_clients.pop(normalize_instance_url(url), None)


def invalidate_jira_client(url: str) -> None:
    """Remove a cached Jira client so the next call creates a fresh one."""
    with _clients_lock:
        _jira_clients.pop(normalize_instance_url(url), None)


def handle_jira_auth_failure(url: str) -> None:
    """Handle a Jira authentication failure by invalidating the cached client and raising."""
    invalidate_jira_client(url)
    raise AuthNotConfiguredError(url, "Jira")


================================================
FILE: confluence_markdown_exporter/config.py
================================================
"""Config sub-app for the cme CLI."""

import json
import logging
from typing import Annotated

import jmespath
import typer
import yaml

from confluence_markdown_exporter.utils.app_data_store import APP_CONFIG_PATH
from confluence_markdown_exporter.utils.app_data_store import get_settings
from confluence_markdown_exporter.utils.app_data_store import reset_to_defaults
from confluence_markdown_exporter.utils.app_data_store import set_setting

logger = logging.getLogger(__name__)

# Each table row must be its own \n\n-separated block so typer's epilog
# renderer keeps single \n between rows, forming valid markdown table syntax.
_CONFIG_KEYS_EPILOG = (
    "---\n\n"
    "**Available config keys** (run `cme config list` to see all current values):\n\n"
    "| Key | Description |\n\n"
    "| --- | ----------- |\n\n"
    "| `export.output_path` | Directory where exported files are saved |\n\n"
    "| `export.log_level` | Verbosity: `DEBUG`, `INFO`, `WARNING`, `ERROR` |\n\n"
    "| `export.save_log_to_file` | Also write logs to `cme.log` next to the config file |\n\n"
    "| `export.skip_unchanged` | Skip pages unchanged since last export |\n\n"
    "| `export.cleanup_stale` | Delete local files for removed pages |\n\n"
    "| `export.page_path` | File path template for exported pages |\n\n"
    "| `export.attachment_path` | File path template for exported attachments |\n\n"
    "| `export.page_href` | Link style for pages: `relative` or `absolute` |\n\n"
    "| `export.attachment_href` | Link style for attachments: `relative` or `absolute` |\n\n"
    "| `export.include_document_title` | Prepend H1 title to each page |\n\n"
    "| `export.include_toc` | Export Table of Contents macro (`true`/`false`) |\n\n"
    "| `export.include_macro` | How to render `include`/`excerpt-include` macros:"
    " `inline` (default) or `transclusion` (Obsidian `![[Page Title]]` embed) |\n\n"
    "| `export.page_breadcrumbs` | Include breadcrumb links at top of page |\n\n"
    "| `export.confluence_url_in_frontmatter` | Include Confluence page URL in YAML "
    "front matter: `none`, `webui`, `tinyui`, `both` |\n\n"
    "| `export.page_metadata_in_frontmatter` | Add Confluence page metadata "
    "fields (page_id, space_key, type, created, created_by, last_modified, "
    "last_modified_by, version) to YAML front matter (`true`/`false`) |\n\n"
    "| `export.enable_jira_enrichment` | Fetch Jira data for enriched links |\n\n"
    "| `export.attachments_export` | Which attachments to download:"
    " `referenced` (default), `all`, `disabled` |\n\n"
    "| `export.image_captions` | Use image captions as markdown alt text (`true`/`false`) |\n\n"
    "| `export.comments_export` | Which comments to export to sidecar "
    "`.comments.md` files: `none` (default), `inline`, `footer`, `all` |\n\n"
    "| `export.convert_status_badges` | Convert Confluence status badges to `<mark>` elements |\n\n"
    "| `export.convert_text_highlights` | Convert background-color spans to `<mark>` elements |\n\n"
    "| `export.convert_font_colors` | Convert font-color spans to `<font>` elements |\n\n"
    "| `export.filename_length` | Maximum filename length (default: 255) |\n\n"
    "| `connection_config.max_workers` | Parallel export workers (default: 20) |\n\n"
    "| `connection_config.use_v2_api` | Use Confluence REST API v2 (`true`/`false`) |\n\n"
    "| `connection_config.verify_ssl` | Verify SSL certificates (`true`/`false`) |\n\n"
    "| `connection_config.timeout` | API request timeout in seconds |\n\n"
    "| `auth.confluence` | Credentials keyed by instance URL — use `cme config edit` |\n\n"
    "| `auth.jira` | Jira credentials keyed by instance URL — use `cme config edit` |\n\n"
    "---\n\n"
    "Env var override: prefix with `CME_` and `__` as delimiter. "
    "Examples: `CME_EXPORT__OUTPUT_PATH=/tmp/export`, `CME_CONNECTION_CONFIG__MAX_WORKERS=5`.\n\n"
)

app = typer.Typer(
    rich_markup_mode="markdown",
    invoke_without_command=True,
    help=(
        "Manage configuration interactively or via subcommands.\n\n"
        "Running `cme config` without a subcommand opens the **interactive menu**, "
        "which lets you browse and change all settings including authentication credentials.\n\n"
        "For scripting or automation, use the subcommands below."
    ),
    epilog=(
        "**Subcommands at a glance:**\n\n"
        "- `cme config` — interactive menu\n\n"
        "- `cme config list` — print full config as YAML\n\n"
        "- `cme config list -o json` — print full config as JSON\n\n"
        "- `cme config get export.log_level` — print a single value\n\n"
        "- `cme config set export.log_level=DEBUG` — set a value\n\n"
        "- `cme config edit auth.confluence` — edit credentials interactively\n\n"
        "- `cme config path` — show config file path\n\n"
        "- `cme config reset` — reset all settings to defaults\n\n"
        "- `cme config reset export.log_level` — reset a single key to its default\n\n"
    ),
)


@app.callback(invoke_without_command=True)
def callback(ctx: typer.Context) -> None:
    """Open the interactive configuration menu if no subcommand is given."""
    if ctx.invoked_subcommand is None:
        from confluence_markdown_exporter.utils.config_interactive import main_config_menu_loop

        main_config_menu_loop(None)


@app.command(
    help=(
        "Reset configuration to defaults.\n\n"
        "Without a `KEY` argument, resets the **entire configuration** to factory defaults. "
        "Pass a dot-notation key to reset only that key or section.\n\n"
        "Use `--yes` / `-y` to skip the confirmation prompt (useful in scripts)."
    ),
    epilog=(
        "**Examples:**\n\n"
        "- `cme config reset` — reset everything (prompts for confirmation)\n\n"
        "- `cme config reset --yes` — skip confirmation prompt\n\n"
        "- `cme config reset export.log_level` — reset a single key to its default\n\n"
        "- `cme config reset connection_config` — reset a whole section to defaults\n\n"
    ),
)
def reset(
    key: Annotated[
        str | None,
        typer.Argument(
            help=(
                "Dot-notation config key or section to reset to its default. "
                "If omitted, the entire configuration is reset. "
                "Examples: `export.log_level`, `connection_config`, `export`."
            ),
            metavar="KEY",
        ),
    ] = None,
    yes: Annotated[  # noqa: FBT002
        bool,
        typer.Option("--yes", "-y", help="Skip the confirmation prompt."),
    ] = False,
) -> None:
    if not yes:
        target = f"'{key}'" if key else "all configuration"
        confirmed = typer.confirm(f"Reset {target} to defaults?", default=False)
        if not confirmed:
            raise typer.Abort
    reset_to_defaults(key)
    target = f"'{key}'" if key else "Configuration"
    typer.echo(f"{target} reset to defaults.")


@app.command(
    help=(
        "Print the path to the configuration file.\n\n"
        "Override the config file location by setting the `CME_CONFIG_PATH` environment variable."
    ),
    epilog=(
        "**Example:**\n\n"
        "- `cme config path`\n\n"
        "- `CME_CONFIG_PATH=/custom/path.json cme config path` — custom config file\n\n"
    ),
)
def path() -> None:
    """Output the path to the configuration file."""
    typer.echo(str(APP_CONFIG_PATH))


@app.command(
    name="list",
    help=(
        "Print the current configuration as YAML (default) or JSON.\n\n"
        "Shows all settings and their current effective values. "
        "Use this to discover available config keys for `cme config get` and "
        "`cme config set`.\n\n"
        "> **Note:** Secret values (API tokens, passwords) are printed in plaintext."
    ),
    epilog=(
        "**Examples:**\n\n"
        "- `cme config list` — YAML output (default)\n\n"
        "- `cme config list -o json` — JSON output\n\n"
        "- `cme config list -o yaml` — explicit YAML\n\n"
    ),
)
def list_config(
    output: Annotated[
        str,
        typer.Option(
            "--output",
            "-o",
            help="Output format. Accepted values: `yaml` (default) or `json`.",
            metavar="FORMAT",
        ),
    ] = "yaml",
) -> None:
    """Output the current configuration as YAML or JSON."""
    current_settings = get_settings()
    data = json.loads(current_settings.model_dump_json())
    fmt = output.lower()
    if fmt == "json":
        typer.echo(json.dumps(data, indent=2))
    elif fmt in ("yaml", "yml"):
        typer.echo(yaml.dump(data, default_flow_style=False, allow_unicode=True), nl=False)
    else:
        typer.echo(f"Unknown format '{output}': expected 'yaml' or 'json'.", err=True)
        raise typer.Exit(code=1)


@app.command(
    help=(
        "Print the current value of a single config key.\n\n"
        "Keys use dot notation to address nested settings "
        "(e.g. `export.log_level`, `connection_config.max_workers`). "
        "Nested sections are printed as YAML. "
        "Run `cme config list` to see all available keys."
    ),
    epilog=(
        "**Examples:**\n\n"
        "- `cme config get export.log_level`\n\n"
        "- `cme config get export.output_path`\n\n"
        "- `cme config get connection_config.max_workers`\n\n"
        "- `cme config get connection_config` — prints the whole section as YAML\n\n"
        "- `cme config get export` — prints all export settings\n\n"
        + _CONFIG_KEYS_EPILOG
    ),
)
def get(
    key: Annotated[
        str,
        typer.Argument(
            help=(
                "Config key in dot notation. "
                "Examples: `export.log_level`, `connection_config.max_workers`, `export`."
            ),
            metavar="KEY",
        ),
    ],
) -> None:
    """Output the current value of a config key."""
    current_settings = get_settings()
    data = json.loads(current_settings.model_dump_json())
    value = jmespath.search(key, data)
    if value is None:
        typer.echo(f"Key '{key}' not found.", err=True)
        raise typer.Exit(code=1)
    if isinstance(value, dict | list):
        typer.echo(yaml.dump(value, default_flow_style=False, allow_unicode=True), nl=False)
    else:
        typer.echo(str(value))


@app.command(
    name="set",
    help=(
        "Set one or more configuration values.\n\n"
        "Each argument must be a `key=value` pair using dot notation for the key. "
        "Values are parsed as JSON where possible "
        "(so `true`, `false`, numbers, and JSON arrays work), "
        "falling back to a plain string.\n\n"
        "> **Note:** For auth keys that contain a URL "
        "(e.g. `auth.confluence.https://...`), use `cme config edit auth.confluence` "
        "instead — the interactive editor handles URL-based keys correctly."
    ),
    epilog=(
        "**Examples:**\n\n"
        "- `cme config set export.log_level=DEBUG`\n\n"
        "- `cme config set export.output_path=/tmp/export`\n\n"
        "- `cme config set export.skip_unchanged=false`\n\n"
        "- `cme config set connection_config.max_workers=5`\n\n"
        "- `cme config set connection_config.verify_ssl=false`\n\n"
        "- `cme config set export.log_level=INFO export.output_path=./out`"
        " — multiple keys at once\n\n"
        + _CONFIG_KEYS_EPILOG
    ),
)
def set_config(
    key_values: Annotated[
        list[str],
        typer.Argument(
            help=(
                "One or more `key=value` pairs. "
                "Keys use dot notation (e.g. `export.log_level=DEBUG`). "
                "Values are parsed as JSON first, then as plain strings. "
                "For auth keys containing URLs, use `cme config edit` instead."
            ),
            metavar="KEY=VALUE",
        ),
    ],
) -> None:
    """Set one or more configuration values."""
    for kv in key_values:
        if "=" not in kv:
            typer.echo(f"Invalid format '{kv}': expected key=value.", err=True)
            raise typer.Exit(code=1)
        key, _, raw_value = kv.partition("=")
        value = _parse_value(raw_value)
        try:
            set_setting(key.strip(), value)
        except (ValueError, KeyError) as e:
            typer.echo(f"Failed to set '{key.strip()}': {e}", err=True)
            raise typer.Exit(code=1) from e
    typer.echo("Configuration updated.")


@app.command(
    help=(
        "Open the interactive editor for a specific config key.\n\n"
        "Launches the interactive configuration menu pre-navigated to the given key. "
        "Especially useful for editing authentication credentials, "
        "where the instance URL is part of the key and cannot be set via `cme config set`."
    ),
    epilog=(
        "**Examples:**\n\n"
        "- `cme config edit auth.confluence` — add or update Confluence credentials\n\n"
        "- `cme config edit auth.jira` — edit Jira credentials\n\n"
        "- `cme config edit export.log_level` — edit a setting interactively\n\n"
        "- `cme config edit export.output_path` — set output path interactively\n\n"
    ),
)
def edit(
    key: Annotated[
        str,
        typer.Argument(
            help=(
                "Config key to open in the interactive editor, using dot notation. "
                "Examples: `auth.confluence`, `auth.jira`, `export.log_level`."
            ),
            metavar="KEY",
        ),
    ],
) -> None:
    """Open the interactive editor for a specific config key."""
    from confluence_markdown_exporter.utils.config_interactive import main_config_menu_loop

    main_config_menu_loop(key)


def _parse_value(value_str: str) -> object:
    """Parse a CLI value string, trying JSON first then falling back to raw string.

    Handles JSON scalars (true/false, numbers, null), arrays, and objects.
    Also accepts Python-style True/False for convenience.
    """
    try:
        return json.loads(value_str)
    except json.JSONDecodeError:
        pass
    lower = value_str.lower()
    if lower == "true":
        return True
    if lower == "false":
        return False
    return value_str


================================================
FILE: confluence_markdown_exporter/confluence.py
================================================
"""Confluence API documentation.

https://developer.atlassian.com/cloud/confluence/rest/v1/intro
"""

import functools
import json
import logging
import mimetypes
import os
import re
import urllib.parse
from collections.abc import Set
from concurrent.futures import ThreadPoolExecutor
from concurrent.futures import as_completed
from os import PathLike
from pathlib import Path
from string import Template
from typing import Any
from typing import ClassVar
from typing import Literal
from typing import TypeAlias
from typing import cast
from urllib.parse import unquote
from urllib.parse import urlparse

import yaml
from atlassian.errors import ApiError
from atlassian.errors import ApiNotFoundError
from bs4 import BeautifulSoup
from bs4 import Tag
from markdownify import ATX
from markdownify import MarkdownConverter
from pydantic import BaseModel
from pydantic import Field
from requests import HTTPError
from requests import RequestException
from rich.progress import BarColumn
from rich.progress import MofNCompleteColumn
from rich.progress import Progress
from rich.progress import SpinnerColumn
from rich.progress import TaskProgressColumn
from rich.progress import TextColumn
from rich.progress import TimeElapsedColumn
from rich.progress import TimeRemainingColumn
from tabulate import tabulate

from confluence_markdown_exporter.api_clients import JiraAuthenticationError
from confluence_markdown_exporter.api_clients import build_gateway_url
from confluence_markdown_exporter.api_clients import get_confluence_instance
from confluence_markdown_exporter.api_clients import get_jira_instance
from confluence_markdown_exporter.api_clients import get_thread_confluence
from confluence_markdown_exporter.api_clients import handle_jira_auth_failure
from confluence_markdown_exporter.api_clients import parse_confluence_path
from confluence_markdown_exporter.api_clients import parse_gateway_url
from confluence_markdown_exporter.utils.app_data_store import get_settings
from confluence_markdown_exporter.utils.app_data_store import normalize_instance_url
from confluence_markdown_exporter.utils.drawio_converter import load_and_parse_drawio
from confluence_markdown_exporter.utils.export import github_heading_slug
from confluence_markdown_exporter.utils.export import sanitize_filename
from confluence_markdown_exporter.utils.export import sanitize_key
from confluence_markdown_exporter.utils.export import save_file
from confluence_markdown_exporter.utils.lockfile import AttachmentEntry
from confluence_markdown_exporter.utils.lockfile import LockfileManager
from confluence_markdown_exporter.utils.page_registry import PageTitleRegistry
from confluence_markdown_exporter.utils.rich_console import ExportStats
from confluence_markdown_exporter.utils.rich_console import console
from confluence_markdown_exporter.utils.rich_console import get_stats
from confluence_markdown_exporter.utils.rich_console import reset_stats
from confluence_markdown_exporter.utils.table_converter import TableConverter

JsonResponse: TypeAlias = dict
StrPath: TypeAlias = str | PathLike[str]

logger = logging.getLogger(__name__)
_MAX_UNICODE_CODEPOINT = 0x10FFFF

_RE_RGB_BG = re.compile(r"background-color:\s*rgb\((\d+),\s*(\d+),\s*(\d+)\)")
_RE_RGB_COLOR = re.compile(r"(?<![a-z-])color:\s*rgb\((\d+),\s*(\d+),\s*(\d+)\)")
_RE_COLORID_CSS = re.compile(r"(?<![>\w])\[data-colorid=(\w+)\]\{color:(#[0-9a-fA-F]+)\}")
_RE_HEX_COLOR = re.compile(r"^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$")

# Confluence default header backgrounds — applied automatically to <th> cells, and
# (in matrix-style tables) to row-label <td>s. Treated as "no user-chosen colour".
_DEFAULT_HEADER_BGS = frozenset({"#f4f5f7", "#f2f2f2"})


def _rgb_to_hex(r: int, g: int, b: int) -> str:
    return f"#{r:02x}{g:02x}{b:02x}"


def _extract_cell_highlight_hex(el: Tag) -> str | None:
    """Return Confluence cell background hex from data-highlight-colour, or None.

    Confluence Cloud sets `data-highlight-colour="#rrggbb"` (or `"transparent"`)
    on `<td>` / `<th>` when a cell background colour is applied.
    """
    val = el.get("data-highlight-colour")
    if not isinstance(val, str):
        return None
    val = val.strip().lower()
    if not val or val == "transparent" or val in _DEFAULT_HEADER_BGS:
        return None
    if _RE_HEX_COLOR.match(val):
        return val
    return None


# Background colours for Confluence status-badge lozenges (Atlassian design token pastels).
_LOZENGE_COLORS: dict[str, str] = {
    "aui-lozenge-complete": "#cce0ff",  # blue
    "aui-lozenge-success": "#baf3db",  # green
    "aui-lozenge-current": "#f8e6a0",  # yellow / orange
    "aui-lozenge-error": "#ffd5d2",  # red
    "aui-lozenge-progress": "#dfd8fd",  # purple / violet
}


def _require_dict(response: object, context: str) -> JsonResponse:
    """Validate that an API response is a dict, not an HTML redirect or error string.

    SAML SSO redirects and session-expiry responses are returned as raw HTML strings
    by the atlassian-python-api client instead of raising an exception.  Calling
    .get() on such a string produces a confusing AttributeError; this helper surfaces
    a clear message instead.
    """
    if isinstance(response, dict):
        return response
    preview = str(response)[:120].replace("\n", " ")
    if "SAMLRequest" in str(response) or "SAMLResponse" in str(response):
        msg = (
            f"Authentication failed for {context}: received a SAML SSO redirect instead of JSON. "
            "Check that your Confluence token/credentials are correct and not expired."
        )
    else:
        msg = f"Unexpected non-dict response for {context}: {preview!r}"
    raise ValueError(msg)


def _extract_base_url(url: str) -> str:
    """Extract the base URL from a Confluence or Jira URL.

    For Atlassian Cloud URLs (``*.atlassian.net``) returns ``{scheme}://{hostname}``.
    For Atlassian API gateway URLs of the form
    ``https://api.atlassian.com/ex/{service}/{cloudId}/...``
    returns ``https://api.atlassian.com/ex/{service}/{cloudId}`` so that
    the Cloud ID is preserved as part of the base URL used for auth lookup
    and SDK initialisation.
    For Server/Data Center instances with a context path (e.g.
    ``https://host/confluence/spaces/KEY``), the context path is preserved
    so the SDK client hits the correct REST endpoints.
    """
    parsed = urllib.parse.urlparse(url)
    if parsed.scheme is None or parsed.hostname is None:
        msg = (
            "Invalid URL: a scheme (http:// or https://) and hostname are required. "
            "Expected format: 'https://<hostname>[:port]/...'."
        )
        raise ValueError(msg)

    if gateway := parse_gateway_url(url):
        return normalize_instance_url(build_gateway_url(*gateway))

    # For Server/DC instances the Confluence webapp may be deployed under a
    # context path (e.g. ``/confluence``).  Preserve everything before the
    # first path segment that belongs to Confluence's own routing.
    _confluence_route_segments = {
        "wiki",
        "display",
        "spaces",
        "rest",
        "pages",
        "plugins",
        "dosearchsite.action",
    }
    segments = [s for s in parsed.path.split("/") if s]
    context_parts: list[str] = []
    for segment in segments:
        if segment.lower() in _confluence_route_segments:
            break
        context_parts.append(segment)

    base = f"{parsed.scheme}://{parsed.hostname}"
    if parsed.port and parsed.port not in (80, 443):
        base = f"{parsed.scheme}://{parsed.hostname}:{parsed.port}"
    if context_parts:
        base = f"{base}/{'/'.join(context_parts)}"
    return normalize_instance_url(base)


def _join_confluence_link(data: JsonResponse, key: str) -> str:
    links = data.get("_links", {})
    if not isinstance(links, dict):
        return ""
    base = links.get("base")
    rel = links.get(key)
    if not isinstance(base, str) or not isinstance(rel, str) or not base or not rel:
        return ""
    return f"{base.rstrip('/')}/{rel.lstrip('/')}"


def _get_web_url(data: JsonResponse) -> str:
    return _join_confluence_link(data, "webui")


def _get_tiny_url(data: JsonResponse) -> str:
    return _join_confluence_link(data, "tinyui")


_JIRA_ROUTE_SEGMENTS = {
    "agile",
    "backlog",
    "board",
    "browse",
    "issues",
    "plugins",
    "projects",
    "rest",
    "secure",
    "servicedesk",
    "software",
}

_HTML_ELEMENTS = frozenset(
    {
        "a",
        "abbr",
        "acronym",
        "address",
        "area",
        "article",
        "aside",
        "audio",
        "b",
        "base",
        "bdi",
        "bdo",
        "blockquote",
        "body",
        "br",
        "button",
        "canvas",
        "caption",
        "cite",
        "code",
        "col",
        "colgroup",
        "data",
        "datalist",
        "dd",
        "del",
        "details",
        "dfn",
        "dialog",
        "div",
        "dl",
        "dt",
        "em",
        "embed",
        "fieldset",
        "figcaption",
        "figure",
        "font",
        "footer",
        "form",
        "h1",
        "h2",
        "h3",
        "h4",
        "h5",
        "h6",
        "head",
        "header",
        "hgroup",
        "hr",
        "html",
        "i",
        "iframe",
        "img",
        "input",
        "ins",
        "kbd",
        "keygen",
        "label",
        "legend",
        "li",
        "link",
        "main",
        "map",
        "mark",
        "menu",
        "menuitem",
        "meta",
        "meter",
        "nav",
        "noscript",
        "object",
        "ol",
        "optgroup",
        "option",
        "output",
        "p",
        "picture",
        "pre",
        "progress",
        "q",
        "rp",
        "rt",
        "ruby",
        "s",
        "samp",
        "script",
        "section",
        "select",
        "small",
        "source",
        "span",
        "strong",
        "style",
        "sub",
        "summary",
        "sup",
        "table",
        "tbody",
        "td",
        "template",
        "textarea",
        "tfoot",
        "th",
        "thead",
        "time",
        "title",
        "tr",
        "track",
        "u",
        "ul",
        "var",
        "video",
        "wbr",
    }
)

_ANGLE_BRACKET_RE = re.compile(r"<([^<>\n]*)>")
_CODE_FENCE_RE = re.compile(r"^(`{3,}|~{3,})")
_INLINE_CODE_RE = re.compile(r"`[^`\n]*`")
_AUTOLINK_URI_RE = re.compile(r"^[A-Za-z][A-Za-z0-9+.\-]{1,31}:[^\s<>]*$")
_AUTOLINK_EMAIL_RE = re.compile(
    r"^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~\-]+@[A-Za-z0-9](?:[A-Za-z0-9\-]{0,61}[A-Za-z0-9])?"
    r"(?:\.[A-Za-z0-9](?:[A-Za-z0-9\-]{0,61}[A-Za-z0-9])?)*$"
)


def _extract_jira_base_url(url: str) -> str | None:
    """Extract the Jira instance base URL from a Jira issue URL.

    Strips Jira-specific routing segments (e.g. ``browse``) so that the context
    path is preserved for Server/DC deployments (e.g. ``https://host/jira``),
    matching the key format used in ``auth.jira`` configuration.
    Returns ``None`` when *url* is not an absolute URL.
    """
    parsed = urllib.parse.urlparse(url)
    if not parsed.scheme or not parsed.hostname:
        return None

    if gateway := parse_gateway_url(url):
        return normalize_instance_url(build_gateway_url(*gateway))

    segments = [s for s in parsed.path.split("/") if s]
    context_parts: list[str] = []
    for segment in segments:
        if segment.lower() in _JIRA_ROUTE_SEGMENTS:
            break
        context_parts.append(segment)

    base = f"{parsed.scheme}://{parsed.hostname}"
    if parsed.port and parsed.port not in (80, 443):
        base = f"{parsed.scheme}://{parsed.hostname}:{parsed.port}"
    if context_parts:
        base = f"{base}/{'/'.join(context_parts)}"
    return normalize_instance_url(base)


settings = get_settings()


class JiraIssue(BaseModel):
    key: str
    summary: str
    description: str | None
    status: str

    @classmethod
    def from_json(cls, data: JsonResponse) -> "JiraIssue":
        fields = data.get("fields", {})
        return cls(
            key=data.get("key", ""),
            summary=fields.get("summary", ""),
            description=fields.get("description", ""),
            status=fields.get("status", {}).get("name", ""),
        )

    @classmethod
    def from_key(cls, issue_key: str, jira_url: str) -> "JiraIssue | None":
        """Fetch a Jira issue by key."""
        settings = get_settings()
        if not settings.export.enable_jira_enrichment:
            return None

        try:
            return cls._fetch_cached(issue_key, jira_url)
        except JiraAuthenticationError:
            handle_jira_auth_failure(jira_url)
            return None

    @classmethod
    @functools.lru_cache(maxsize=100)
    def _fetch_cached(cls, issue_key: str, jira_url: str) -> "JiraIssue":
        jira_instance = get_jira_instance(jira_url)
        issue_data = cast("JsonResponse", jira_instance.get_issue(issue_key))
        return cls.from_json(issue_data)


class User(BaseModel):
    account_id: str
    username: str
    display_name: str
    public_name: str
    email: str

    @classmethod
    def from_json(cls, data: JsonResponse) -> "User":
        return cls(
            account_id=data.get("accountId", ""),
            username=data.get("username", ""),
            display_name=data.get("displayName", ""),
            public_name=data.get("publicName", ""),
            email=data.get("email", ""),
        )

    @classmethod
    @functools.lru_cache(maxsize=100)
    def from_username(cls, username: str, base_url: str = "") -> "User":
        return cls.from_json(
            cast(
                "JsonResponse",
                get_thread_confluence(base_url).get_user_details_by_username(username),
            )
        )

    @classmethod
    @functools.lru_cache(maxsize=100)
    def from_userkey(cls, userkey: str, base_url: str = "") -> "User":
        return cls.from_json(
            cast(
                "JsonResponse",
                get_thread_confluence(base_url).get_user_details_by_userkey(userkey),
            )
        )

    @classmethod
    @functools.lru_cache(maxsize=100)
    def from_accountid(cls, accountid: str, base_url: str = "") -> "User":
        return cls.from_json(
            cast(
                "JsonResponse",
                get_thread_confluence(base_url).get_user_details_by_accountid(accountid),
            )
        )


class Version(BaseModel):
    number: int
    by: User
    when: str
    friendly_when: str

    @classmethod
    def from_json(cls, data: JsonResponse) -> "Version":
        return cls(
            number=data.get("number", 0),
            by=User.from_json(data.get("by", {})),
            when=data.get("when", ""),
            friendly_when=data.get("friendlyWhen", ""),
        )


class History(BaseModel):
    created: str
    created_by: User

    @classmethod
    def from_json(cls, data: JsonResponse) -> "History":
        return cls(
            created=data.get("createdDate", ""),
            created_by=User.from_json(data.get("createdBy", {})),
        )


class Organization(BaseModel):
    base_url: str
    spaces: list["Space"]

    @property
    def pages(self) -> list["Page | Descendant"]:
        return [page for space in self.spaces for page in space.pages]

    def export(self) -> None:
        """Export all pages across all spaces, showing per-space discovery progress."""
        all_pages: list[Page | Descendant] = []
        n = len(self.spaces)
        logger.info("Exporting %d space(s) from %s", n, self.base_url)
        with console.status("", spinner="dots") as status:
            for i, space in enumerate(self.spaces, 1):
                status.update(
                    f"[dim]Fetching pages for space [highlight]{space.name}[/highlight]"
                    f" ({i}/{n})…[/dim]"
                )
                all_pages.extend(space.pages)
        logger.info("Discovered %d page(s) across %d space(s)", len(all_pages), n)
        export_pages(all_pages)

    @classmethod
    def from_json(cls, data: JsonResponse, base_url: str) -> "Organization":
        return cls(
            base_url=base_url,
            spaces=[Space.from_json(space, base_url) for space in data.get("results", [])],
        )

    @classmethod
    @functools.lru_cache(maxsize=100)
    def from_url(cls, base_url: str) -> "Organization":
        logger.debug("Fetching space list from %s", base_url)
        with console.status(
            f"[dim]Fetching space list from [highlight]{base_url}[/highlight]…[/dim]"
        ):
            org = cls.from_json(
                cast(
                    "JsonResponse",
                    get_thread_confluence(base_url).get_all_spaces(
                        space_type="global", space_status="current", expand="homepage"
                    ),
                ),
                base_url,
            )
        logger.info("Found %d space(s) in %s", len(org.spaces), base_url)
        return org


class Space(BaseModel):
    base_url: str
    key: str
    name: str
    description: str
    homepage: int | None

    @property
    def pages(self) -> list["Page | Descendant"]:
        if self.homepage is None:
            logger.warning(
                f"Space '{self.name}' (key: {self.key}) has no homepage. No pages will be exported."
            )
            return []

        homepage = Page.from_id(self.homepage, self.base_url)
        return [homepage, *homepage.descendants]

    def export(self) -> None:
        """Export all pages in this space to Markdown."""
        logger.debug("Fetching pages for space '%s' (%s)", self.name, self.key)
        with console.status(
            f"[dim]Fetching pages for space [highlight]{self.name}[/highlight]…[/dim]"
        ):
            pages = self.pages
        logger.info("Found %d page(s) in space '%s'", len(pages), self.name)
        export_pages(pages)

    @classmethod
    def from_json(cls, data: JsonResponse, base_url: str) -> "Space":
        return cls(
            base_url=base_url,
            key=data.get("key", ""),
            name=data.get("name", ""),
            description=data.get("description", {}).get("plain", {}).get("value", ""),
            homepage=data.get("homepage", {}).get("id"),
        )

    @classmethod
    @functools.lru_cache(maxsize=100)
    def from_key(cls, space_key: str, base_url: str) -> "Space":
        return cls.from_json(
            cast(
                "JsonResponse",
                get_thread_confluence(base_url).get_space(space_key, expand="homepage"),
            ),
            base_url,
        )

    @classmethod
    def from_url(cls, space_url: str) -> "Space":
        """Retrieve a Space object given a Confluence space URL.

        The Confluence instance is selected automatically by matching the URL's
        hostname against configured instances.  If no match is found, a new
        entry is registered in the auth config so the user can fill in
        credentials via the interactive config menu.

        Supports standard instance URLs (``https://company.atlassian.net/wiki/spaces/KEY``)
        and Atlassian API gateway URLs
        (``https://api.atlassian.com/ex/confluence/{cloudId}/wiki/spaces/KEY``).
        """
        base_url = _extract_base_url(space_url)

        # Ensure a client exists (creates/prompts if first time for this host)
        get_confluence_instance(base_url)

        parsed = urllib.parse.urlparse(space_url)
        base_path = urllib.parse.urlparse(base_url).path.rstrip("/")
        relative_path = parsed.path[len(base_path) :]
        if match := parse_confluence_path(relative_path):
            if match.space_key:
                logger.debug("Resolved space key '%s' from URL %s", match.space_key, space_url)
                return cls.from_key(match.space_key, base_url)

        msg = f"Could not parse space URL {space_url}."
        raise ValueError(msg)


class Label(BaseModel):
    id: str
    name: str
    prefix: str

    @classmethod
    def from_json(cls, data: JsonResponse) -> "Label":
        return cls(
            id=data.get("id", ""),
            name=data.get("name", ""),
            prefix=data.get("prefix", ""),
        )


class Document(BaseModel):
    base_url: str
    title: str
    space: Space
    ancestors: list["Ancestor"]
    version: Version

    @property
    def _template_vars(self) -> dict[str, str]:
        homepage_id = ""
        homepage_title = ""
        if self.space.homepage:
            homepage_id = str(self.space.homepage)
            homepage_title = sanitize_filename(
                Page.from_id(self.space.homepage, self.base_url).title
            )

        return {
            "space_key": sanitize_filename(self.space.key),
            "space_name": sanitize_filename(self.space.name),
            "homepage_id": homepage_id,
            "homepage_title": homepage_title,
            "ancestor_ids": "/".join(str(a.id) for a in self.ancestors),
            "ancestor_titles": "/".join(sanitize_filename(a.title) for a in self.ancestors),
        }


class Attachment(Document):
    id: str
    file_size: int
    media_type: str
    media_type_description: str
    file_id: str
    collection_name: str
    download_link: str
    comment: str

    @property
    def extension(self) -> str:
        if self.comment == "draw.io diagram" and self.media_type == "application/vnd.jgraph.mxfile":
            return ".drawio"
        if self.comment == "draw.io preview" and self.media_type == "image/png":
            return ".drawio.png"

        return mimetypes.guess_extension(self.media_type) or ""

    @property
    def filename(self) -> str:
        return f"{self.file_id}{self.extension}"

    @property
    def _template_vars(self) -> dict[str, str]:
        ext = self.extension
        title = self.title
        title_without_ext = title[: -len(ext)] if ext and title.endswith(ext) else Path(title).stem
        return {
            **super()._template_vars,
            "attachment_id": str(self.id),
            "attachment_title": sanitize_filename(title_without_ext),
            # file_id is a GUID and does not need sanitization. On
            # Confluence Data Center / Server the API does not populate
            # fileId, so fall back to the content id which is always
            # present and unique.
            "attachment_file_id": self.file_id or str(self.id),
            "attachment_extension": self.extension,
        }

    @property
    def export_path(self) -> Path:
        filepath_template = Template(settings.export.attachment_path.replace("{", "${"))
        return Path(filepath_template.safe_substitute(self._template_vars))

    @classmethod
    def from_json(cls, data: JsonResponse, base_url: str) -> "Attachment":
        extensions = data.get("extensions", {})
        container = data.get("container", {})
        return cls(
            base_url=base_url,
            id=data.get("id", ""),
            title=data.get("title", ""),
            space=Space.from_key(
                data.get("_expandable", {}).get("space", "").split("/")[-1], base_url
            ),
            file_size=extensions.get("fileSize", 0),
            media_type=extensions.get("mediaType", ""),
            media_type_description=extensions.get("mediaTypeDescription", ""),
            file_id=extensions.get("fileId", ""),
            collection_name=extensions.get("collectionName", ""),
            download_link=data.get("_links", {}).get("download", ""),
            comment=extensions.get("comment", ""),
            ancestors=[
                *[
                    Ancestor.from_json(ancestor, base_url)
                    for ancestor in container.get("ancestors", [])
                ],
                Ancestor.from_json(container, base_url),
            ][1:],
            version=Version.from_json(data.get("version", {})),
        )

    @classmethod
    def from_page_id(cls, page_id: int, base_url: str) -> list["Attachment"]:
        attachments = []
        start = 0
        paging_limit = 50
        size = paging_limit  # Initialize to limit to enter the loop

        while size >= paging_limit:
            response = cast(
                "JsonResponse",
                get_thread_confluence(base_url).get_attachments_from_content(
                    page_id,
                    start=start,
                    limit=paging_limit,
                    expand="container.ancestors,version",
                ),
            )

            attachments.extend(
                [cls.from_json(att, base_url) for att in response.get("results", [])]
            )

            size = response.get("size", 0)
            start += size

        logger.debug("Found %d attachment(s) for page id=%s", len(attachments), page_id)
        return attachments

    def export(self) -> None:
        stats = get_stats()
        filepath = settings.export.output_path / self.export_path
        if filepath.exists():
            logger.debug("Skipping attachment '%s' — already exists at %s", self.title, filepath)
            return

        logger.debug("Downloading attachment '%s' to %s", self.title, filepath)
        client = get_thread_confluence(self.base_url)
        try:
            response = client.request(
                method="GET",
                path=client.url + self.download_link,
                absolute=True,
                advanced_mode=True,
            )
            response.raise_for_status()  # Raise error if request fails
        except HTTPError:
            logger.warning("There is no attachment with title '%s'. Skipping export.", self.title)
            stats.inc_attachments_failed()
            return
        except RequestException as e:
            logger.warning("Failed to download attachment '%s': %s. Skipping.", self.title, e)
            stats.inc_attachments_failed()
            return

        save_file(filepath, response.content)
        logger.debug("Saved attachment '%s' (%d bytes)", self.title, len(response.content))
        stats.inc_attachments_exported()


class Ancestor(Document):
    id: int

    @classmethod
    def from_json(cls, data: JsonResponse, base_url: str) -> "Ancestor":
        return cls(
            base_url=base_url,
            id=data.get("id", 0),
            title=data.get("title", ""),
            space=Space.from_key(
                data.get("_expandable", {}).get("space", "").split("/")[-1], base_url
            ),
            ancestors=[],  # Ancestors of ancestor is not needed for now.
            version=Version.from_json({}),  # Version of ancestor is not needed for now.
        )


class Descendant(Document):
    id: int

    @property
    def _template_vars(self) -> dict[str, str]:
        return {
            **super()._template_vars,
            "page_id": str(self.id),
            "page_title": sanitize_filename(self.title),
        }

    @property
    def export_path(self) -> Path:
        filepath_template = Template(settings.export.page_path.replace("{", "${"))
        return Path(filepath_template.safe_substitute(self._template_vars))

    @classmethod
    def from_json(cls, data: JsonResponse, base_url: str) -> "Descendant":
        return cls(
            base_url=base_url,
            id=data.get("id", 0),
            title=data.get("title", ""),
            space=Space.from_key(
                data.get("_expandable", {}).get("space", "").split("/")[-1], base_url
            ),
            ancestors=[
                Ancestor.from_json(ancestor, base_url) for ancestor in data.get("ancestors", [])
            ][1:],
            version=Version.from_json(data.get("version", {})),
        )


def _parse_image_captions(storage_xml: str) -> dict[str, str]:
    """Return {filename: caption} parsed from Confluence storage-format XML."""
    captions: dict[str, str] = {}
    if not storage_xml:
        return captions
    for block in re.findall(r"<ac:image[^>]*>.*?</ac:image>", storage_xml, re.DOTALL):
        filename_m = re.search(r'ri:filename="([^"]+)"', block)
        if not filename_m:
            continue
        caption_m = re.search(r"<ac:caption[^>]*>(.*?)</ac:caption>", block, re.DOTALL)
        if not caption_m:
            continue
        caption_content = caption_m.group(1)
        # CDATA in ac:plain-text-body (older format)
        cdata_m = re.search(
            r"<ac:plain-text-body>\s*<!\[CDATA\[(.*?)\]\]>\s*</ac:plain-text-body>",
            caption_content,
            re.DOTALL,
        )
        if cdata_m:
            caption = cdata_m.group(1).strip()
        else:
            # HTML elements in caption (e.g. <p>text</p>) — strip tags
            caption = BeautifulSoup(caption_content, "html.parser").get_text().strip()
        if caption:
            captions[filename_m.group(1)] = caption
    return captions


class Page(Document):
    id: int
    type: str = ""
    web_url: str = ""
    tiny_url: str = ""
    body: str
    body_export: str
    editor2: str
    body_storage: str = ""
    labels: list["Label"]
    attachments: list["Attachment"]
    history: History = Field(
        default_factory=lambda: History(created="", created_by=User.from_json({}))
    )

    @property
    def descendants(self) -> list["Descendant"]:
        url = "rest/api/content/search"
        params = {
            "cql": f"type=page AND ancestor={self.id}",
            "expand": "metadata.properties,ancestors,version",
            "limit": 250,
        }
        results = []
        client = get_thread_confluence(self.base_url)

        try:
            response = cast("dict", client.get(url, params=params))
            results.extend(response.get("results", []))
            next_path = response.get("_links", {}).get("next")

            while next_path:
                response = cast("dict", client.get(next_path))
                results.extend(response.get("results", []))
                next_path = response.get("_links", {}).get("next")

        except HTTPError as e:
            if e.response.status_code == 404:  # noqa: PLR2004
                logger.warning(
                    f"Content with ID {self.id} not found (404) when fetching descendants."
                )
                return []
            return []
        except Exception:
            logger.exception(
                f"Unexpected error when fetching descendants for content ID {self.id}."
            )
            return []
        return [Descendant.from_json(result, self.base_url) for result in results]

    @property
    def _template_vars(self) -> dict[str, str]:
        return {
            **super()._template_vars,
            "page_id": str(self.id),
            "page_title": sanitize_filename(self.title),
        }

    @property
    def export_path(self) -> Path:
        filepath_template = Template(settings.export.page_path.replace("{", "${"))
        return Path(filepath_template.safe_substitute(self._template_vars))

    @property
    def html(self) -> str:
        if settings.export.include_document_title:
            return f"<h1>{self.title}</h1>{self.body}"
        return self.body

    @property
    def markdown(self) -> str:
        return self.Converter(self).markdown

    def export(self) -> dict[str, AttachmentEntry]:
        if self.title == "Page not accessible":
            logger.warning("Skipping export for inaccessible page id=%s", self.id)
            return {}

        logger.debug("Exporting page id=%s '%s'", self.id, self.title)
        if settings.export.log_level == "DEBUG":
            self.export_body()
        # Export attachments first so the files can be utilized during markdown conversion
        logger.debug("Exporting attachments for page id=%s", self.id)
        attachment_entries = self.export_attachments()
        logger.debug("Converting to Markdown for page id=%s", self.id)
        self.export_markdown()
        if settings.export.comments_export != "none":
            logger.debug("Exporting comments for page id=%s", self.id)
            self.export_comments_sidecar()
        logger.info(
            "Exported '%s' -> %s", self.title, settings.export.output_path / self.export_path
        )
        return attachment_entries

    def export_with_descendants(self) -> None:
        with console.status(
            f"[dim]Fetching descendants of [highlight]{self.title}[/highlight]…[/dim]"
        ):
            pages = [self, *self.descendants]
        export_pages(pages)

    def export_body(self) -> None:
        soup = BeautifulSoup(self.html, "html.parser")
        save_file(
            settings.export.output_path
            / self.export_path.parent
            / f"{self.export_path.stem}_body_view.html",
            str(soup.prettify()),
        )
        soup = BeautifulSoup(self.body_export, "html.parser")
        save_file(
            settings.export.output_path
            / self.export_path.parent
            / f"{self.export_path.stem}_body_export_view.html",
            str(soup.prettify()),
        )
        save_file(
            settings.export.output_path
            / self.export_path.parent
            / f"{self.export_path.stem}_body_editor2.xml",
            str(self.editor2),
        )

    def export_markdown(self) -> None:
        conv = self.Converter(self)
        save_file(
            settings.export.output_path / self.export_path,
            conv.markdown,
        )
        self._marked_texts: dict[str, str] = conv._marked_texts

    _COMMENT_TITLE_MAX_LEN = 60

    def _fetch_inline_comments(self) -> list[dict]:
        client = get_thread_confluence(self.base_url)
        results: list[dict] = []
        try:
            resp = cast(
                "dict",
                client.get_page_comments(
                    self.id,
                    location="inline",
                    expand="extensions.inlineProperties,extensions.resolution,body.view,history.createdBy",
                    limit=50,
                ),
            )
            for comment in resp.get("results", []):
                status = comment.get("extensions", {}).get("resolution", {}).get("status", "open")
                if status == "open":
                    results.append(comment)
            next_path = resp.get("_links", {}).get("next")
            while next_path:
                resp = cast("dict", client.get(next_path))
                for comment in resp.get("results", []):
                    status = (
                        comment.get("extensions", {}).get("resolution", {}).get("status", "open")
                    )
                    if status == "open":
                        results.append(comment)
                next_path = resp.get("_links", {}).get("next")
        except Exception:  # noqa: BLE001
            logger.warning("Failed to fetch inline comments for page id=%s", self.id)
        return results

    def _fetch_page_comments(self) -> list[dict]:
        client = get_thread_confluence(self.base_url)
        results: list[dict] = []
        try:
            resp = cast(
                "dict",
                client.get_page_comments(
                    self.id,
                    location="footer",
                    expand="extensions.resolution,body.view,history.createdBy",
                    limit=50,
                ),
            )
            for comment in resp.get("results", []):
                status = comment.get("extensions", {}).get("resolution", {}).get("status", "open")
                if status == "open":
                    results.append(comment)
            next_path = resp.get("_links", {}).get("next")
            while next_path:
                resp = cast("dict", client.get(next_path))
                for comment in resp.get("results", []):
                    status = (
                        comment.get("extensions", {}).get("resolution", {}).get("status", "open")
                    )
                    if status == "open":
                        results.append(comment)
                next_path = resp.get("_links", {}).get("next")
        except Exception:  # noqa: BLE001
            logger.warning("Failed to fetch page comments for page id=%s", self.id)
        return results

    def _fetch_comment_replies(self, comment_id: str) -> list[dict]:
        client = get_thread_confluence(self.base_url)
        try:
            resp = cast(
                "dict",
                client.get(
                    f"rest/api/content/{comment_id}/child/comment",
                    params={"expand": "body.view,history.createdBy", "limit": 50},
                ),
            )
            return resp.get("results", [])
        except Exception:  # noqa: BLE001
            return []

    def export_comments_sidecar(self) -> None:
        mode = settings.export.comments_export
        inline = self._fetch_inline_comments() if mode in ("inline", "all") else []
        page = self._fetch_page_comments() if mode in ("footer", "all") else []
        if not inline and not page:
            return

        source_url = f"{self.base_url}/wiki/spaces/{self.space.key}/pages/{self.id}"

        lines: list[str] = [
            "---",
            f"confluence_page_id: '{self.id}'",
            f'confluence_page_title: "{self.title}"',
            f'confluence_webui_url: "{source_url}"',
            "---",
            "",
        ]

        if inline:
            lines.append("## Inline comments")
            lines.append("")
            self._render_inline_comments(lines, inline)

        if page:
            lines.append("## Page comments")
            lines.append("")
            self._render_page_comments(lines, page)

        save_file(
            settings.export.output_path
            / self.export_path.parent
            / f"{self.export_path.stem}.comments.md",
            "\n".join(lines),
        )

    def _render_inline_comments(self, lines: list[str], comments: list[dict]) -> None:
        for comment in comments:
            ref = comment.get("extensions", {}).get("inlineProperties", {}).get("markerRef", "")
            marked_md = self._marked_texts.get(ref, "")

            plain = re.sub(r"\s+", " ", marked_md).strip()
            n = self._COMMENT_TITLE_MAX_LEN
            short_title = plain[:n] + "…" if len(plain) > n else plain
            if not short_title:
                short_title = f"Comment {ref[:8]}"
            lines.append(f"### {short_title}")
            lines.append("")

            if marked_md:
                lines.extend(
                    f"> {line}" if line.strip() else ">" for line in marked_md.splitlines()
                )
                lines.append("")

            author = comment.get("history", {}).get("createdBy", {}).get("displayName", "Unknown")
            created = comment.get("history", {}).get("createdDate", "")[:10]
            body_md = (
                MarkdownConverter()
                .convert(comment.get("body", {}).get("view", {}).get("value", ""))
                .strip()
            )

            lines.append(f"**{author}** · {created}")
            lines.append("")
            if body_md:
                lines.append(body_md)
                lines.append("")

            for reply in self._fetch_comment_replies(comment["id"]):
                r_author = (
                    reply.get("history", {}).get("createdBy", {}).get("displayName", "Unknown")
                )
                r_created = reply.get("history", {}).get("createdDate", "")[:10]
                r_body_md = (
                    MarkdownConverter()
                    .convert(reply.get("body", {}).get("view", {}).get("value", ""))
                    .strip()
                )
                lines.append(f"**{r_author}** · {r_created}")
                lines.append("")
                if r_body_md:
                    lines.append(r_body_md)
                    lines.append("")

    def _render_page_comments(self, lines: list[str], comments: list[dict]) -> None:
        for comment in comments:
            body_md = (
                MarkdownConverter()
                .convert(comment.get("body", {}).get("view", {}).get("value", ""))
                .strip()
            )

            plain = re.sub(r"\s+", " ", body_md).strip()
            n = self._COMMENT_TITLE_MAX_LEN
            short_title = plain[:n] + "…" if len(plain) > n else plain
            if not short_title:
                short_title = f"Comment {str(comment.get('id', ''))[:8]}"
            lines.append(f"### {short_title}")
            lines.append("")

            author = comment.get("history", {}).get("createdBy", {}).get("displayName", "Unknown")
            created = comment.get("history", {}).get("createdDate", "")[:10]
            lines.append(f"**{author}** · {created}")
            lines.append("")
            if body_md:
                lines.append(body_md)
                lines.append("")

            for reply in self._fetch_comment_replies(comment["id"]):
                r_author = (
                    reply.get("history", {}).get("createdBy", {}).get("displayName", "Unknown")
                )
                r_created = reply.get("history", {}).get("createdDate", "")[:10]
                r_body_md = (
                    MarkdownConverter()
                    .convert(reply.get("body", {}).get("view", {}).get("value", ""))
                    .strip()
                )
                lines.append(f"**{r_author}** · {r_created}")
                lines.append("")
                if r_body_md:
                    lines.append(r_body_md)
                    lines.append("")

    def _attachments_for_export(self) -> list["Attachment"]:
        """Return the subset of attachments that should be exported for this page."""
        if settings.export.attachments_export == "all":
            return list(self.attachments)
        bodies = self.body + self.body_export
        return [
            a
            for a in self.attachments
            if (a.filename.endswith(".drawio") and f"diagramName={a.title}" in self.body)
            or (
                a.filename.endswith((".drawio.png", ".drawio"))
                and a.title.replace(" ", "%20") in self.body_export
            )
            or a.file_id in bodies
            or a.id in bodies
            or a.title in bodies
            or a.title.replace(" ", "%20") in bodies
        ]

    def export_attachments(self) -> dict[str, AttachmentEntry]:
        if settings.export.attachments_export == "disabled":
            logger.debug("Attachment download disabled for page id=%s", self.id)
            return {}
        old_entries = LockfileManager.get_page_attachment_entries(str(self.id))
        new_entries: dict[str, AttachmentEntry] = {}
        output_path = settings.export.output_path
        stats = get_stats()

        for attachment in self._attachments_for_export():
            att_id = attachment.id
            att_version = attachment.version.number if attachment.version else 0

            # Skip download if the same attachment version is tracked and the file still exists
            if att_id in old_entries:
                old = old_entries[att_id]
                if old.version == att_version and (output_path / old.path).exists():
                    new_entries[att_id] = old
                    logger.debug(
                        "Skipping unchanged attachment '%s' (v%d)", attachment.title, att_version
                    )
                    stats.inc_attachments_skipped()
                    continue

            attachment.export()
            if att_version:
                new_entries[att_id] = AttachmentEntry(
                    version=att_version, path=str(attachment.export_path)
                )

        # Clean up orphaned attachment files when an attachment was re-versioned
        for att_id, old_entry in old_entries.items():
            if att_id in new_entries and old_entry.path != new_entries[att_id].path:
                old_file = output_path / old_entry.path
                old_file.unlink(missing_ok=True)
                logger.info("Deleted old attachment file: %s", old_entry.path)
                stats.inc_attachments_removed()

        return new_entries

    def get_attachment_by_id(self, attachment_id: str) -> Attachment | None:
        """Get the Attachment object by its ID.

        Confluence Server sometimes stores attachments without a file_id.
        Fall back to the plain attachment.id and return None if nothing matches.
        """
        for a in self.attachments:
            if attachment_id in a.id:
                return a
            if a.file_id and attachment_id in a.file_id:
                return a
        return None

    def get_attachment_by_file_id(self, file_id: str) -> Attachment | None:
        for a in self.attachments:
            if a.file_id and file_id in a.file_id:
                return a
        return None

    def get_attachments_by_title(self, title: str) -> list[Attachment]:
        return [attachment for attachment in self.attachments if attachment.title == title]

    @classmethod
    def from_json(cls, data: JsonResponse, base_url: str) -> "Page":
        return cls(
            base_url=base_url,
            id=data.get("id", 0),
            type=data.get("type", ""),
            web_url=_get_web_url(data),
            tiny_url=_get_tiny_url(data),
            title=data.get("title", ""),
            space=Space.from_key(
                data.get("_expandable", {}).get("space", "").split("/")[-1], base_url
            ),
            body=data.get("body", {}).get("view", {}).get("value", ""),
            body_export=data.get("body", {}).get("export_view", {}).get("value", ""),
            editor2=data.get("body", {}).get("editor2", {}).get("value", ""),
            body_storage=data.get("body", {}).get("storage", {}).get("value", ""),
            labels=[
                Label.from_json(label)
                for label in data.get("metadata", {}).get("labels", {}).get("results", [])
            ],
            attachments=Attachment.from_page_id(data.get("id", 0), base_url),
            ancestors=[
                Ancestor.from_json(ancestor, base_url) for ancestor in data.get("ancestors", [])
            ][1:],
            version=Version.from_json(data.get("version", {})),
            history=History.from_json(data.get("history", {})),
        )

    @classmethod
    @functools.lru_cache(maxsize=1000)
    def from_id(cls, page_id: int, base_url: str) -> "Page":
        _empty_space = Space(base_url=base_url, key="", name="", description="", homepage=0)
        if page_id is None:
            logger.warning("Page ID is None, returning empty page")
            return cls(
                base_url=base_url,
                id=0,
                title="Page not accessible",
                space=_empty_space,
                body="",
                body_export="",
                editor2="",
                labels=[],
                attachments=[],
                ancestors=[],
            )
        logger.debug("Fetching page id=%s from %s", page_id, base_url)
        expand = (
            "body.view,body.export_view,body.editor2,body.storage,metadata.labels,"
            "metadata.properties,ancestors,version,history,history.createdBy"
        )
        try:
            return cls.from_json(
                _require_dict(
                    get_thread_confluence(base_url).get_page_by_id(
                        page_id,
                        expand=expand,
                    ),
                    f"page id={page_id} at {base_url}",
                ),
                base_url,
            )
        except (ApiError, HTTPError):
            logger.warning("Could not access page id=%s — treating as inaccessible", page_id)
            return cls(
                base_url=base_url,
                id=page_id,
                title="Page not accessible",
                space=_empty_space,
                body="",
                body_export="",
                editor2="",
                labels=[],
                attachments=[],
                ancestors=[],
                version=Version.from_json({}),
            )

    @classmethod
    def from_url(cls, page_url: str) -> "Page":
        """Retrieve a Page object given a Confluence page URL.

        The Confluence instance is selected automatically by matching the URL's
        hostname against configured instances.  If no match is found, a new
        entry is registered in the auth config so the user can fill in
        credentials via the interactive config menu.

        Supports standard instance URLs and Atlassian API gateway URLs of the form
        ``https://api.atlassian.com/ex/confluence/{cloudId}/wiki/spaces/KEY/pages/123``.
        """
        base_url = _extract_base_url(page_url)

        # Ensure a client exists (creates/prompts if first time for this host)
        get_confluence_instance(base_url)

        parsed = urllib.parse.urlparse(page_url)
        query_params = urllib.parse.parse_qs(parsed.query)
        page_id_param = next(
            (
                values[0]
                for key, values in query_params.items()
                if key.lower() == "pageid" and values and values[0]
            ),
            None,
        )
        if page_id_param and page_id_param.isdigit():
            page_id = int(page_id_param)
            logger.debug(
                "Resolved page id=%s from Confluence query string in URL %s", page_id, page_url
            )
            return Page.from_id(page_id, base_url)

        base_path = urllib.parse.urlparse(base_url).path.rstrip("/")
        relative_path = parsed.path[len(base_path) :]
        if match := parse_confluence_path(relative_path):
            if match.page_id:
                logger.debug("Resolved page id=%s from Confluence URL %s", match.page_id, page_url)
                return Page.from_id(match.page_id, base_url)

            if match.space_key and match.page_title:
                logger.debug(
                    "Resolving page '%s' in space '%s' from Confluence URL %s",
                    match.page_title,
                    match.space_key,
                    page_url,
                )
                page_data = _require_dict(
                    get_thread_confluence(base_url).get_page_by_title(
                        space=match.space_key, title=match.page_title, expand="version"
                    ),
                    f"page title={match.page_title!r} space={match.space_key!r} at {base_url}",
                )
                return Page.from_id(page_data["id"], base_url)

        msg = f"Could not parse page URL {page_url}."
        raise ValueError(msg)

    class Converter(TableConverter, MarkdownConverter):
        """Create a custom MarkdownConverter for Confluence HTML to Markdown conversion."""

        class Options(MarkdownConverter.DefaultOptions):  # type: ignore[assignment]
            bullets = "-"
            heading_style = ATX
            macros_to_ignore: Set[str] = frozenset(["qc-read-and-understood-signature-box"])
            front_matter_indent = 2

        def __init__(self, page: "Page", **options) -> None:  # noqa: ANN003
            super().__init__(**options)
            self.page = page
            self.page_properties = {}
            self._marked_texts: dict[str, str] = {}
            self._colorid_map_cache: dict[str, str] | None = None
            self._image_captions_cache: dict[str, str] | None = None
            self._panel_icon_map_cache: dict[str, str] | None = None
            self._plantuml_index: int = 0
            self._storage_plantuml_macros_cache: list[Tag] | None = None

        @property
        def _colorid_map(self) -> dict[str, str]:
            if self._colorid_map_cache is None:
                cache: dict[str, str] = {}
                soup = BeautifulSoup(self.page.html, "html.parser")
                for style_tag in soup.find_all("style"):
                    css = style_tag.get_text()
                    for m in _RE_COLORID_CSS.finditer(css):
                        color_id = m.group(1)
                        if color_id not in cache:
                            cache[color_id] = m.group(2)
                self._colorid_map_cache = cache
            return self._colorid_map_cache

        @property
        def _storage_plantuml_macros(self) -> list[Tag]:
            """Cache and return all PlantUML structured-macros from body.storage."""
            if self._storage_plantuml_macros_cache is None:
                macros: list[Tag] = []
                if self.page.body_storage:
                    wrapped = f"<root>{self.page.body_storage}</root>"
                    soup = BeautifulSoup(wrapped, "xml")
                    macros.extend(
                        macro
                        for macro in soup.find_all("structured-macro")
                        if isinstance(macro, Tag) and macro.get("name") == "plantuml"
                    )
                self._storage_plantuml_macros_cache = macros
            return self._storage_plantuml_macros_cache

        @property
        def _image_captions(self) -> dict[str, str]:
            if self._image_captions_cache is None:
                self._image_captions_cache = _parse_image_captions(self.page.body_storage)
            return self._image_captions_cache

        @property
        def _panel_icon_map(self) -> dict[str, str]:
            """Map panel macro-id to its custom icon emoji from editor2 XML."""
            if self._panel_icon_map_cache is None:
                cache: dict[str, str] = {}
                if self.page.editor2:
                    wrapped = f"<root>{self.page.editor2}</root>"
                    soup = BeautifulSoup(wrapped, "xml")
                    panel_names = {"panel", "info", "note", "tip", "warning"}
                    for macro in soup.find_all("structured-macro"):
                        if not isinstance(macro, Tag):
                            continue
                        if macro.get("name") not in panel_names:
                            continue
                        macro_id = macro.get("macro-id")
                        if not macro_id:
                            continue
                        emoji = self._extract_panel_emoji(macro)
                        if emoji:
                            cache[str(macro_id)] = emoji
                self._panel_icon_map_cache = cache
            return self._panel_icon_map_cache

        @staticmethod
        def _extract_panel_emoji(macro: Tag) -> str | None:
            params: dict[str, str] = {}
            for p in macro.find_all("parameter", recursive=False):
                if not isinstance(p, Tag):
                    continue
                name = p.get("name")
                if name:
                    params[str(name)] = p.get_text(strip=True)
            if text := params.get("panelIconText"):
                return text
            if icon_id := params.get("panelIconId"):
                try:
                    cps = [int(cp, 16) for cp in icon_id.split("-")]
                    if all(0 <= cp <= _MAX_UNICODE_CODEPOINT for cp in cps):
                        return "".join(chr(cp) for cp in cps)
                except (OverflowError, ValueError):
                    pass
            return None

        @property
        def markdown(self) -> str:
            html = self._strip_excerpt_include_panel_titles(self.page.html)
            md_body = self.convert(html)
            md_body = self._escape_template_placeholders(md_body)
            markdown = f"{self.front_matter}\n"
            if settings.export.page_breadcrumbs:
                markdown += f"{self.breadcrumbs}\n"
            markdown += f"{md_body}\n"
            return markdown

        @property
        def front_matter(self) -> str:
            indent = self.options["front_matter_indent"]
            self.set_page_properties(tags=self.labels)
            self._add_confluence_url_properties()
            self._add_page_metadata_properties()

            if not self.page_properties:
                return ""

            yml = yaml.dump(self.page_properties, indent=indent).strip()
            # Indent the root level list items
            yml = re.sub(r"^( *)(- )", r"\1" + " " * indent + r"\2", yml, flags=re.MULTILINE)
            return f"---\n{yml}\n---\n"

        def _add_confluence_url_properties(self) -> None:
            mode = settings.export.confluence_url_in_frontmatter
            if mode == "none":
                return

            if mode in ("webui", "both") and self.page.web_url:
                key = sanitize_key("confluence_webui_url")
                if key not in self.page_properties:
                    self.page_properties[key] = self.page.web_url

            if mode in ("tinyui", "both") and self.page.tiny_url:
                key = sanitize_key("confluence_tinyui_url")
                if key not in self.page_properties:
                    self.page_properties[key] = self.page.tiny_url

        def _add_page_metadata_properties(self) -> None:
            if not settings.export.page_metadata_in_frontmatter:
                return

            page = self.page
            version = page.version
            history = page.history
            metadata = {
                # Stored as str to stay JS-safe-integer compatible: Confluence
                # Cloud page IDs can exceed 2^53, which JS-based SSGs (Hugo,
                # Astro, ...) parsing the front matter would silently truncate.
                "confluence_page_id": str(page.id),
                "confluence_space_key": page.space.key,
                "confluence_type": page.type,
                "confluence_created": history.created,
                "confluence_created_by": history.created_by.display_name,
                "confluence_last_modified": version.when,
                "confluence_last_modified_by": version.by.display_name,
                "confluence_version": version.number,
            }
            for raw_key, value in metadata.items():
                if value in (None, "", 0):
                    continue
                key = sanitize_key(raw_key)
                if key not in self.page_properties:
                    self.page_properties[key] = value

        @property
        def breadcrumbs(self) -> str:
            return (
                " > ".join(
                    [self.convert_page_link(ancestor.id) for ancestor in self.page.ancestors]
                )
                + "\n"
            )

        @property
        def labels(self) -> list[str]:
            return [label.name for label in self.page.labels]

        def set_page_properties(self, **props: list[str] | str | None) -> None:
            for key, value in props.items():
                if value:
                    self.page_properties[sanitize_key(key)] = value

        def convert_page_properties(
            self, el: BeautifulSoup, text: str, parent_tags: list[str]
        ) -> str | None:
            fmt = settings.export.page_properties_format

            if fmt == "table":
                return text

            rows = [
                cast("list[Tag]", tr.find_all(["th", "td"]))
                for tr in cast("list[Tag]", el.find_all("tr"))
                if tr
            ]
            if not rows:
                return None

            props: dict[str, str] = {}
            key_counts: dict[str, int] = {}
            for row in rows:
                if len(row) == 2:  # noqa: PLR2004
                    raw_key = row[0].get_text(strip=True)
                    count = key_counts.get(raw_key, 0) + 1
                    key_counts[raw_key] = count
                    unique_key = raw_key if count == 1 else f"{raw_key} {count}"
                    props[unique_key] = self.convert(str(row[1])).strip()

            if fmt in ("frontmatter", "frontmatter_and_table", "meta-bind-view-fields"):
                self.set_page_properties(**props)

            if fmt == "frontmatter":
                return None

            if fmt == "frontmatter_and_table":
                return text

            if fmt == "dataview-inline-field":
                lines = "\n".join(f"{k}:: {v}" for k, v in props.items())
                return f"\n{lines}\n"

            # meta-bind-view-fields: two-column table with VIEW fields in value column
            table_data = [
                (f"**{k}**", f"`VIEW[{{{sanitize_key(k)}}}][text(renderMarkdown)]`") for k in props
            ]
            return "\n\n" + tabulate(table_data, headers=["", ""], tablefmt="pipe") + "\n"

        def convert_alert(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:
            """Convert Confluence info macros to Markdown GitHub style alerts.

            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

            Inside table cells GitHub alerts don't render in most viewers
            (Obsidian, etc.), so emit a leading emoji + plain text instead.
            """
            alert_type_map = {
                "info": "IMPORTANT",
                "panel": "NOTE",
                "tip": "TIP",
                "note": "WARNING",
                "warning": "CAUTION",
            }
            alert_emoji_map = {
                "NOTE": "\U0001f4dd",
                "TIP": "\U0001f4a1",
                "IMPORTANT": "❗",
                "WARNING": "⚠️",
                "CAUTION": "\U0001f6d1",
            }

            alert_type = alert_type_map.get(str(el["data-macro-name"]), "NOTE")

            macro_id = el.get("data-macro-id")
            custom_emoji = self._panel_icon_map.get(str(macro_id)) if macro_id else None
            emoji = custom_emoji or alert_emoji_map[alert_type]

            tags = parent_tags if isinstance(parent_tags, list | set) else set()
            if "td" in tags or "th" in tags:
                return f"{emoji} {text.strip()}"

            blockquote = super().convert_blockquote(el, text, parent_tags)
            return f"\n> [!{alert_type}]{blockquote}"

        def convert_div(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:
            # Handle Confluence macros
            if el.has_attr("data-macro-name"):
                macro_name = str(el["data-macro-name"])
                if macro_name in self.options["macros_to_ignore"]:
                    return ""

                macro_handlers = {
                    "panel": self.convert_alert,
                    "info": self.convert_alert,
                    "note": self.convert_alert,
                    "tip": self.convert_alert,
                    "warning": self.convert_alert,
                    "details": self.convert_page_properties,
                    "drawio": self.convert_drawio,
                    "plantuml": self.convert_plantuml,
                    "scroll-ignore": self.convert_hidden_content,
                    "toc": self.convert_toc,
                    "jira": self.convert_jira_table,
                    "attachments": self.convert_attachments,
                    "markdown": self.convert_markdown,
                    "mohamicorp-markdown": self.convert_markdown,
                    "include": self.convert_include,
                    "excerpt-include": self.convert_include,
                }
                if macro_name in macro_handlers:
                    return macro_handlers[macro_name](el, text, parent_tags)

            class_handlers = {
                "expand-container": self.convert_expand_container,
                "columnLayout": self.convert_column_layout,
            }
            for class_name, handler in class_handlers.items():
                if class_name in str(el.get("class", "")):
                    return handler(el, text, parent_tags)

            return super().convert_div(el, text, parent_tags)

        def convert_expand_container(
            self, el: BeautifulSoup, text: str, parent_tags: list[str]
        ) -> str:
            """Convert expand-container div to HTML details element."""
            # Extract summary text from expand-control-text
            summary_element = el.find("span", class_="expand-control-text")
            summary_text = (
                summary_element.get_text().strip() if summary_element else "Click here to expand..."
            )

            # Extract content from expand-content
            content_element = el.find("div", class_="expand-content")
            # Recursively convert the content
            content = (
                self.process_tag(content_element, parent_tags).strip() if content_element else ""
            )

            # Return as details element
            return f"\n<details>\n<summary>{summary_text}</summary>\n\n{content}\n\n</details>\n\n"

        def _span_highlight(self, style: str, text: str) -> str | None:
            bg_m = _RE_RGB_BG.search(style)
            if not bg_m:
                return None
            hex_color = _rgb_to_hex(int(bg_m.group(1)), int(bg_m.group(2)), int(bg_m.group(3)))
            return f'<mark style="background: {hex_color};">{text}</mark>'

        def _wrap_cell_highlight(self, el: BeautifulSoup, text: str) -> str:
            if not settings.export.convert_text_highlights:
                return text
            bg = _extract_cell_highlight_hex(el)
            if bg is None:
                return text
            inner = text or "&nbsp;"
            return f'<mark style="background: {bg};">{inner}</mark>'

        def convert_td(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:
            text = super().convert_td(el, text, parent_tags)
            return self._wrap_cell_highlight(el, text)

        def convert_th(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:
            text = super().convert_th(el, text, parent_tags)
            return self._wrap_cell_highlight(el, text)

        def _span_font_color(self, el: BeautifulSoup, style: str, text: str) -> str | None:
            color_m = _RE_RGB_COLOR.search(style)
            if color_m:
                hex_color = _rgb_to_hex(
                    int(color_m.group(1)), int(color_m.group(2)), int(color_m.group(3))
                )
                return f'<font style="color: {hex_color};">{text}</font>'
            color_id = el.get("data-colorid")
            if isinstance(color_id, str):
                hex_color = self._colorid_map.get(color_id)
                if hex_color:
                    return f'<font style="color: {hex_color};">{text}</font>'
            return None

        def _span_status_badge(self, el: BeautifulSoup, text: str) -> str | None:
            if not settings.export.convert_status_badges:
                return None
            classes = el.get("class") or []
            if not isinstance(classes, list):
                return None
            if "status-macro" not in classes:
                return None
            bg = "#dfe1e6"  # default gray
            for cls, color in _LOZENGE_COLORS.items():
                if cls in classes:
                    bg = color
                    break
            return f'<mark style="background: {bg};">{text.strip()}</mark>'

        def convert_span(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:  # noqa: C901, PLR0911
            if el.has_attr("data-macro-name"):
                if el["data-macro-name"] == "jira":
                    return self.convert_jira_issue(el, text, parent_tags)
                if el["data-macro-name"] == "status":
                    result = self._span_status_badge(el, text)
                    if result is not None:
                        return result
                if el["data-macro-name"] == "plantuml":
                    return self.convert_plantuml(el, text, parent_tags)

            if el.has_attr("class") and "inline-comment-marker" in el["class"]:
                return self.convert_inline_comment_marker(el, text, parent_tags)

            raw_style = el.get("style", "")
            style = raw_style if isinstance(raw_style, str) else ""
            if settings.export.convert_text_highlights:
                result = self._span_highlight(style, text)
                if result is not None:
                    return result

            if settings.export.convert_font_colors:
                result = self._span_font_color(el, style, text)
                if result is not None:
                    return result

            return text

        def convert_inline_comment_marker(
            self, el: BeautifulSoup, text: str, _parent_tags: list[str]
        ) -> str:
            if settings.export.comments_export in ("inline", "all"):
                ref = el.get("data-ref", "")
                if isinstance(ref, str) and ref and ref not in self._marked_texts:
                    self._marked_texts[ref] = text
            return text

        def convert_attachments(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:
            file_header = el.find("th", {"class": "filename-column"})
            file_header_text = file_header.text.strip() if file_header else "File"

            modified_header = el.find("th", {"class": "modified-column"})
            modified_header_text = modified_header.text.strip() if modified_header else "Modified"

            def _get_path(p: Path) -> str:
                attachment_path = self._get_path_for_href(p, settings.export.attachment_href)
                return attachment_path.replace(" ", "%20")

            def _attachment_link(att: Attachment) -> str:
                if settings.export.attachment_href == "wiki":
                    return f"[[{att.export_path.name}|{att.title}]]"
                return f"[{att.title}]({_get_path(att.export_path)})"

            rows = [
                {
                    "file": _attachment_link(att),
                    "modified": f"{att.version.friendly_when} by {self.convert_user(att.version.by)}",  # noqa: E501
                }
                for att in self.page.attachments
            ]

            html = f"""<table>
            <tr><th>{file_header_text}</th><th>{modified_header_text}</th></tr>
            {"".join(f"<tr><td>{row['file']}</td><td>{row['modified']}</td></tr>" for row in rows)}
            </table>"""

            return (
                f"\n\n{self.convert_table(BeautifulSoup(html, 'html.parser'), text, parent_tags)}\n"
            )

        def convert_column_layout(
            self, el: BeautifulSoup, text: str, parent_tags: list[str]
        ) -> str:
            cells = el.find_all("div", {"class": "cell"})

            if len(cells) < 2:  # noqa: PLR2004
                return super().convert_div(el, text, parent_tags)

            html = f"<table><tr>{''.join([f'<td>{cell!s}</td>' for cell in cells])}</tr></table>"

            return self.convert_table(BeautifulSoup(html, "html.parser"), text, parent_tags)

        def convert_jira_table(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:
            jira_tables = BeautifulSoup(self.page.body_export, "html.parser").find_all(
                "div", {"class": "jira-table"}
            )

            if len(jira_tables) == 0:
                logger.warning("No Jira table found. Ignoring.")
                return text

            if len(jira_tables) > 1:
                logger.exception("Multiple Jira tables are not supported. Ignoring.")
                return text

            return self.process_tag(jira_tables[0], parent_tags)

        def convert_toc(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:
            if not settings.export.include_toc:
                return ""

            tocs = BeautifulSoup(self.page.body_export, "html.parser").find_all(
                "div", {"class": "toc-macro"}
            )

            if len(tocs) == 0:
                logger.warning("Could not find TOC macro. Ignoring.")
                return text

            if len(tocs) > 1:
                logger.exception("Multiple TOC macros are not supported. Ignoring.")
                return text

            return self.process_tag(tocs[0], parent_tags)

        def convert_hidden_content(
            self, el: BeautifulSoup, text: str, parent_tags: list[str]
        ) -> str:
            content = super().convert_p(el, text, parent_tags)
            if not content.strip():
                return ""
            return f"\n<!--{content}-->\n"

        def convert_jira_issue(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:
            issue_key = el.get("data-jira-key")
            link = cast("BeautifulSoup", el.find("a", {"class": "jira-issue-key"}))
            if not link:
                return text
            if not issue_key:
                return self.process_tag(link, parent_tags)

            try:
                jira_url = _extract_jira_base_url(str(link.get("href", ""))) or self.page.base_url
                issue = JiraIssue.from_key(str(issue_key), jira_url)
            except HTTPError:
                return f"[[{issue_key}]]({link.get('href')})"

            if not issue:
                return f"[[{issue_key}]]({link.get('href')})"

            return f"[[{issue.key}] {issue.summary}]({link.get('href')})"

        def convert_pre(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:  # type: ignore[override]
            if not text:
                return ""

            code_language = ""
            if el.has_attr("data-syntaxhighlighter-params"):
                match = re.search(r"brush:\s*([^;]+)", str(el["data-syntaxhighlighter-params"]))
                if match:
                    code_language = match.group(1)

            if "@startuml" in text:
                code_language = "plantuml"

            return f"\n\n```{code_language}\n{text}\n```\n\n"

        def convert_sub(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:
            return f"<sub>{text}</sub>"

        def convert_sup(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:
            """Convert superscript to Markdown footnotes."""
            if el.previous_sibling is None:
                return f"[^{text}]:"  # Footnote definition
            return f"[^{text}]"  # f"<sup>{text}</sup>"

        def convert_a(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:  # noqa: PLR0911, PLR0912, C901
            if "user-mention" in str(el.get("class")):
                return self.convert_user_mention(el, text, parent_tags)
            if "createpage.action" in str(el.get("href")) or "createlink" in str(el.get("class")):
                logger.warning(
                    f"Broken link detected: '{text}' on page '{self.page.title}' "
                    f"(ID: {self.page.id}). This is likely a Confluence bug. "
                    f"Please report this issue to Atlassian Support."
                )
                # Find fallback link without using string= parameter to avoid
                # BeautifulSoup recursion bug. The string= parameter triggers
                # recursive .string property access which fails on Fabric
                # Editor v2 HTML with fab:media tags
                try:
                    soup = BeautifulSoup(self.page.editor2, "html.parser")
                    for link in soup.find_all("a"):
                        # Use get_text() instead of .string to avoid recursion issues
                        link_text = link.get_text(strip=True)
                        if link_text == text:
                            # Prevent infinite recursion if fallback is the same element
                            if isinstance(link, Tag) and link.get("href") != el.get("href"):
                                return self.convert_a(link, text, parent_tags)  # type: ignore[arg-type]
                except RecursionError:
                    # editor2 HTML contains problematic tags (e.g., fab:media)
                    # that cause BS4 recursion. Skip fallback and return
                    # wiki-style link
                    pass
                # If no matching link found, return wiki-style link
                return f"[[{text}]]"
            if "page" in str(el.get("data-linked-resource-type")):
                page_id = str(el.get("data-linked-resource-id", ""))
                if page_id and page_id != "null":
                    return self.convert_page_link(int(page_id))
            if "attachment" in str(el.get("data-linked-resource-type")):
                link = self.convert_attachment_link(el, text, parent_tags)
                # convert_attachment_link may return None if the attachment meta is incomplete
                return link or f"[{text}]({el.get('href')})"
            href_str = str(el.get("href", ""))
            if href_str:
                parsed_href = urlparse(href_str)
                base_host = urlparse(getattr(self.page, "base_url", "") or "").hostname
                if not parsed_href.hostname or parsed_href.hostname == base_host:
                    query_params = urllib.parse.parse_qs(parsed_href.query)
                    page_id_param = next(
                        (
                            values[0]
                            for key, values in query_params.items()
                            if key.lower() == "pageid" and values and values[0]
                        ),
                        None,
                    )
                    if page_id_param and page_id_param.isdigit():
                        return self.convert_page_link(int(page_id_param))
                    if match := parse_confluence_path(parsed_href.path):
                        if match.page_id:
                            return self.convert_page_link(match.page_id)
            if (href := href_str).startswith("#"):
                if settings.export.page_href == "wiki":
                    return f"[[#{text}]]"
                return f"[{text}](#{github_heading_slug(href[1:])})"

            return super().convert_a(el, text, parent_tags)

        def convert_page_link(self, page_id: int) -> str:
            if not page_id:
                msg = "Page link does not have valid page_id."
                raise ValueError(msg)

            page = Page.from_id(page_id, self.page.base_url)

            if page.title == "Page not accessible":
                logger.warning(
                    f"Confluence page link (ID: {page_id}) is not accessible, "
                    f"referenced from page '{self.page.title}' (ID: {self.page.id})"
                )
                return f"[Page not accessible (ID: {page_id})]"

            PageTitleRegistry.register(int(page.id), page.title)

            if settings.export.page_href == "wiki":
                if PageTitleRegistry.is_ambiguous(page.title):
                    vault_path = page.export_path.with_suffix("").as_posix()
                    return f"[[{vault_path}|{page.title}]]"
                return f"[[{page.title}]]"

            page_path = self._get_path_for_href(page.export_path, settings.export.page_href)
            return f"[{page.title}]({page_path.replace(' ', '%20')})"

        def convert_attachment_link(
            self, el: BeautifulSoup, text: str, parent_tags: list[str]
        ) -> str:
            """Build a Markdown link for an attachment.

            If the attachment metadata is missing,
            return the original Confluence URL instead of crashing.
            """
            attachment = None
            if fid := el.get("data-linked-resource-file-id"):
                attachment = self.page.get_attachment_by_file_id(str(fid))
            if not attachment and (fid := el.get("data-media-id")):
                attachment = self.page.get_attachment_by_file_id(str(fid))
            if not attachment and (aid := el.get("data-linked-resource-id")):
                attachment = self.page.get_attachment_by_id(str(aid))

            if attachment is None:
                href = el.get("href") or text
                return f"[{text}]({href})"

            if settings.export.attachment_href == "wiki":
                return f"[[{attachment.export_path.name}|{attachment.title}]]"

            path = self._get_path_for_href(attachment.export_path, settings.export.attachment_href)
            return f"[{attachment.title}]({path.replace(' ', '%20')})"

        def convert_time(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:
            if el.has_attr("datetime"):
                return f"{el['datetime']}"

            return f"{text}"

        def convert_user_mention(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:
            if aid := el.get("data-account-id"):
                try:
                    return self.convert_user(User.from_accountid(str(aid), self.page.base_url))
                except ApiNotFoundError:
                    logger.warning(f"User {aid} not found. Using text instead.")

            return self.convert_user_name(text)

        def convert_user(self, user: User) -> str:
            return self.convert_user_name(user.display_name)

        def convert_user_name(self, name: str) -> str:
            return name.removesuffix("(Unlicensed)").removesuffix("(Deactivated)").strip()

        def convert_li(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:
            md = super().convert_li(el, text, parent_tags)
            bullet = self.options["bullets"][0]

            # Convert Confluence task lists to GitHub task lists
            if el.has_attr("data-inline-task-id"):
                is_checked = el.has_attr("class") and "checked" in el["class"]
                return md.replace(f"{bullet} ", f"{bullet} {'[x]' if is_checked else '[ ]'} ", 1)

            return md

        _ATLASSIAN_EMOTICONS: ClassVar[dict[str, str]] = {
            "atlassian-check_mark": "✅",
            "atlassian-cross_mark": "❌",
            "atlassian-yes": "👍",
            "atlassian-no": "👎",
            "atlassian-information": "\u2139\ufe0f",
            "atlassian-warning": "⚠️",
            "atlassian-forbidden": "🚫",
            "atlassian-plus": "\u2795",
            "atlassian-minus": "\u2796",
            "atlassian-question": "❓",
            "atlassian-exclamation": "❗",
            "atlassian-light_on": "💡",
            "atlassian-light_off": "💡",
            "atlassian-star_yellow": "⭐",
            "atlassian-blue_star": "🔵",
            "atlassian-smile": "😊",
            "atlassian-sad": "😞",
            "atlassian-tongue": "😛",
            "atlassian-biggrin": "😁",
            "atlassian-wink": "😉",
        }

        def _convert_emoticon(self, el: BeautifulSoup) -> str | None:
            classes = el.get("class") or []
            if "emoticon" not in classes:
                return None
            emoji_id = str(el.get("data-emoji-id", ""))
            fallback = str(el.get("data-emoji-fallback", ""))
            if fallback and not fallback.startswith(":"):
                return fallback
            if emoji_id:
                try:
                    codepoints = [int(cp, 16) for cp in emoji_id.split("-")]
                    if all(0 <= cp <= _MAX_UNICODE_CODEPOINT for cp in codepoints):
                        return "".join(chr(cp) for cp in codepoints)
                except (OverflowError, ValueError):
                    pass
                if emoji_id in self._ATLASSIAN_EMOTICONS:
                    return self._ATLASSIAN_EMOTICONS[emoji_id]
            shortname = str(el.get("data-emoji-shortname", ""))
            return shortname or fallback or str(el.get("alt", "")) or None

        def convert_img(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:  # noqa: C901, PLR0911, PLR0912
            if emoticon := self._convert_emoticon(el):
                return emoticon

            attachment = None
            if fid := el.get("data-media-id"):
                attachment = self.page.get_attachment_by_file_id(str(fid))
            if not attachment and (fid := el.get("data-media-id")):
                attachment = self.page.get_attachment_by_file_id(str(fid))
            if not attachment and (fid := el.get("data-linked-resource-file-id")):
                attachment = self.page.get_attachment_by_file_id(str(fid))
            if not attachment and (aid := el.get("data-linked-resource-id")):
                attachment = self.page.get_attachment_by_id(str(aid))
            if not attachment and (encoded_xml := el.get("data-encoded-xml")):
                decoded = unquote(str(encoded_xml))
                if m := re.search(r'ri:filename="([^"]+)"', decoded):
                    matches = self.page.get_attachments_by_title(m.group(1))
                    if matches:
                        attachment = matches[0]

            url_src = str(el.get("src", ""))

            if ".drawio.png" in url_src:
                filename = unquote(urlparse(url_src).path.split("/")[-1])
                drawio_result = self._convert_drawio_embedded_mermaid(filename)
                if drawio_result:
                    return drawio_result
                # If no mermaid diagram extracted, use PNG as attachment fallback
                if attachment is None:
                    drawio_images = self.page.get_attachments_by_title(filename)
                    if len(drawio_images) > 0:
                        attachment = drawio_images[0]

            if attachment is None:
                href = el.get("href") or text
                if href:
                    return f"![{text}]({href})"
                if url_src:
                    return f"![{text}]({url_src})"
                return text

            caption = (
                self._image_captions.get(attachment.title, "")
                if settings.export.image_captions
                else ""
            )

            if settings.export.attachment_href == "wiki":
                img_md = f"![[{attachment.export_path.name}]]"
                return f"{img_md}\n*{caption}*" if caption else img_md

            path = self._get_path_for_href(attachment.export_path, settings.export.attachment_href)
            el["src"] = path.replace(" ", "%20")
            tags = parent_tags if isinstance(parent_tags, list | set) else set()
            if "_inline" in tags:
                tags = set(tags)
                tags.discard("_inline")  # Always show images.
            img_md = super().convert_img(el, text, tags)  # type: ignore[union-attr]
            return f"{img_md}\n*{caption}*" if caption else img_md

        def _normalize_unicode_whitespace(self, text: str) -> str:
            r"""Normalize Unicode whitespace to regular spaces.

            This fixes an issue where markdownify's chomp() function strips Unicode
            whitespace characters (like \xa0 from &nbsp;) entirely, causing missing
            spaces in markdown output.

            Confluence often uses &nbsp; (non-breaking space, \xa0) inside inline
            formatting tags like <em>&nbsp;text</em>. BeautifulSoup correctly converts
            this to \xa0, but markdownify's chomp() doesn't preserve it, resulting in
            output like "word*text*" instead of "word *text*".

            This method normalizes all Unicode whitespace characters to regular ASCII
            spaces so they are preserved by markdownify's chomp() function.

            Args:
                text: Text string to normalize

            Returns:
                Text with Unicode whitespace replaced by regular spaces
            """
            # Normalize all Unicode whitespace to regular space
            # This includes: \xa0 (nbsp), \u2000-\u200a (various spaces),
            # \u2028 (line separator), \u2029 (paragraph separator), etc.
            # Keep \n, \r, \t as-is since they have semantic meaning
            normalized = text
            for char in text:
                if char.isspace() and char not in " \n\r\t":
                    # Replace Unicode whitespace with regular space
                    normalized = normalized.replace(char, " ")
            return normalized

        def escape(self, text: str, parent_tags: list[str]) -> str:
            escaped: str = cast("Any", MarkdownConverter).escape(self, text, parent_tags)
            return escaped.replace("[", r"\[").replace("]", r"\]")

        def _escape_template_placeholders(self, text: str) -> str:
            r"""Escape <placeholder> patterns that Obsidian misparsed as HTML tags.

            Confluence templates use <placeholder text> to mark values that need
            replacing. Obsidian's renderer treats these as HTML, breaking page
            formatting. This method escapes them to \<placeholder text\> so they
            render as literal angle-bracket text.

            Valid HTML tags (e.g. <br/>) are preserved. Content inside fenced code
            blocks and inline code spans is left untouched.
            """

            def _escape_if_placeholder(m: re.Match) -> str:
                inner = m.group(1)
                if _AUTOLINK_URI_RE.match(inner) or _AUTOLINK_EMAIL_RE.match(inner):
                    return m.group(0)
                # Strip leading slash (closing tag), get first token, strip trailing slash
                stripped = inner.strip().lstrip("/")
                tag_name = re.split(r"[\s/]", stripped)[0].lower() if stripped else ""
                if tag_name in _HTML_ELEMENTS or inner.startswith("!"):
                    return m.group(0)
                return f"\\<{inner}\\>"

            lines = text.split("\n")
            result = []
            in_fence = False
            for line in lines:
                if _CODE_FENCE_RE.match(line):
                    in_fence = not in_fence
                    result.append(line)
                    continue
                if in_fence:
                    result.append(line)
                    continue
                # Interleave non-code and inline-code parts; only process non-code
                parts = _INLINE_CODE_RE.split(line)
                codes = _INLINE_CODE_RE.findall(line)
                processed = []
                for i, part in enumerate(parts):
                    processed.append(_ANGLE_BRACKET_RE.sub(_escape_if_placeholder, part))
                    if i < len(codes):
                        processed.append(codes[i])
                result.append("".join(processed))
            return "\n".join(result)

        def convert_em(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:
            """Convert <em> tags, preserving spaces from Unicode whitespace entities."""
            text = self._normalize_unicode_whitespace(text)
            return super().convert_em(el, text, parent_tags)

        def convert_strong(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:
            """Convert <strong> tags, preserving spaces from Unicode whitespace entities."""
            text = self._normalize_unicode_whitespace(text)
            return super().convert_strong(el, text, parent_tags)

        def convert_code(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:
            """Convert <code> tags, preserving spaces from Unicode whitespace entities."""
            text = self._normalize_unicode_whitespace(text)
            return super().convert_code(el, text, parent_tags)

        def convert_i(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:
            """Convert <i> tags, preserving spaces from Unicode whitespace entities."""
            text = self._normalize_unicode_whitespace(text)
            return super().convert_i(el, text, parent_tags)

        def convert_b(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:
            """Convert <b> tags, preserving spaces from Unicode whitespace entities."""
            text = self._normalize_unicode_whitespace(text)
            return super().convert_b(el, text, parent_tags)

        def _convert_drawio_embedded_mermaid(self, filename: str) -> str | None:
            """Extract mermaid diagram from DrawIO PNG preview image.

            Args:
                filename: The filename of the drawio diagram image.

            Returns:
                Markdown formatted mermaid diagram or None if not found.
            """
            drawio_title = filename.removesuffix(".png")
            drawio_attachments = self.page.get_attachments_by_title(drawio_title)

            if len(drawio_attachments) == 0:
                return None

            drawio_filepath = settings.export.output_path / drawio_attachments[0].export_path
            if not drawio_filepath.exists():
                return None

            # Extract mermaid diagram from DrawIO file
            return load_and_parse_drawio(str(drawio_filepath))

        def convert_drawio(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:
            if match := re.search(r"\|diagramName=(.+?)\|", str(el)):
                drawio_name = match.group(1)
                preview_name = f"{drawio_name}.png"
                drawio_attachments = self.page.get_attachments_by_title(drawio_name)
                preview_attachments = self.page.get_attachments_by_title(preview_name)

                if not drawio_attachments or not preview_attachments:
                    return f"\n<!-- Drawio diagram `{drawio_name}` not found -->\n\n"

                if settings.export.attachment_href == "wiki":
                    preview_filename = preview_attachments[0].export_path.name
                    drawio_filename = drawio_attachments[0].export_path.name
                    drawio_image_embedding = f"![[{preview_filename}|{drawio_name}]]"
                    drawio_link = f"[[{drawio_filename}|{drawio_image_embedding}]]"
                else:
                    drawio_path = self._get_path_for_href(
                        drawio_attachments[0].export_path, settings.export.attachment_href
                    )
                    preview_path = self._get_path_for_href(
                        preview_attachments[0].export_path, settings.export.attachment_href
                    )
                    drawio_image_embedding = f"![{drawio_name}]({preview_path.replace(' ', '%20')})"
                    drawio_link = f"[{drawio_image_embedding}]({drawio_path.replace(' ', '%20')})"
                return f"\n{drawio_link}\n\n"

            return ""

        def _extract_uml_from_editor2(self, macro_id: str) -> str | None:
            """Extract PlantUML source from editor2 XML by macro-id (Cloud format)."""
            if not self.page.editor2:
                return None
            wrapped = f"<root>{self.page.editor2}</root>"
            soup = BeautifulSoup(wrapped, "xml")
            for macro in soup.find_all("structured-macro"):
                if not isinstance(macro, Tag):
                    continue
                if macro.get("name") != "plantuml" or macro.get("macro-id") != macro_id:
                    continue
                plain_text_body = macro.find("plain-text-body")
                if not isinstance(plain_text_body, Tag):
                    continue
                cdata = plain_text_body.get_text(strip=True)
                if not cdata:
                    continue
                try:
                    return json.loads(cdata).get("umlDefinition") or None
                except json.JSONDecodeError:
                    return None
            return None

        def _extract_uml_from_storage(self) -> str | None:
            """Extract PlantUML source from body.storage by position (Server format)."""
            storage_macros = self._storage_plantuml_macros
            idx = self._plantuml_index
            self._plantuml_index += 1
            if idx >= len(storage_macros):
                return None
            plain_text_body = storage_macros[idx].find("plain-text-body")
            if not isinstance(plain_text_body, Tag):
                return None
            uml = plain_text_body.get_text(strip=True)
            return uml or None

        def convert_plantuml(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:
            """Convert PlantUML diagrams to Markdown code blocks.

            Supports two Confluence formats:

            1. **Cloud / editor2**: The editor2 XML contains structured macros with
               the UML definition in a JSON CDATA section (``{"umlDefinition": "..."}``).
               Each macro carries a ``macro-id`` that is also present in the view
               HTML as ``data-macro-id``.

            2. **Server / Data Center**: ``editor2`` is often empty.  The UML source
               lives as raw ``@startuml`` text inside ``<plain-text-body>`` CDATA
               sections in ``body.storage``.  The view HTML renders each diagram
               inside a ``<span data-macro-name="plantuml">`` without a ``macro-id``.
               Diagrams are matched by position (Nth diagram in storage corresponds
               to the Nth ``<span>`` in view HTML).
            """
            # Strategy 1: editor2 with macro-id (Cloud)
            macro_id = el.get("data-macro-id")
            if macro_id:
                uml = self._extract_uml_from_editor2(str(macro_id))
                if uml:
                    return f"\n```plantuml\n{uml}\n```\n\n"

            # Strategy 2: body.storage fallback (Server / Data Center)
            uml = self._extract_uml_from_storage()
            if uml:
                return f"\n```plantuml\n{uml}\n```\n\n"

            logger.warning("PlantUML macro could not be resolved from editor2 or body.storage")
            return "\n<!-- PlantUML diagram (source not found) -->\n\n"

        def convert_include(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:
            """Convert Confluence `include` / `excerpt-include` macro.

            When `include_macro = transclusion`, emit an Obsidian-style embed link
            (`![[Page Title]]`) so the referenced page renders inline in Obsidian,
            mimicking the Confluence include/excerpt behavior. Requires the target
            page to also be exported so the link can resolve.

            When `include_macro = inline` (default), the body_view content is
            already expanded — fall through to normal div processing to render it.
            """
            macro_name = str(el.get("data-macro-name", ""))
            macro_id = el.get("data-macro-id")

            target_title: str | None = None
            if macro_id and isinstance(macro_id, str):
                target_title = self._extract_include_target_title(macro_id)

            if settings.export.include_macro == "transclusion" and target_title:
                return f"\n![[{target_title}]]\n\n"

            if settings.export.include_macro == "transclusion":
                logger.warning(
                    f"{macro_name} macro found but target page title could not be resolved; "
                    f"falling back to inline content"
                )

            inline = super().convert_div(el, text, parent_tags)  # type: ignore[misc]
            if macro_name == "excerpt-include":
                title_note = f" from page '{target_title}'" if target_title else ""
                return (
                    f"\n<!-- excerpt start{title_note} -->\n"
                    f"{inline}"
                    f"\n<!-- excerpt end{title_note} -->\n\n"
                )
            return inline

        def _strip_excerpt_include_panel_titles(self, html: str) -> str:
            """Strip the source-page-title panel from `excerpt-include` bodies.

            Confluence's `excerpt-include` body.view wraps the included
            content in a panel whose `panelHeader` is the source page title
            unless `nopanel=true`. The `panelContent` div holds the actual
            body. We unwrap to leave only the body.
            """
            soup = BeautifulSoup(html, "html.parser")
            for el in soup.find_all(attrs={"data-macro-name": "excerpt-include"}):
                self._unwrap_excerpt_include_panel(el)
            return str(soup)

        def _unwrap_excerpt_include_panel(self, el: Tag) -> None:
            classes = el.get("class") or []
            if not isinstance(classes, list) or "panel" not in classes:
                return
            header = el.find("div", class_="panelHeader")
            if isinstance(header, Tag):
                header.decompose()
            content = el.find("div", class_="panelContent")
            if isinstance(content, Tag):
                content.unwrap()

        def _extract_include_target_title(self, macro_id: str) -> str | None:
            """Resolve the target page title for an `include` / `excerpt-include` macro.

            BeautifulSoup with `xml` parser strips namespace prefixes, so
            `ac:structured-macro` becomes `structured-macro`, `ri:page` becomes
            `page`, and `ri:content-title` becomes `content-title`.
            """
            wrapped_editor2 = f"<root>{self.page.editor2}</root>"
            soup_editor2 = BeautifulSoup(wrapped_editor2, "xml")
            for macro in soup_editor2.find_all("structured-macro"):
                if not isinstance(macro, Tag):
                    continue
                if macro.get("name") not in ("include", "excerpt-include"):
                    continue
                if macro.get("macro-id") != macro_id:
                    continue
                ri_page = macro.find("page")
                if isinstance(ri_page, Tag):
                    title = ri_page.get("content-title")
                    if isinstance(title, str) and title:
                        return title
            return None

        def _find_element_with_namespace(self, parent: BeautifulSoup, tag_name: str) -> Tag | None:
            """Find an element with or without namespace prefix."""
            result = parent.find(f"ac:{tag_name}") or parent.find(tag_name)
            return result if isinstance(result, Tag) else None

        def _find_structured_macro(self, el: BeautifulSoup) -> Tag | None:
            """Find structured-macro element with or without namespace."""
            return self._find_element_with_namespace(el, "structured-macro")

        def _extract_plain_text_body(self, el: BeautifulSoup | Tag) -> str | None:
            """Extract markdown content from plain-text-body element."""
            plain_text_body = self._find_element_with_namespace(el, "plain-text-body")  # type: ignore[arg-type]
            if plain_text_body:
                return plain_text_body.get_text()
            return None

        def _extract_markdown_parameter(self, el: BeautifulSoup | Tag) -> str | None:
            """Extract markdown content from parameter element."""
            param = el.find("ac:parameter", {"ac:name": "markdown"})
            if param is None:
                param = el.find("parameter", {"name": "markdown"})
            if isinstance(param, Tag):
                return param.get_text()
            return None

        def _extract_markdown_from_body(self, el: BeautifulSoup) -> str | None:
            """Extract markdown content from body HTML."""
            # Try plain-text-body first (standard markdown macro)
            markdown_content = self._extract_plain_text_body(el)
            if markdown_content:
                return markdown_content

            # Check in structured-macro child element
            structured_macro = self._find_structured_macro(el)
            if structured_macro:
                markdown_content = self._extract_plain_text_body(structured_macro)
                if markdown_content:
                    return markdown_content

            # Try parameter for mohamicorp-markdown
            markdown_content = self._extract_markdown_parameter(el)
            if markdown_content:
                return markdown_content

            # Check parameter in structured-macro child
            if structured_macro:
                markdown_content = self._extract_markdown_parameter(structured_macro)
                if markdown_content:
                    return markdown_content

            return None

        def _extract_markdown_from_editor2(self, macro_id: str) -> str | None:
            """Extract markdown content from editor2 XML."""
            wrapped_editor2 = f"<root>{self.page.editor2}</root>"
            soup_editor2 = BeautifulSoup(wrapped_editor2, "xml")

            # BeautifulSoup strips namespace prefixes, so ac:structured-macro
            # becomes structured-macro
            markdown_macros = soup_editor2.find_all("structured-macro")
            for macro in markdown_macros:
                if not isinstance(macro, Tag):
                    continue
                if (
                    macro.get("name") in ("markdown", "mohamicorp-markdown")
                    and macro.get("macro-id") == macro_id
                ):
                    # Try plain-text-body first
                    plain_text_body = macro.find("plain-text-body")
                    if isinstance(plain_text_body, Tag):
                        return plain_text_body.get_text(strip=True)

                    # Try parameter for mohamicorp-markdown
                    param = macro.find("parameter", {"name": "markdown"})
                    if isinstance(param, Tag):
                        return param.get_text(strip=True)

            return None

        def convert_markdown(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:
            """Convert Markdown macro fragments to Markdown.

            Supports both standard 'markdown' macro and 'mohamicorp-markdown'
            macro. The content is already in Markdown format, so we just extract
            and return it.
            """
            macro_name = el.get("data-macro-name", "")

            # First, try to extract from body HTML
            markdown_content = self._extract_markdown_from_body(el)

            # If not found, try editor2 XML (similar to plantuml)
            if not markdown_content:
                macro_id = el.get("data-macro-id")
                if macro_id and isinstance(macro_id, str):
                    markdown_content = self._extract_markdown_from_editor2(macro_id)

            if not markdown_content:
                logger.warning(
                    f"Markdown macro ({macro_name}) found but no content could be extracted"
                )
                return f"\n<!-- Markdown macro ({macro_name}) content not found -->\n\n"

            # Return the markdown content directly (it's already in markdown format)
            # Add newlines for proper spacing
            return f"\n{markdown_content}\n\n"

        def convert_table(self, el: BeautifulSoup, text: str, parent_tags: list[str]) -> str:
            if el.has_attr("class") and "metadata-summary-macro" in el["class"]:
                return self.convert_page_properties_report(el, text, parent_tags)

            return super().convert_table(el, text, parent_tags)

        def convert_page_properties_report(
            self, el: BeautifulSoup, text: str, parent_tags: list[str]
        ) -> str:
            data_cql = el.get("data-cql")
            if not data_cql:
                return ""

            if settings.export.page_properties_report_format == "dataview":
                dql = self._cql_to_dataview(el, str(data_cql))
                if dql is not None:
                    return f"\n```dataview\n{dql}\n```\n"

            soup = BeautifulSoup(self.page.body_export, "html.parser")
            table = soup.find("table", {"data-cql": data_cql})
            if not table:
                return ""
            return super().convert_table(table, "", parent_tags)  # type: ignore -

        def _cql_to_dataview(self, el: BeautifulSoup, cql: str) -> str | None:
            """Translate a Confluence CQL query to an Obsidian Dataview DQL query.

            Returns None if the CQL cannot be meaningfully translated.
            """
            current_content_id = str(el.get("data-current-content-id", ""))
            headings_raw = str(el.get("data-headings", ""))
            first_col = str(el.get("data-first-column-heading", "Title"))
            sort_by = str(el.get("data-sort-by", first_col))
            reverse_sort = str(el.get("data-reverse-sort", "false")).lower() == "true"

            label_conditions = [
                m.group(1) for m in re.finditer(r'label\s*=\s*"([^"]+)"', cql, re.IGNORECASE)
            ]

            parent_match = re.search(r'parent\s*=\s*"?(\d+)"?', cql, re.IGNORECASE)
            current_content_match = re.search(
                r'(?:ancestor|parent)\s*=\s*currentContent\s*\(\s*\)', cql, re.IGNORECASE
            )

            from_clause: str | None = None
            if current_content_match or (
                parent_match and parent_match.group(1) == current_content_id
            ):
                folder = str(self.page.export_path.parent).replace("\\", "/")
                from_clause = f'"{folder}"'

            if from_clause is None and not label_conditions:
                return None

            lines: list[str] = []

            if headings_raw:
                headings = [h.strip() for h in headings_raw.split(",") if h.strip()]
                col_names = ", ".join(f'{sanitize_key(h)} AS "{h}"' for h in headings)
                lines.append(f"TABLE {col_names}")
            else:
                lines.append("TABLE")

            from_parts = ([from_clause] if from_clause else []) + [
                f"#{lbl}" for lbl in label_conditions
            ]
            if from_parts:
                lines.append("FROM " + " AND ".join(from_parts))

            sort_col = sanitize_key(sort_by)
            sort_dir = "DESC" if reverse_sort else "ASC"
            lines.append(f"SORT {sort_col} {sort_dir}")

            return "\n".join(lines)

        def _get_path_for_href(
            self, path: Path, style: Literal["absolute", "relative", "wiki"]
        ) -> str:
            """Get the path to use in href attributes based on settings."""
            if style == "absolute":
                # Note that usually absolute would be
                # something like this: (settings.export.output_path / path).absolute()
                # In this case the URL will be "absolute" to the export path.
                # This is useful for local file links.
                result = "/" + str(path).lstrip("/")
            elif style == "wiki":
                result = path.name
            else:
                result = os.path.relpath(path, self.page.export_path.parent)
            return result


_CQL_MAX_BATCH_SIZE: int = 25


def _fetch_page_ids_v2_batch(batch: list[str], base_url: str) -> set[str]:
    """Single v2 API request for a batch of page IDs.

    Uses GET /api/v2/pages?id=X&id=Y&...  (Atlassian Cloud).
    The v2 API accepts multiple ``id`` params, so they are encoded directly
    into the URL path since the SDK only accepts a dict for ``params``.
    """
    query = urllib.parse.urlencode([("id", pid) for pid in batch] + [("limit", len(batch))])
    response = cast("dict", get_thread_confluence(base_url).get(f"api/v2/pages?{query}"))
    if not response:
        return set()
    return {str(item["id"]) for item in response.get("results", [])}


def _fetch_page_ids_cql_batch(batch: list[str], base_url: str) -> set[str]:
    """Single CQL v1 request for a batch of page IDs.

    Uses GET /rest/api/content/search with id in (...) (self-hosted / fallback).
    """
    cql = "id in ({})".format(",".join(batch))
    response = cast(
        "dict",
        get_thread_confluence(base_url).get(
            "rest/api/content/search",
            params={"cql": cql, "limit": len(batch), "fields": "id"},
        ),
    )
    if not response:
        return set()
    return {str(item["id"]) for item in response.get("results", [])}


def fetch_deleted_page_ids(page_ids: list[str], base_url: str) -> set[str]:
    """Return the subset of *page_ids* that no longer exist in Confluence.

    Uses the v2 REST API when ``connection_config.use_v2_api`` is enabled
    (multiple ``id`` query params, up to ``export.existence_check_batch_size``
    IDs per request), or the v1 CQL content search otherwise (capped at
    :data:`_CQL_MAX_BATCH_SIZE` IDs per request).

    Per-batch API failures are handled safely: affected IDs are assumed to
    still exist so they are never accidentally deleted.
    """
    if not page_ids:
        return set()

    use_v2 = settings.connection_config.use_v2_api
    batch_size = settings.export.existence_check_batch_size
    effective_batch_size = batch_size if use_v2 else min(batch_size, _CQL_MAX_BATCH_SIZE)
    n_batches = -(-len(page_ids) // effective_batch_size)  # ceil division
    logger.debug(
        "Checking existence of %d page(s) in %d batch(es) via %s API",
        len(page_ids),
        n_batches,
        "v2" if use_v2 else "v1 CQL",
    )
    existing: set[str] = set()

    for i in range(0, len(page_ids), effective_batch_size):
        batch = page_ids[i : i + effective_batch_size]
        try:
            if use_v2:
                existing.update(_fetch_page_ids_v2_batch(batch, base_url))
            else:
                existing.update(_fetch_page_ids_cql_batch(batch, base_url))
        except Exception:  # noqa: BLE001
            logger.warning(
                "Failed to check page existence for batch (%d IDs). "
                "Skipping deletion for these pages.",
                len(batch),
            )
            existing.update(batch)

    return set(page_ids) - existing


def sync_removed_pages(base_url: str) -> None:
    """Orchestrate stale-file cleanup: check API for deleted pages, then clean up."""
    if not settings.export.cleanup_stale:
        logger.debug("Stale page cleanup disabled — skipping.")
        return

    unseen = LockfileManager.unseen_ids()
    if not unseen:
        logger.debug("No unseen pages in lockfile — nothing to clean up.")
        return

    with console.status(f"[dim]Checking {len(unseen)} unseen page(s) for removal…[/dim]"):
        deleted = fetch_deleted_page_ids(sorted(unseen), base_url)

    if deleted:
        logger.info("Removing %d stale page(s) from local export.", len(deleted))
    LockfileManager.remove_pages(deleted)


def _make_progress() -> Progress:
    """Build a rich Progress instance for page export."""
    return Progress(
        SpinnerColumn(),
        TextColumn("[progress.description]{task.description}"),
        BarColumn(),
        MofNCompleteColumn(),
        TaskProgressColumn(),
        TimeElapsedColumn(),
        TimeRemainingColumn(),
        console=console,
        transient=False,
    )


def _export_page_worker(page: "Page | Descendant", stats: ExportStats | None = None) -> None:
    """Export a single Confluence page to Markdown (worker function).

    Each page carries its own ``base_url`` so the correct thread-local client
    is used automatically — no global state manipulation needed.

    Args:
        page: The page to export.
        stats: Optional stats tracker to update on completion.
    """
    _page = Page.from_id(page.id, page.base_url)
    attachment_entries = _page.export()
    LockfileManager.record_page(_page, attachment_entries)
    if stats is not None:
        stats.inc_exported()


def export_pages(pages: list["Page | Descendant"]) -> None:
    """Export a list of Confluence pages to Markdown.

    Pages are exported in parallel using ThreadPoolExecutor for significant
    performance improvement. Worker count is read from
    settings.connection_config.max_workers (default: 20).

    Args:
        pages: List of pages to export.
    """
    # Mark all pages as seen so cleanup skips API checks for unchanged pages
    LockfileManager.mark_seen([p.id for p in pages])
    for p in pages:
        PageTitleRegistry.register(int(p.id), p.title)
    pages_to_export = [page for page in pages if LockfileManager.should_export(page)]

    skipped_count = len(pages) - len(pages_to_export)
    stats = reset_stats(total=len(pages))
    for _ in range(skipped_count):
        stats.inc_skipped()

    if skipped_count:
        logger.info("Skipping %d unchanged page(s).", skipped_count)

    if not pages_to_export:
        logger.info("All %d page(s) unchanged — nothing to export.", len(pages))
        return

    # Get worker count from config
    max_workers = settings.connection_config.max_workers
    serial = settings.export.log_level == "DEBUG" or max_workers <= 1

    mode_label = "serial" if serial else f"parallel ({max_workers} workers)"
    logger.debug("Export mode: %s, pages to export: %d", mode_label, len(pages_to_export))

    with _make_progress() as progress:
        task = progress.add_task(
            f"[cyan]Exporting {len(pages_to_export)} page(s)[/cyan]",
            total=len(pages_to_export),
        )

        if serial:
            for page in pages_to_export:
                progress.update(task, description=f"[cyan]Page {page.id}[/cyan]")
                try:
                    _export_page_worker(page, stats)
                except Exception:
                    logger.exception("Failed to export page %s", page.id)
                    stats.inc_failed()
                finally:
                    progress.advance(task)
        else:
            with ThreadPoolExecutor(max_workers=max_workers) as executor:
                futures = {
                    executor.submit(_export_page_worker, page, stats): page
                    for page in pages_to_export
                }
                for future in as_completed(futures):
                    page = futures[future]
                    try:
                        future.result()
                    except Exception:
                        logger.exception("Failed to export page %s", page.id)
                        stats.inc_failed()
                    finally:
                        progress.advance(task)


================================================
FILE: confluence_markdown_exporter/main.py
================================================
import json
import logging
import platform
import sys
import urllib.parse
from typing import Annotated

import typer
import typer.rich_utils
import yaml
from rich.panel import Panel
from rich.table import Table

from confluence_markdown_exporter import __version__
from confluence_markdown_exporter import config as config_module
from confluence_markdown_exporter.utils.app_data_store import APP_CONFIG_PATH
from confluence_markdown_exporter.utils.app_data_store import get_settings
from confluence_markdown_exporter.utils.lockfile import LockfileManager
from confluence_markdown_exporter.utils.measure_time import measure
from confluence_markdown_exporter.utils.rich_console import console
from confluence_markdown_exporter.utils.rich_console import get_rich_console
from confluence_markdown_exporter.utils.rich_console import get_stats
from confluence_markdown_exporter.utils.rich_console import reset_stats
from confluence_markdown_exporter.utils.rich_console import setup_logging

typer.rich_utils._get_rich_console = get_rich_console

logger = logging.getLogger(__name__)


class _CmeTyper(typer.Typer):
    """Typer subclass that intercepts AuthNotConfiguredError at the app boundary.

    When an export command raises AuthNotConfiguredError, the exception propagates
    through any active console.status() context managers (stopping spinners cleanly
    via their __exit__) before reaching here.  We then open the config menu at the
    exact failing URL and exit — no traceback, no per-command boilerplate.
    """

    def __call__(self, *args: object, **kwargs: object) -> None:
        from confluence_markdown_exporter.api_clients import AuthNotConfiguredError

        try:
            super().__call__(*args, **kwargs)
        except AuthNotConfiguredError as e:
            from confluence_markdown_exporter.utils.config_interactive import main_config_menu_loop

            console.print(
                f"Please configure {e.service} credentials for {e.url} and re-run the export."
            )
            main_config_menu_loop(f"auth.{e.service.lower()}", new_instance_url=e.url)
            sys.exit(1)
        except ValueError as e:
            console.print(
                f"[red bold]{e}[/red bold]\n"
                "See [code]--help[/code] or [code]README.md[/code] for more information."
            )
            sys.exit(1)


# Each list item must be its own \n\n-separated block so typer's epilog renderer
# keeps single \n between items, forming a valid markdown bullet list.
_QUICKSTART_EPILOG = (
    "**Quick start:**\n\n"
    "- Configure credentials: `cme config edit auth.confluence`\n\n"
    "- Set output path: `cme config set export.output_path=./output`\n\n"
    "- Export a page: `cme pages https://company.atlassian.net/wiki/spaces/KEY/pages/123/Title`\n\n"
    "- Export a space: `cme spaces https://company.atlassian.net/wiki/spaces/MYSPACE`\n\n"
    "- Export everything: `cme orgs https://company.atlassian.net`\n\n"
    "- Each command also has a singular alias"
    " (`page`, `space`, `org`) that behaves identically.\n\n"
)

_PAGE_URL_FORMATS = (
    "**Supported URL formats:**\n\n"
    "- **Cloud**: `https://company.atlassian.net/wiki/spaces/KEY/pages/123/Title`\n\n"
    "- **Server (long)**: `https://confluence.company.com/display/KEY/Title`\n\n"
    "- **Server (short)**: `https://confluence.company.com/KEY/Title`\n\n"
)

_SPACE_URL_FORMATS = (
    "**Supported URL formats:**\n\n"
    "- **Cloud**: `https://company.atlassian.net/wiki/spaces/SPACEKEY`\n\n"
    "- **Server (long)**: `https://confluence.company.com/display/SPACEKEY`\n\n"
    "- **Server (short)**: `https://confluence.company.com/SPACEKEY`\n\n"
)

app = _CmeTyper(
    rich_markup_mode="markdown",
    no_args_is_help=True,
    help=(
        "Export Confluence pages, spaces, or entire organizations to Markdown files.\n\n"
        "Authentication and settings are managed via `cme config`. "
        "Run `cme config` to open the interactive menu, or use "
        "`cme config set <key=value>` to set values directly.\n\n"
        "Most settings can also be overridden with environment variables using the prefix "
        "`CME_` and `__` as the nested delimiter "
        "(e.g. `CME_EXPORT__OUTPUT_PATH=/tmp/export`)."
    ),
    epilog=_QUICKSTART_EPILOG,
)
app.add_typer(config_module.app, name="config")


def _init_logging() -> None:
    """Initialize logging from config (CME_EXPORT__LOG_LEVEL env var takes precedence)."""
    export = get_settings().export
    log_file = APP_CONFIG_PATH.parent / "cme.log" if export.save_log_to_file else None
    setup_logging(export.log_level, log_file=log_file)


def _print_summary() -> None:
    """Print a rich summary panel with export statistics."""
    stats = get_stats()
    if stats.total == 0:
        return

    output_path = get_settings().export.output_path

    grid = Table.grid(padding=(0, 2))
    grid.add_column(style="dim", justify="right")
    grid.add_column()

    grid.add_row("Pages", "")
    grid.add_row("  Total", str(stats.total))
    grid.add_row("  [success]Exported[/success]", f"[success]{stats.exported}[/success]")
    grid.add_row("  [dim]Skipped (unchanged)[/dim]", str(stats.skipped))
    if stats.removed:
        grid.add_row("  [dim]Removed[/dim]", str(stats.removed))
    if stats.failed:
        grid.add_row("  [error]Failed[/error]", f"[error]{stats.failed}[/error]")

    attachments_total = (
        stats.attachments_exported + stats.attachments_skipped + stats.attachments_failed
    )
    if attachments_total or stats.attachments_removed:
        grid.add_row("Attachments", "")
        if attachments_total:
            grid.add_row("  Total", str(attachments_total))
        att_exp = stats.attachments_exported
        grid.add_row("  [success]Exported[/success]", f"[success]{att_exp}[/success]")
        grid.add_row("  [dim]Skipped (unchanged)[/dim]", str(stats.attachments_skipped))
        if stats.attachments_removed:
            grid.add_row("  [dim]Removed[/dim]", str(stats.attachments_removed))
        if stats.attachments_failed:
            grid.add_row("  [error]Failed[/error]", f"[error]{stats.attachments_failed}[/error]")

    grid.add_row("Output", str(output_path))

    if stats.failed:
        title = "[warning]Export finished with errors[/warning]"
    else:
        title = "[success]Export complete[/success]"
    console.print(Panel(grid, title=title, expand=False))


@app.command(
    help=(
        "Export one or more Confluence pages by URL to Markdown.\n\n"
        "Fetches each page via the Confluence API and writes a Markdown file to the "
        "configured output directory (`export.output_path`). "
        "Pages that have not changed since the last export are skipped by default "
        "(`export.skip_unchanged=true`)."
    ),
    epilog=(
        "**Examples:**\n\n"
        "- `cme pages https://company.atlassian.net/wiki/spaces/KEY/pages/123/My+Page`\n\n"
        "- `cme pages https://...page1 https://...page2` — export multiple pages at once\n\n"
        "- `cme page URL` — singular alias, identical behaviour\n\n"
        "---\n\n" + _PAGE_URL_FORMATS
    ),
)
def pages(
    page_urls: Annotated[
        list[str],
        typer.Argument(
            help=(
                "One or more Confluence page URLs. "
                "Supports Cloud and Server URL formats. "
                "Example: https://company.atlassian.net/wiki/spaces/KEY/pages/123/Title"
            ),
            metavar="PAGE_URL",
        ),
    ],
) -> None:
    from confluence_markdown_exporter.confluence import Page
    from confluence_markdown_exporter.confluence import sync_removed_pages
    from confluence_markdown_exporter.utils.page_registry import PageTitleRegistry

    _init_loggin
Download .txt
gitextract_yof8yoxc/

├── .dockerignore
├── .github/
│   ├── FUNDING.yml
│   ├── ISSUE_TEMPLATE/
│   │   ├── 1_bug_report.yaml
│   │   ├── 2_feature_request.yaml
│   │   ├── 3_question.yaml
│   │   └── config.yml
│   ├── PULL_REQUEST_TEMPLATE.md
│   ├── dependabot.yml
│   └── workflows/
│       ├── docker-build.yml
│       ├── docker-publish.yml
│       ├── docs.yml
│       ├── python-build.yml
│       ├── python-publish.yml
│       └── release.yml
├── .gitignore
├── .python-version
├── .vscode/
│   ├── extensions.json
│   ├── launch.json
│   ├── settings.json
│   └── tasks.json
├── CONTRIBUTING.md
├── Dockerfile
├── LICENSE
├── README.md
├── confluence_markdown_exporter/
│   ├── __init__.py
│   ├── api_clients.py
│   ├── config.py
│   ├── confluence.py
│   ├── main.py
│   └── utils/
│       ├── __init__.py
│       ├── app_data_store.py
│       ├── config_interactive.py
│       ├── drawio_converter.py
│       ├── export.py
│       ├── lockfile.py
│       ├── measure_time.py
│       ├── page_registry.py
│       ├── rich_console.py
│       ├── table_converter.py
│       └── type_converter.py
├── docs/
│   ├── compatibility.md
│   ├── configuration/
│   │   ├── authentication.md
│   │   ├── ci.md
│   │   ├── index.md
│   │   ├── options.md
│   │   └── target-systems.md
│   ├── contributing.md
│   ├── docker.md
│   ├── features.md
│   ├── installation.md
│   ├── intro.md
│   ├── troubleshooting.md
│   └── usage.md
├── docusaurus.config.ts
├── package.json
├── pyproject.toml
├── scripts/
│   ├── build-versions.mjs
│   └── bump-docs-version.sh
├── sidebars.ts
├── src/
│   ├── components/
│   │   ├── HomepageFeatures/
│   │   │   ├── index.tsx
│   │   │   └── styles.module.css
│   │   └── quickstart/
│   │       └── index.tsx
│   ├── css/
│   │   └── custom.css
│   └── pages/
│       ├── index.module.css
│       └── index.tsx
├── tests/
│   ├── __init__.py
│   ├── conftest.py
│   ├── integration/
│   │   ├── __init__.py
│   │   └── test_cli_integration.py
│   └── unit/
│       ├── __init__.py
│       ├── test_alert_conversion.py
│       ├── test_api_clients.py
│       ├── test_confluence.py
│       ├── test_emoticon_conversion.py
│       ├── test_include_macro_conversion.py
│       ├── test_main.py
│       ├── test_nbsp_fix.py
│       ├── test_plantuml_code_block_detection.py
│       ├── test_plantuml_conversion.py
│       ├── test_template_placeholders.py
│       └── utils/
│           ├── __init__.py
│           ├── test_app_data_store_env.py
│           ├── test_drawio_converter.py
│           ├── test_export.py
│           ├── test_lockfile.py
│           ├── test_measure_time.py
│           ├── test_page_registry.py
│           ├── test_rich_console.py
│           ├── test_table_converter.py
│           └── test_type_converter.py
└── tsconfig.json
Download .txt
SYMBOL INDEX (845 symbols across 39 files)

FILE: confluence_markdown_exporter/api_clients.py
  function parse_gateway_url (line 34) | def parse_gateway_url(url: str) -> tuple[str, str] | None:
  function build_gateway_url (line 39) | def build_gateway_url(service: str, cloud_id: str) -> str:
  function ensure_service_gateway_url (line 43) | def ensure_service_gateway_url(url: str, service: str | None = None) -> ...
  function _is_standard_atlassian_cloud_url (line 56) | def _is_standard_atlassian_cloud_url(url: str) -> bool:
  function _try_fetch_cloud_id (line 65) | def _try_fetch_cloud_id(base_url: str) -> str | None:
  function _get_confluence_sdk_url (line 79) | def _get_confluence_sdk_url(base_url: str, auth: ApiDetails) -> str:
  function _get_jira_sdk_url (line 86) | def _get_jira_sdk_url(base_url: str, auth: ApiDetails) -> str:
  function _decode_url_part (line 93) | def _decode_url_part(v: str | None) -> None | str:
  class ConfluenceRef (line 99) | class ConfluenceRef(BaseModel):
  function parse_confluence_path (line 121) | def parse_confluence_path(path: str) -> ConfluenceRef | None:
  class AuthNotConfiguredError (line 140) | class AuthNotConfiguredError(BaseException):
    method __init__ (line 147) | def __init__(self, url: str, service: str = "Confluence") -> None:
  class JiraAuthenticationError (line 153) | class JiraAuthenticationError(Exception):
  function _jira_auth_failure_hook (line 157) | def _jira_auth_failure_hook(
  function response_hook (line 167) | def response_hook(
  class ApiClientFactory (line 181) | class ApiClientFactory:
    method __init__ (line 184) | def __init__(self, connection_config: AtlassianSdkConnectionConfig) ->...
    method create_confluence (line 191) | def create_confluence(self, url: str, auth: ApiDetails) -> ConfluenceA...
    method create_jira (line 206) | def create_jira(self, url: str, auth: ApiDetails) -> JiraApiSdk:
  function get_confluence_instance (line 222) | def get_confluence_instance(url: str) -> ConfluenceApiSdk:
  function get_thread_confluence (line 272) | def get_thread_confluence(base_url: str) -> ConfluenceApiSdk:
  function get_jira_instance (line 288) | def get_jira_instance(url: str) -> JiraApiSdk:
  function invalidate_confluence_client (line 348) | def invalidate_confluence_client(url: str) -> None:
  function invalidate_jira_client (line 354) | def invalidate_jira_client(url: str) -> None:
  function handle_jira_auth_failure (line 360) | def handle_jira_auth_failure(url: str) -> None:

FILE: confluence_markdown_exporter/config.py
  function callback (line 90) | def callback(ctx: typer.Context) -> None:
  function reset (line 113) | def reset(
  function path (line 151) | def path() -> None:
  function list_config (line 172) | def list_config(
  function get (line 214) | def get(
  function set_config (line 263) | def set_config(
  function edit (line 307) | def edit(
  function _parse_value (line 325) | def _parse_value(value_str: str) -> object:

FILE: confluence_markdown_exporter/confluence.py
  function _rgb_to_hex (line 88) | def _rgb_to_hex(r: int, g: int, b: int) -> str:
  function _extract_cell_highlight_hex (line 92) | def _extract_cell_highlight_hex(el: Tag) -> str | None:
  function _require_dict (line 119) | def _require_dict(response: object, context: str) -> JsonResponse:
  function _extract_base_url (line 140) | def _extract_base_url(url: str) -> str:
  function _join_confluence_link (line 191) | def _join_confluence_link(data: JsonResponse, key: str) -> str:
  function _get_web_url (line 202) | def _get_web_url(data: JsonResponse) -> str:
  function _get_tiny_url (line 206) | def _get_tiny_url(data: JsonResponse) -> str:
  function _extract_jira_base_url (line 353) | def _extract_jira_base_url(url: str) -> str | None:
  class JiraIssue (line 386) | class JiraIssue(BaseModel):
    method from_json (line 393) | def from_json(cls, data: JsonResponse) -> "JiraIssue":
    method from_key (line 403) | def from_key(cls, issue_key: str, jira_url: str) -> "JiraIssue | None":
    method _fetch_cached (line 417) | def _fetch_cached(cls, issue_key: str, jira_url: str) -> "JiraIssue":
  class User (line 423) | class User(BaseModel):
    method from_json (line 431) | def from_json(cls, data: JsonResponse) -> "User":
    method from_username (line 442) | def from_username(cls, username: str, base_url: str = "") -> "User":
    method from_userkey (line 452) | def from_userkey(cls, userkey: str, base_url: str = "") -> "User":
    method from_accountid (line 462) | def from_accountid(cls, accountid: str, base_url: str = "") -> "User":
  class Version (line 471) | class Version(BaseModel):
    method from_json (line 478) | def from_json(cls, data: JsonResponse) -> "Version":
  class History (line 487) | class History(BaseModel):
    method from_json (line 492) | def from_json(cls, data: JsonResponse) -> "History":
  class Organization (line 499) | class Organization(BaseModel):
    method pages (line 504) | def pages(self) -> list["Page | Descendant"]:
    method export (line 507) | def export(self) -> None:
    method from_json (line 523) | def from_json(cls, data: JsonResponse, base_url: str) -> "Organization":
    method from_url (line 531) | def from_url(cls, base_url: str) -> "Organization":
  class Space (line 549) | class Space(BaseModel):
    method pages (line 557) | def pages(self) -> list["Page | Descendant"]:
    method export (line 567) | def export(self) -> None:
    method from_json (line 578) | def from_json(cls, data: JsonResponse, base_url: str) -> "Space":
    method from_key (line 589) | def from_key(cls, space_key: str, base_url: str) -> "Space":
    method from_url (line 599) | def from_url(cls, space_url: str) -> "Space":
  class Label (line 628) | class Label(BaseModel):
    method from_json (line 634) | def from_json(cls, data: JsonResponse) -> "Label":
  class Document (line 642) | class Document(BaseModel):
    method _template_vars (line 650) | def _template_vars(self) -> dict[str, str]:
  class Attachment (line 669) | class Attachment(Document):
    method extension (line 680) | def extension(self) -> str:
    method filename (line 689) | def filename(self) -> str:
    method _template_vars (line 693) | def _template_vars(self) -> dict[str, str]:
    method export_path (line 710) | def export_path(self) -> Path:
    method from_json (line 715) | def from_json(cls, data: JsonResponse, base_url: str) -> "Attachment":
    method from_page_id (line 743) | def from_page_id(cls, page_id: int, base_url: str) -> list["Attachment"]:
    method export (line 770) | def export(self) -> None:
  class Ancestor (line 801) | class Ancestor(Document):
    method from_json (line 805) | def from_json(cls, data: JsonResponse, base_url: str) -> "Ancestor":
  class Descendant (line 818) | class Descendant(Document):
    method _template_vars (line 822) | def _template_vars(self) -> dict[str, str]:
    method export_path (line 830) | def export_path(self) -> Path:
    method from_json (line 835) | def from_json(cls, data: JsonResponse, base_url: str) -> "Descendant":
  function _parse_image_captions (line 850) | def _parse_image_captions(storage_xml: str) -> dict[str, str]:
  class Page (line 879) | class Page(Document):
    method descendants (line 895) | def descendants(self) -> list["Descendant"]:
    method _template_vars (line 930) | def _template_vars(self) -> dict[str, str]:
    method export_path (line 938) | def export_path(self) -> Path:
    method html (line 943) | def html(self) -> str:
    method markdown (line 949) | def markdown(self) -> str:
    method export (line 952) | def export(self) -> dict[str, AttachmentEntry]:
    method export_with_descendants (line 973) | def export_with_descendants(self) -> None:
    method export_body (line 980) | def export_body(self) -> None:
    method export_markdown (line 1002) | def export_markdown(self) -> None:
    method _fetch_inline_comments (line 1012) | def _fetch_inline_comments(self) -> list[dict]:
    method _fetch_page_comments (line 1043) | def _fetch_page_comments(self) -> list[dict]:
    method _fetch_comment_replies (line 1074) | def _fetch_comment_replies(self, comment_id: str) -> list[dict]:
    method export_comments_sidecar (line 1088) | def export_comments_sidecar(self) -> None:
    method _render_inline_comments (line 1123) | def _render_inline_comments(self, lines: list[str], comments: list[dic...
    method _render_page_comments (line 1172) | def _render_page_comments(self, lines: list[str], comments: list[dict]...
    method _attachments_for_export (line 1212) | def _attachments_for_export(self) -> list["Attachment"]:
    method export_attachments (line 1231) | def export_attachments(self) -> dict[str, AttachmentEntry]:
    method get_attachment_by_id (line 1271) | def get_attachment_by_id(self, attachment_id: str) -> Attachment | None:
    method get_attachment_by_file_id (line 1284) | def get_attachment_by_file_id(self, file_id: str) -> Attachment | None:
    method get_attachments_by_title (line 1290) | def get_attachments_by_title(self, title: str) -> list[Attachment]:
    method from_json (line 1294) | def from_json(cls, data: JsonResponse, base_url: str) -> "Page":
    method from_id (line 1323) | def from_id(cls, page_id: int, base_url: str) -> "Page":
    method from_url (line 1372) | def from_url(cls, page_url: str) -> "Page":
    class Converter (line 1430) | class Converter(TableConverter, MarkdownConverter):
      class Options (line 1433) | class Options(MarkdownConverter.DefaultOptions):  # type: ignore[ass...
      method __init__ (line 1439) | def __init__(self, page: "Page", **options) -> None:  # noqa: ANN003
      method _colorid_map (line 1451) | def _colorid_map(self) -> dict[str, str]:
      method _storage_plantuml_macros (line 1465) | def _storage_plantuml_macros(self) -> list[Tag]:
      method _image_captions (line 1481) | def _image_captions(self) -> dict[str, str]:
      method _panel_icon_map (line 1487) | def _panel_icon_map(self) -> dict[str, str]:
      method _extract_panel_emoji (line 1510) | def _extract_panel_emoji(macro: Tag) -> str | None:
      method markdown (line 1530) | def markdown(self) -> str:
      method front_matter (line 1541) | def front_matter(self) -> str:
      method _add_confluence_url_properties (line 1555) | def _add_confluence_url_properties(self) -> None:
      method _add_page_metadata_properties (line 1570) | def _add_page_metadata_properties(self) -> None:
      method breadcrumbs (line 1598) | def breadcrumbs(self) -> str:
      method labels (line 1607) | def labels(self) -> list[str]:
      method set_page_properties (line 1610) | def set_page_properties(self, **props: list[str] | str | None) -> None:
      method convert_page_properties (line 1615) | def convert_page_properties(
      method convert_alert (line 1660) | def convert_alert(self, el: BeautifulSoup, text: str, parent_tags: l...
      method convert_div (line 1696) | def convert_div(self, el: BeautifulSoup, text: str, parent_tags: lis...
      method convert_expand_container (line 1734) | def convert_expand_container(
      method _span_highlight (line 1754) | def _span_highlight(self, style: str, text: str) -> str | None:
      method _wrap_cell_highlight (line 1761) | def _wrap_cell_highlight(self, el: BeautifulSoup, text: str) -> str:
      method convert_td (line 1770) | def convert_td(self, el: BeautifulSoup, text: str, parent_tags: list...
      method convert_th (line 1774) | def convert_th(self, el: BeautifulSoup, text: str, parent_tags: list...
      method _span_font_color (line 1778) | def _span_font_color(self, el: BeautifulSoup, style: str, text: str)...
      method _span_status_badge (line 1792) | def _span_status_badge(self, el: BeautifulSoup, text: str) -> str | ...
      method convert_span (line 1807) | def convert_span(self, el: BeautifulSoup, text: str, parent_tags: li...
      method convert_inline_comment_marker (line 1835) | def convert_inline_comment_marker(
      method convert_attachments (line 1844) | def convert_attachments(self, el: BeautifulSoup, text: str, parent_t...
      method convert_column_layout (line 1877) | def convert_column_layout(
      method convert_jira_table (line 1889) | def convert_jira_table(self, el: BeautifulSoup, text: str, parent_ta...
      method convert_toc (line 1904) | def convert_toc(self, el: BeautifulSoup, text: str, parent_tags: lis...
      method convert_hidden_content (line 1922) | def convert_hidden_content(
      method convert_jira_issue (line 1930) | def convert_jira_issue(self, el: BeautifulSoup, text: str, parent_ta...
      method convert_pre (line 1949) | def convert_pre(self, el: BeautifulSoup, text: str, parent_tags: lis...
      method convert_sub (line 1964) | def convert_sub(self, el: BeautifulSoup, text: str, parent_tags: lis...
      method convert_sup (line 1967) | def convert_sup(self, el: BeautifulSoup, text: str, parent_tags: lis...
      method convert_a (line 1973) | def convert_a(self, el: BeautifulSoup, text: str, parent_tags: list[...
      method convert_page_link (line 2036) | def convert_page_link(self, page_id: int) -> str:
      method convert_attachment_link (line 2061) | def convert_attachment_link(
      method convert_time (line 2087) | def convert_time(self, el: BeautifulSoup, text: str, parent_tags: li...
      method convert_user_mention (line 2093) | def convert_user_mention(self, el: BeautifulSoup, text: str, parent_...
      method convert_user (line 2102) | def convert_user(self, user: User) -> str:
      method convert_user_name (line 2105) | def convert_user_name(self, name: str) -> str:
      method convert_li (line 2108) | def convert_li(self, el: BeautifulSoup, text: str, parent_tags: list...
      method _convert_emoticon (line 2142) | def _convert_emoticon(self, el: BeautifulSoup) -> str | None:
      method convert_img (line 2162) | def convert_img(self, el: BeautifulSoup, text: str, parent_tags: lis...
      method _normalize_unicode_whitespace (line 2222) | def _normalize_unicode_whitespace(self, text: str) -> str:
      method escape (line 2254) | def escape(self, text: str, parent_tags: list[str]) -> str:
      method _escape_template_placeholders (line 2258) | def _escape_template_placeholders(self, text: str) -> str:
      method convert_em (line 2303) | def convert_em(self, el: BeautifulSoup, text: str, parent_tags: list...
      method convert_strong (line 2308) | def convert_strong(self, el: BeautifulSoup, text: str, parent_tags: ...
      method convert_code (line 2313) | def convert_code(self, el: BeautifulSoup, text: str, parent_tags: li...
      method convert_i (line 2318) | def convert_i(self, el: BeautifulSoup, text: str, parent_tags: list[...
      method convert_b (line 2323) | def convert_b(self, el: BeautifulSoup, text: str, parent_tags: list[...
      method _convert_drawio_embedded_mermaid (line 2328) | def _convert_drawio_embedded_mermaid(self, filename: str) -> str | N...
      method convert_drawio (line 2350) | def convert_drawio(self, el: BeautifulSoup, text: str, parent_tags: ...
      method _extract_uml_from_editor2 (line 2378) | def _extract_uml_from_editor2(self, macro_id: str) -> str | None:
      method _extract_uml_from_storage (line 2401) | def _extract_uml_from_storage(self) -> str | None:
      method convert_plantuml (line 2414) | def convert_plantuml(self, el: BeautifulSoup, text: str, parent_tags...
      method convert_include (line 2446) | def convert_include(self, el: BeautifulSoup, text: str, parent_tags:...
      method _strip_excerpt_include_panel_titles (line 2483) | def _strip_excerpt_include_panel_titles(self, html: str) -> str:
      method _unwrap_excerpt_include_panel (line 2496) | def _unwrap_excerpt_include_panel(self, el: Tag) -> None:
      method _extract_include_target_title (line 2507) | def _extract_include_target_title(self, macro_id: str) -> str | None:
      method _find_element_with_namespace (line 2530) | def _find_element_with_namespace(self, parent: BeautifulSoup, tag_na...
      method _find_structured_macro (line 2535) | def _find_structured_macro(self, el: BeautifulSoup) -> Tag | None:
      method _extract_plain_text_body (line 2539) | def _extract_plain_text_body(self, el: BeautifulSoup | Tag) -> str |...
      method _extract_markdown_parameter (line 2546) | def _extract_markdown_parameter(self, el: BeautifulSoup | Tag) -> st...
      method _extract_markdown_from_body (line 2555) | def _extract_markdown_from_body(self, el: BeautifulSoup) -> str | None:
      method _extract_markdown_from_editor2 (line 2582) | def _extract_markdown_from_editor2(self, macro_id: str) -> str | None:
      method convert_markdown (line 2609) | def convert_markdown(self, el: BeautifulSoup, text: str, parent_tags...
      method convert_table (line 2637) | def convert_table(self, el: BeautifulSoup, text: str, parent_tags: l...
      method convert_page_properties_report (line 2643) | def convert_page_properties_report(
      method _cql_to_dataview (line 2661) | def _cql_to_dataview(self, el: BeautifulSoup, cql: str) -> str | None:
      method _get_path_for_href (line 2712) | def _get_path_for_href(
  function _fetch_page_ids_v2_batch (line 2732) | def _fetch_page_ids_v2_batch(batch: list[str], base_url: str) -> set[str]:
  function _fetch_page_ids_cql_batch (line 2746) | def _fetch_page_ids_cql_batch(batch: list[str], base_url: str) -> set[str]:
  function fetch_deleted_page_ids (line 2764) | def fetch_deleted_page_ids(page_ids: list[str], base_url: str) -> set[str]:
  function sync_removed_pages (line 2808) | def sync_removed_pages(base_url: str) -> None:
  function _make_progress (line 2827) | def _make_progress() -> Progress:
  function _export_page_worker (line 2842) | def _export_page_worker(page: "Page | Descendant", stats: ExportStats | ...
  function export_pages (line 2859) | def export_pages(pages: list["Page | Descendant"]) -> None:

FILE: confluence_markdown_exporter/main.py
  class _CmeTyper (line 31) | class _CmeTyper(typer.Typer):
    method __call__ (line 40) | def __call__(self, *args: object, **kwargs: object) -> None:
  function _init_logging (line 105) | def _init_logging() -> None:
  function _print_summary (line 112) | def _print_summary() -> None:
  function pages (line 173) | def pages(
  function pages_with_descendants (line 253) | def pages_with_descendants(
  function spaces (line 314) | def spaces(
  function orgs (line 373) | def orgs(
  function version (line 415) | def version() -> None:
  function _redact_url (line 424) | def _redact_url(url: str) -> str:
  function _redact_config (line 438) | def _redact_config(data: dict) -> dict:
  function bugreport (line 471) | def bugreport() -> None:

FILE: confluence_markdown_exporter/utils/app_data_store.py
  function get_app_config_path (line 23) | def get_app_config_path() -> Path:
  class AtlassianSdkConnectionConfig (line 39) | class AtlassianSdkConnectionConfig(BaseModel):
  class ConnectionConfig (line 92) | class ConnectionConfig(AtlassianSdkConnectionConfig):
  class ApiDetails (line 115) | class ApiDetails(BaseModel):
    method _single_line (line 160) | def _single_line(cls, v: object) -> object:
    method dump_secret (line 167) | def dump_secret(self, v: SecretStr) -> str:
  class AuthConfig (line 171) | class AuthConfig(BaseModel):
    method _migrate (line 199) | def _migrate(cls, data: object) -> object:  # noqa: C901, PLR0912
    method get_instance (line 245) | def get_instance(self, url: str) -> ApiDetails | None:
    method get_jira_instance (line 250) | def get_jira_instance(self, url: str) -> ApiDetails | None:
    method default_confluence_url (line 255) | def default_confluence_url(self) -> str | None:
    method default_jira_url (line 259) | def default_jira_url(self) -> str | None:
    method _match_by_host (line 264) | def _match_by_host(instances: dict[str, ApiDetails], url: str) -> ApiD...
  function _looks_like_url_keyed (line 290) | def _looks_like_url_keyed(d: dict) -> bool:
  function normalize_instance_url (line 295) | def normalize_instance_url(url: str) -> str:
  class ExportConfig (line 300) | class ExportConfig(BaseModel):
    method _migrate_attachment_path (line 398) | def _migrate_attachment_path(cls, v: object) -> object:
    method _migrate_page_properties (line 504) | def _migrate_page_properties(cls, data: object) -> object:
    method _migrate_attachments_export (line 518) | def _migrate_attachments_export(cls, data: object) -> object:
    method _migrate_inline_comments (line 531) | def _migrate_inline_comments(cls, data: object) -> object:
  class ConfigModel (line 682) | class ConfigModel(BaseModel):
  class _JsonConfigSource (line 692) | class _JsonConfigSource(PydanticBaseSettingsSource):
    method get_field_value (line 695) | def get_field_value(self, field: Any, field_name: str) -> Any:  # noqa...
    method field_is_complex (line 698) | def field_is_complex(self, field: Any) -> bool:  # noqa: ANN401
    method __call__ (line 701) | def __call__(self) -> dict[str, Any]:
  class AppSettings (line 711) | class AppSettings(BaseSettings):
    method settings_customise_sources (line 737) | def settings_customise_sources(
  function load_app_data (line 749) | def load_app_data() -> dict[str, dict]:
  function save_app_data (line 761) | def save_app_data(config_model: ConfigModel) -> None:
  function get_settings (line 768) | def get_settings() -> AppSettings:
  function _set_by_path (line 773) | def _set_by_path(obj: dict, path: str, value: object) -> None:
  function _set_by_keys (line 784) | def _set_by_keys(obj: dict, keys: list[str], value: object) -> None:
  function set_setting (line 794) | def set_setting(path: str, value: object) -> None:
  function set_setting_with_keys (line 805) | def set_setting_with_keys(keys: list[str], value: object) -> None:
  function get_default_value_by_path (line 820) | def get_default_value_by_path(path: str | None = None) -> object:
  function reset_to_defaults (line 840) | def reset_to_defaults(path: str | None = None) -> None:

FILE: confluence_markdown_exporter/utils/config_interactive.py
  function _get_field_type (line 33) | def _get_field_type(model: type[BaseModel], key: str) -> type | None:
  function _get_submodel (line 40) | def _get_submodel(model: type[BaseModel], key: str) -> type[BaseModel] |...
  function _get_field_metadata (line 56) | def _get_field_metadata(model: type[BaseModel], key: str) -> dict:
  function _format_prompt_message (line 79) | def _format_prompt_message(key_name: str, model: type[BaseModel]) -> str:
  function _validate_int (line 103) | def _validate_int(val: str) -> bool | str:
  function _validate_pydantic (line 107) | def _validate_pydantic(val: object, model: type[BaseModel], key_name: st...
  function _prompt_literal (line 118) | def _prompt_literal(prompt_message: str, field_type: type, current_value...
  function _prompt_bool (line 128) | def _prompt_bool(prompt_message: str, current_value: object) -> object:
  function _prompt_path (line 134) | def _prompt_path(
  function _prompt_int (line 148) | def _prompt_int(prompt_message: str, current_value: object) -> object:
  function _prompt_list (line 163) | def _prompt_list(prompt_message: str, current_value: object) -> object:
  function _prompt_str (line 184) | def _prompt_str(
  function get_model_by_path (line 198) | def get_model_by_path(model: type[BaseModel], path: str) -> type[BaseMod...
  function _get_dict_value_model (line 217) | def _get_dict_value_model(model: type[BaseModel], key: str) -> type[Base...
  function _edit_instance_fields (line 237) | def _edit_instance_fields(  # noqa: C901, PLR0912
  function _maybe_sync_new_instance (line 315) | def _maybe_sync_new_instance(instance_url: str, parent_path_parts: list[...
  function _edit_instance_dict_loop (line 348) | def _edit_instance_dict_loop(  # noqa: C901, PLR0912, PLR0915
  function _main_config_menu (line 448) | def _main_config_menu(settings: dict, default: tuple[str, bool] | None =...
  function _prompt_for_new_value (line 491) | def _prompt_for_new_value(  # noqa: PLR0911
  function _maybe_sync_auth_change (line 516) | def _maybe_sync_auth_change(
  function _reset_and_reload (line 562) | def _reset_and_reload(parent_key: str | None, display_title: str | None ...
  function _get_choices (line 589) | def _get_choices(config_dict: dict, model: type[BaseModel]) -> list:
  function _edit_dict_config_loop (line 622) | def _edit_dict_config_loop(  # noqa: C901, PLR0912, PLR0915
  function _edit_dict_config (line 718) | def _edit_dict_config(
  function main_config_menu_loop (line 728) | def main_config_menu_loop(  # noqa: C901, PLR0912

FILE: confluence_markdown_exporter/utils/drawio_converter.py
  function load_drawio_file (line 14) | def load_drawio_file(file_path: str | Path) -> str | None:
  function extract_mermaid_data (line 30) | def extract_mermaid_data(xml_content: str) -> str | None:
  function parse_mermaid_json (line 63) | def parse_mermaid_json(mermaid_data: str) -> str | None:
  function format_mermaid_markdown (line 87) | def format_mermaid_markdown(mermaid_diagram: str) -> str:
  function load_and_parse_drawio (line 99) | def load_and_parse_drawio(file_path: str | Path) -> str | None:

FILE: confluence_markdown_exporter/utils/export.py
  function parse_encode_setting (line 14) | def parse_encode_setting(encode_setting: str) -> dict[str, str]:
  function save_file (line 52) | def save_file(file_path: Path, content: str | bytes) -> None:
  function sanitize_filename (line 67) | def sanitize_filename(filename: str) -> str:
  function sanitize_key (line 123) | def sanitize_key(s: str, connector: str = "_") -> str:
  function github_heading_slug (line 141) | def github_heading_slug(text: str) -> str:
  function escape_character_class (line 153) | def escape_character_class(s: str) -> str:

FILE: confluence_markdown_exporter/utils/lockfile.py
  class AttachmentEntry (line 31) | class AttachmentEntry(BaseModel):
  class PageEntry (line 38) | class PageEntry(BaseModel):
  class SpaceEntry (line 47) | class SpaceEntry(BaseModel):
  class OrgEntry (line 53) | class OrgEntry(BaseModel):
  class ConfluenceLock (line 59) | class ConfluenceLock(BaseModel):
    method load (line 67) | def load(cls, lockfile_path: Path) -> ConfluenceLock:
    method all_pages (line 85) | def all_pages(self) -> dict[str, PageEntry]:
    method get_page (line 93) | def get_page(self, page_id: str) -> PageEntry | None:
    method remove_page (line 101) | def remove_page(self, page_id: str) -> None:
    method add_page (line 107) | def add_page(
    method save (line 132) | def save(  # noqa: C901
  class LockfileManager (line 195) | class LockfileManager:
    method init (line 206) | def init(cls) -> None:
    method get_page_attachment_entries (line 232) | def get_page_attachment_entries(cls, page_id: str) -> dict[str, Attach...
    method record_page (line 240) | def record_page(
    method mark_seen (line 256) | def mark_seen(cls, page_ids: list[int]) -> None:
    method should_export (line 265) | def should_export(cls, page: Page | Descendant) -> bool:
    method unseen_ids (line 302) | def unseen_ids(cls) -> set[str]:
    method remove_pages (line 309) | def remove_pages(cls, deleted_ids: set[str]) -> None:

FILE: confluence_markdown_exporter/utils/measure_time.py
  function _format_duration (line 21) | def _format_duration(delta: relativedelta) -> str:
  function measure_time (line 41) | def measure_time(func: Callable[P, T]) -> Callable[P, T]:
  function measure (line 56) | def measure(step: str) -> Generator[None, None, None]:

FILE: confluence_markdown_exporter/utils/page_registry.py
  class PageTitleRegistry (line 17) | class PageTitleRegistry:
    method reset (line 30) | def reset(cls) -> None:
    method register (line 36) | def register(cls, page_id: int, title: str) -> None:
    method is_ambiguous (line 51) | def is_ambiguous(cls, title: str) -> bool:
    method title_count (line 55) | def title_count(cls, title: str) -> int:

FILE: confluence_markdown_exporter/utils/rich_console.py
  function get_rich_console (line 194) | def get_rich_console(*, stderr: bool = False) -> Console:
  function setup_logging (line 208) | def setup_logging(log_level: str = "INFO", log_file: Path | None = None)...
  class ExportStats (line 242) | class ExportStats:
    method inc_exported (line 256) | def inc_exported(self) -> None:
    method inc_skipped (line 261) | def inc_skipped(self) -> None:
    method inc_failed (line 266) | def inc_failed(self) -> None:
    method inc_removed (line 271) | def inc_removed(self) -> None:
    method inc_attachments_exported (line 276) | def inc_attachments_exported(self) -> None:
    method inc_attachments_skipped (line 281) | def inc_attachments_skipped(self) -> None:
    method inc_attachments_failed (line 286) | def inc_attachments_failed(self) -> None:
    method inc_attachments_removed (line 291) | def inc_attachments_removed(self) -> None:
  function reset_stats (line 301) | def reset_stats(total: int = 0) -> ExportStats:
  function get_stats (line 315) | def get_stats() -> ExportStats:

FILE: confluence_markdown_exporter/utils/table_converter.py
  function _get_int_attr (line 13) | def _get_int_attr(cell: Tag, attr: str, default: str = "1") -> int:
  function pad (line 23) | def pad(rows: list[list[Tag]]) -> list[list[Tag]]:
  function make_empty_cell (line 55) | def make_empty_cell() -> Tag:
  function _normalize_table_cell_text (line 60) | def _normalize_table_cell_text(text: str) -> str:
  class TableConverter (line 66) | class TableConverter(MarkdownConverter):
    method convert_table (line 69) | def convert_table(self, el: BeautifulSoup, text: str, parent_tags: lis...
    method convert_th (line 88) | def convert_th(self, el: BeautifulSoup, text: str, parent_tags: list[s...
    method convert_tr (line 92) | def convert_tr(self, el: BeautifulSoup, text: str, parent_tags: list[s...
    method convert_td (line 96) | def convert_td(self, el: BeautifulSoup, text: str, parent_tags: list[s...
    method convert_thead (line 100) | def convert_thead(self, el: BeautifulSoup, text: str, parent_tags: lis...
    method convert_tbody (line 104) | def convert_tbody(self, el: BeautifulSoup, text: str, parent_tags: lis...
    method _normalize_parent_tags (line 111) | def _normalize_parent_tags(
    method convert_ol (line 117) | def convert_ol(
    method convert_li (line 133) | def convert_li(
    method convert_ul (line 141) | def convert_ul(
    method convert_p (line 154) | def convert_p(

FILE: confluence_markdown_exporter/utils/type_converter.py
  function str_to_bool (line 1) | def str_to_bool(value: str) -> bool:

FILE: scripts/build-versions.mjs
  function sh (line 26) | function sh(cmd, opts = {}) {
  function tagHasDocusaurusDocs (line 30) | function tagHasDocusaurusDocs(tag) {
  function listTags (line 40) | function listTags() {
  function snapshotTag (line 49) | function snapshotTag(tag) {
  function main (line 71) | function main() {

FILE: src/components/HomepageFeatures/index.tsx
  type Feature (line 6) | type Feature = {
  constant FEATURES (line 13) | const FEATURES: Feature[] = [
  function FeatureCard (line 83) | function FeatureCard({ icon, title, description, href }: Feature) {
  function HomepageFeatures (line 97) | function HomepageFeatures(): ReactNode {

FILE: src/components/quickstart/index.tsx
  function makeStepTabs (line 14) | function makeStepTabs(local: ReactNode, docker: ReactNode) {
  function AuthenticateTabs (line 40) | function AuthenticateTabs() {
  function ExportTabs (line 63) | function ExportTabs() {
  function VerifyTabs (line 82) | function VerifyTabs() {

FILE: src/pages/index.tsx
  function HomepageHeader (line 16) | function HomepageHeader() {
  constant INSTALL_SNIPPETS (line 46) | const INSTALL_SNIPPETS = {
  function InstallTabs (line 63) | function InstallTabs() {
  function QuickstartSection (line 88) | function QuickstartSection() {
  function Home (line 119) | function Home(): ReactNode {

FILE: tests/conftest.py
  function pytest_configure (line 34) | def pytest_configure(config: pytest.Config) -> None:  # noqa: ARG001
  function pytest_unconfigure (line 55) | def pytest_unconfigure(config: pytest.Config) -> None:  # noqa: ARG001
  function restore_api_functions_for_specific_tests (line 68) | def restore_api_functions_for_specific_tests(
  function temp_config_dir (line 111) | def temp_config_dir() -> Generator[Path, None, None]:
  function mock_confluence_client (line 118) | def mock_confluence_client() -> MagicMock:
  function mock_jira_client (line 135) | def mock_jira_client() -> MagicMock:
  function sample_api_details (line 156) | def sample_api_details() -> ApiDetails:
  function sample_connection_config (line 166) | def sample_connection_config() -> ConnectionConfig:
  function sample_config_model (line 179) | def sample_config_model(
  function confluence_page_response (line 202) | def confluence_page_response() -> dict[str, Any]:
  function confluence_space_response (line 234) | def confluence_space_response() -> dict[str, Any]:
  function jira_issue_response (line 250) | def jira_issue_response() -> dict[str, Any]:

FILE: tests/integration/test_cli_integration.py
  function test_package_has_version (line 12) | def test_package_has_version() -> None:
  function test_version_command (line 19) | def test_version_command() -> None:
  function test_config_list_command (line 47) | def test_config_list_command() -> None:
  function test_cli_entry_points (line 86) | def test_cli_entry_points() -> None:

FILE: tests/unit/test_alert_conversion.py
  function _make_converter (line 13) | def _make_converter(editor2: str = "") -> Page.Converter:
  function converter (line 32) | def converter() -> Page.Converter:
  class TestAlertOutsideTable (line 36) | class TestAlertOutsideTable:
    method test_panel_renders_as_note_alert (line 37) | def test_panel_renders_as_note_alert(self, converter: Page.Converter) ...
    method test_warning_renders_as_caution_alert (line 43) | def test_warning_renders_as_caution_alert(self, converter: Page.Conver...
  class TestAlertInsideTableCell (line 49) | class TestAlertInsideTableCell:
    method test_panel_in_td_emits_emoji_no_blockquote (line 50) | def test_panel_in_td_emits_emoji_no_blockquote(self, converter: Page.C...
    method test_info_in_td_emits_important_emoji (line 61) | def test_info_in_td_emits_important_emoji(self, converter: Page.Conver...
    method test_warning_in_td_emits_caution_emoji (line 71) | def test_warning_in_td_emits_caution_emoji(self, converter: Page.Conve...
    method test_tip_in_td_emits_tip_emoji (line 81) | def test_tip_in_td_emits_tip_emoji(self, converter: Page.Converter) ->...
    method test_note_in_td_emits_warning_emoji (line 91) | def test_note_in_td_emits_warning_emoji(self, converter: Page.Converte...
    method test_panel_in_th_emits_emoji_no_blockquote (line 101) | def test_panel_in_th_emits_emoji_no_blockquote(self, converter: Page.C...
  class TestCustomPanelEmoji (line 112) | class TestCustomPanelEmoji:
    method test_custom_panel_icon_text_used_in_table_cell (line 113) | def test_custom_panel_icon_text_used_in_table_cell(self) -> None:
    method test_custom_panel_icon_id_decoded_when_no_text (line 132) | def test_custom_panel_icon_id_decoded_when_no_text(self) -> None:
    method test_panel_without_custom_icon_falls_back_to_default (line 148) | def test_panel_without_custom_icon_falls_back_to_default(self) -> None:
    method test_unknown_macro_id_falls_back_to_default (line 163) | def test_unknown_macro_id_falls_back_to_default(self) -> None:

FILE: tests/unit/test_api_clients.py
  class TestParseConfluencePath (line 128) | class TestParseConfluencePath:
    method test_parse_confluence_path (line 132) | def test_parse_confluence_path(self, url: str, expected: ConfluenceRef...
  class TestResponseHook (line 142) | class TestResponseHook:
    method test_successful_response (line 145) | def test_successful_response(self, caplog: pytest.LogCaptureFixture) -...
    method test_failed_response (line 156) | def test_failed_response(self, caplog: pytest.LogCaptureFixture) -> None:
  class TestApiClientFactory (line 174) | class TestApiClientFactory:
    method test_init (line 177) | def test_init(self) -> None:
    method test_create_confluence_success (line 185) | def test_create_confluence_success(
    method test_create_confluence_connection_failure (line 209) | def test_create_confluence_connection_failure(
    method test_create_jira_success (line 223) | def test_create_jira_success(
    method test_create_jira_connection_failure (line 247) | def test_create_jira_connection_failure(
  class TestGetConfluenceInstance (line 261) | class TestGetConfluenceInstance:
    method test_successful_connection (line 267) | def test_successful_connection(
    method test_connection_failure_raises (line 292) | def test_connection_failure_raises(
  class TestAuthConfigContextPath (line 313) | class TestAuthConfigContextPath:
    method _make_config (line 316) | def _make_config(self, key: str) -> AuthConfig:
    method test_get_instance_matches_context_path_url (line 337) | def test_get_instance_matches_context_path_url(
    method test_get_instance_no_false_match (line 357) | def test_get_instance_no_false_match(self, stored_key: str, lookup_url...

FILE: tests/unit/test_confluence.py
  class MockPage (line 19) | class MockPage:
    method __init__ (line 22) | def __init__(self) -> None:
    method get_attachment_by_file_id (line 44) | def get_attachment_by_file_id(self, file_id: str) -> None:
  function converter (line 49) | def converter() -> Page.Converter:
  class TestSquareBracketEscaping (line 53) | class TestSquareBracketEscaping:
    method test_bracket_notation_escaped (line 56) | def test_bracket_notation_escaped(self, converter: Page.Converter) -> ...
    method test_bracket_at_start (line 61) | def test_bracket_at_start(self, converter: Page.Converter) -> None:
    method test_bracket_at_end (line 66) | def test_bracket_at_end(self, converter: Page.Converter) -> None:
    method test_multiple_bracket_groups (line 71) | def test_multiple_bracket_groups(self, converter: Page.Converter) -> N...
    method test_bracket_in_code_not_escaped (line 76) | def test_bracket_in_code_not_escaped(self, converter: Page.Converter) ...
    method test_real_link_not_affected (line 81) | def test_real_link_not_affected(self, converter: Page.Converter) -> None:
  class TestAnchorLinkConversion (line 87) | class TestAnchorLinkConversion:
    method test_anchor_uses_href_not_link_text (line 90) | def test_anchor_uses_href_not_link_text(self, converter: Page.Converte...
    method test_anchor_plain_heading (line 96) | def test_anchor_plain_heading(self, converter: Page.Converter) -> None:
    method test_anchor_with_numbers_and_punctuation (line 102) | def test_anchor_with_numbers_and_punctuation(self, converter: Page.Con...
    method test_wiki_anchor_uses_link_text (line 108) | def test_wiki_anchor_uses_link_text(self, converter: Page.Converter) -...
  function _make_attachment (line 119) | def _make_attachment(
  function _make_page (line 149) | def _make_page(
  class TestAttachmentsForExport (line 178) | class TestAttachmentsForExport:
    method test_file_id_in_body_included (line 181) | def test_file_id_in_body_included(self) -> None:
    method test_attachment_id_in_body_included (line 193) | def test_attachment_id_in_body_included(self) -> None:
    method test_attachment_id_in_body_export_included (line 208) | def test_attachment_id_in_body_export_included(self) -> None:
    method test_title_in_body_src_url_included (line 221) | def test_title_in_body_src_url_included(self) -> None:
    method test_title_with_spaces_url_encoded_in_body_export_included (line 236) | def test_title_with_spaces_url_encoded_in_body_export_included(self) -...
    method test_unreferenced_attachment_excluded (line 248) | def test_unreferenced_attachment_excluded(self) -> None:
    method test_attachments_export_all_returns_all (line 256) | def test_attachments_export_all_returns_all(self) -> None:
  class TestAttachmentsExportFlag (line 266) | class TestAttachmentsExportFlag:
    method _make_attachment_mock (line 269) | def _make_attachment_mock(self, att_id: str = "att-1", version: int = ...
    method _make_page_mock (line 276) | def _make_page_mock(self, attachments: list) -> MagicMock:
    method test_referenced_default_exports_attachments (line 282) | def test_referenced_default_exports_attachments(self, tmp_path: Path) ...
    method test_disabled_skips_download_and_lockfile (line 303) | def test_disabled_skips_download_and_lockfile(self) -> None:
    method test_metadata_still_populated_when_disabled (line 323) | def test_metadata_still_populated_when_disabled(self) -> None:
  class TestTransformErrorImg (line 385) | class TestTransformErrorImg:
    method test_transform_error_resolves_attachment_by_encoded_xml (line 388) | def test_transform_error_resolves_attachment_by_encoded_xml(self) -> N...
  class TestParseImageCaptions (line 433) | class TestParseImageCaptions:
    method test_cdata_caption_extracted (line 436) | def test_cdata_caption_extracted(self) -> None:
    method test_plain_text_caption_extracted (line 449) | def test_plain_text_caption_extracted(self) -> None:
    method test_paragraph_caption_extracted (line 462) | def test_paragraph_caption_extracted(self) -> None:
    method test_caption_with_attributes_extracted (line 474) | def test_caption_with_attributes_extracted(self) -> None:
    method test_image_without_caption_excluded (line 488) | def test_image_without_caption_excluded(self) -> None:
    method test_multiple_images_mixed (line 498) | def test_multiple_images_mixed(self) -> None:
    method test_empty_storage_returns_empty (line 519) | def test_empty_storage_returns_empty(self) -> None:
  class TestImageCaptionsInConvertImg (line 525) | class TestImageCaptionsInConvertImg:
    method test_caption_rendered_as_italic_below_image (line 528) | def test_caption_rendered_as_italic_below_image(self) -> None:
    method test_caption_disabled_preserves_original_alt (line 561) | def test_caption_disabled_preserves_original_alt(self) -> None:
  class TestPageFromUrl (line 591) | class TestPageFromUrl:
    method test_from_url_prefers_page_id_query_parameter_for_legacy_server_url (line 594) | def test_from_url_prefers_page_id_query_parameter_for_legacy_server_ur...
  class TestSpanHighlightConversion (line 615) | class TestSpanHighlightConversion:
    method test_background_color_rgb_converted_to_mark (line 618) | def test_background_color_rgb_converted_to_mark(self, converter: Page....
    method test_multiple_channels_converted_correctly (line 623) | def test_multiple_channels_converted_correctly(self, converter: Page.C...
    method test_highlight_disabled_returns_plain_text (line 628) | def test_highlight_disabled_returns_plain_text(self, converter: Page.C...
  class TestCellHighlightConversion (line 638) | class TestCellHighlightConversion:
    method test_td_hex_attribute_wraps_in_mark (line 641) | def test_td_hex_attribute_wraps_in_mark(self, converter: Page.Converte...
    method test_th_hex_attribute_wraps_in_mark (line 650) | def test_th_hex_attribute_wraps_in_mark(self, converter: Page.Converte...
    method test_default_header_gray_not_wrapped (line 659) | def test_default_header_gray_not_wrapped(self, converter: Page.Convert...
    method test_transparent_attribute_not_wrapped (line 670) | def test_transparent_attribute_not_wrapped(self, converter: Page.Conve...
    method test_missing_attribute_not_wrapped (line 680) | def test_missing_attribute_not_wrapped(self, converter: Page.Converter...
    method test_invalid_hex_not_wrapped (line 688) | def test_invalid_hex_not_wrapped(self, converter: Page.Converter) -> N...
    method test_empty_cell_with_highlight_renders_nbsp (line 697) | def test_empty_cell_with_highlight_renders_nbsp(self, converter: Page....
    method test_setting_disabled_returns_plain_text (line 706) | def test_setting_disabled_returns_plain_text(self, converter: Page.Con...
  class TestSpanFontColorConversion (line 721) | class TestSpanFontColorConversion:
    method test_inline_color_rgb_converted_to_font (line 724) | def test_inline_color_rgb_converted_to_font(self, converter: Page.Conv...
    method test_background_color_not_matched_as_font_color (line 729) | def test_background_color_not_matched_as_font_color(self, converter: P...
    method test_data_colorid_resolved_from_style_tag (line 735) | def test_data_colorid_resolved_from_style_tag(self) -> None:
    method test_data_colorid_unknown_falls_through (line 746) | def test_data_colorid_unknown_falls_through(self, converter: Page.Conv...
    method test_font_color_disabled_returns_plain_text (line 752) | def test_font_color_disabled_returns_plain_text(self, converter: Page....
  class TestStatusBadgeConversion (line 762) | class TestStatusBadgeConversion:
    method _badge (line 765) | def _badge(self, extra_class: str, label: str) -> str:
    method test_gray_badge (line 771) | def test_gray_badge(self, converter: Page.Converter) -> None:
    method test_blue_badge (line 776) | def test_blue_badge(self, converter: Page.Converter) -> None:
    method test_green_badge (line 781) | def test_green_badge(self, converter: Page.Converter) -> None:
    method test_yellow_badge (line 786) | def test_yellow_badge(self, converter: Page.Converter) -> None:
    method test_red_badge (line 791) | def test_red_badge(self, converter: Page.Converter) -> None:
    method test_purple_badge (line 796) | def test_purple_badge(self, converter: Page.Converter) -> None:
    method test_badge_disabled_returns_plain_text (line 801) | def test_badge_disabled_returns_plain_text(self, converter: Page.Conve...
  class TestPagePropertiesFormat (line 822) | class TestPagePropertiesFormat:
    method _converter (line 825) | def _converter(self) -> Page.Converter:
    method test_frontmatter_removes_table (line 828) | def test_frontmatter_removes_table(self) -> None:
    method test_table_keeps_table_no_properties (line 837) | def test_table_keeps_table_no_properties(self) -> None:
    method test_frontmatter_and_table_keeps_both (line 845) | def test_frontmatter_and_table_keeps_both(self) -> None:
    method test_dataview_inline_field (line 854) | def test_dataview_inline_field(self) -> None:
    method test_meta_bind_view_fields (line 864) | def test_meta_bind_view_fields(self) -> None:
    method test_duplicate_keys_get_numeric_suffix (line 874) | def test_duplicate_keys_get_numeric_suffix(self) -> None:
    method test_duplicate_keys_in_inline_fields (line 892) | def test_duplicate_keys_in_inline_fields(self) -> None:
  class TestPagePropertiesMigration (line 909) | class TestPagePropertiesMigration:
    method test_old_true_maps_to_frontmatter (line 912) | def test_old_true_maps_to_frontmatter(self) -> None:
    method test_old_false_maps_to_table (line 918) | def test_old_false_maps_to_table(self) -> None:
    method test_new_field_takes_precedence_over_old (line 924) | def test_new_field_takes_precedence_over_old(self) -> None:
    method test_default_is_frontmatter_and_table (line 932) | def test_default_is_frontmatter_and_table(self) -> None:
  class TestConfluenceUrlInFrontmatter (line 939) | class TestConfluenceUrlInFrontmatter:
    method _converter (line 945) | def _converter(self, *, with_urls: bool = True) -> Page.Converter:
    method test_get_web_url_combines_base_and_webui (line 952) | def test_get_web_url_combines_base_and_webui(self) -> None:
    method test_get_tiny_url_combines_base_and_tinyui (line 963) | def test_get_tiny_url_combines_base_and_tinyui(self) -> None:
    method test_helpers_strip_redundant_separators (line 974) | def test_helpers_strip_redundant_separators(self) -> None:
    method test_helpers_return_empty_when_links_missing (line 985) | def test_helpers_return_empty_when_links_missing(self) -> None:
    method test_helpers_return_empty_when_links_not_dict (line 992) | def test_helpers_return_empty_when_links_not_dict(self) -> None:
    method test_helpers_return_empty_when_base_or_rel_missing (line 999) | def test_helpers_return_empty_when_base_or_rel_missing(self) -> None:
    method test_frontmatter_contains_webui_url_when_mode_webui (line 1005) | def test_frontmatter_contains_webui_url_when_mode_webui(self) -> None:
    method test_frontmatter_contains_tinyui_url_when_mode_tinyui (line 1013) | def test_frontmatter_contains_tinyui_url_when_mode_tinyui(self) -> None:
    method test_frontmatter_contains_both_when_mode_both (line 1021) | def test_frontmatter_contains_both_when_mode_both(self) -> None:
    method test_frontmatter_omits_urls_when_mode_none (line 1029) | def test_frontmatter_omits_urls_when_mode_none(self) -> None:
    method test_frontmatter_skips_when_url_value_is_empty (line 1037) | def test_frontmatter_skips_when_url_value_is_empty(self) -> None:
    method test_macro_value_takes_precedence_over_extracted_url (line 1045) | def test_macro_value_takes_precedence_over_extracted_url(self) -> None:
  class TestPageMetadataInFrontmatter (line 1055) | class TestPageMetadataInFrontmatter:
    method _make_page (line 1058) | def _make_page(
    method _converter (line 1085) | def _converter(self, **kwargs: object) -> Page.Converter:
    method test_default_disabled_writes_no_metadata (line 1088) | def test_default_disabled_writes_no_metadata(self) -> None:
    method test_enabled_writes_all_eight_keys (line 1103) | def test_enabled_writes_all_eight_keys(self) -> None:
    method test_blogpost_type_renders (line 1119) | def test_blogpost_type_renders(self) -> None:
    method test_macro_precedence_for_page_id (line 1127) | def test_macro_precedence_for_page_id(self) -> None:
    method test_macro_precedence_for_history_fields (line 1145) | def test_macro_precedence_for_history_fields(
    method test_empty_display_name_skipped (line 1157) | def test_empty_display_name_skipped(self) -> None:
    method test_empty_creator_skipped (line 1169) | def test_empty_creator_skipped(self) -> None:
    method test_empty_type_skipped (line 1179) | def test_empty_type_skipped(self) -> None:
  class TestInlineCommentsFrontMatter (line 1190) | class TestInlineCommentsFrontMatter:
    method test_front_matter_uses_confluence_prefix (line 1193) | def test_front_matter_uses_confluence_prefix(self) -> None:
  function _make_comments_page (line 1244) | def _make_comments_page(
  function _run_export_capturing_save (line 1269) | def _run_export_capturing_save(page: MockPage, mode: str) -> MagicMock:
  function _inline_comment (line 1280) | def _inline_comment(
  function _page_comment (line 1297) | def _page_comment(
  class TestPageCommentsSidecarBody (line 1315) | class TestPageCommentsSidecarBody:
    method test_only_footer_writes_only_page_section (line 1318) | def test_only_footer_writes_only_page_section(self) -> None:
    method test_all_writes_both_sections_inline_first (line 1328) | def test_all_writes_both_sections_inline_first(self) -> None:
    method test_none_writes_no_file (line 1341) | def test_none_writes_no_file(self) -> None:
    method test_inline_only_omits_page_section (line 1349) | def test_inline_only_omits_page_section(self) -> None:
    method test_page_comment_title_falls_back_to_comment_id (line 1361) | def test_page_comment_title_falls_back_to_comment_id(self) -> None:
    method test_page_comment_replies_render_under_parent (line 1370) | def test_page_comment_replies_render_under_parent(self) -> None:
    method test_fetch_page_comments_filters_resolved (line 1402) | def test_fetch_page_comments_filters_resolved(self) -> None:
  class TestPagePropertiesReportDataview (line 1427) | class TestPagePropertiesReportDataview:
    class _MockPageWithExport (line 1451) | class _MockPageWithExport:
      method __init__ (line 1452) | def __init__(self, body_export: str = "") -> None:
      method get_attachment_by_file_id (line 1463) | def get_attachment_by_file_id(self, file_id: str) -> None:
    method test_dataview_output_contains_table_clause (line 1466) | def test_dataview_output_contains_table_clause(self) -> None:
    method test_dataview_output_contains_from_clause (line 1476) | def test_dataview_output_contains_from_clause(self) -> None:
    method test_dataview_from_clause_with_current_content_ancestor (line 1484) | def test_dataview_from_clause_with_current_content_ancestor(self) -> N...
    method test_dataview_output_contains_label_in_from_clause (line 1503) | def test_dataview_output_contains_label_in_from_clause(self) -> None:
    method test_dataview_output_contains_sort_clause (line 1511) | def test_dataview_output_contains_sort_clause(self) -> None:
    method test_frozen_table_when_format_is_frozen (line 1519) | def test_frozen_table_when_format_is_frozen(self) -> None:
  class TestAttachmentTemplateVars (line 1529) | class TestAttachmentTemplateVars:
    method test_cloud_style_keeps_file_id (line 1532) | def test_cloud_style_keeps_file_id(self) -> None:
    method test_dc_style_falls_back_to_content_id (line 1537) | def test_dc_style_falls_back_to_content_id(self) -> None:
    method test_two_dc_attachments_get_distinct_paths (line 1542) | def test_two_dc_attachments_get_distinct_paths(self) -> None:
  class TestWikiLinkDisambiguation (line 1557) | class TestWikiLinkDisambiguation:
    method _make_target_page (line 1560) | def _make_target_page(self, page_id: int, title: str, space_key: str) ...
    method test_unique_title_emits_short_wiki_link (line 1595) | def test_unique_title_emits_short_wiki_link(self) -> None:
    method test_colliding_title_emits_path_qualified_wiki_link (line 1617) | def test_colliding_title_emits_path_qualified_wiki_link(self) -> None:
    method test_relative_link_unaffected (line 1644) | def test_relative_link_unaffected(self) -> None:
  class TestAbsoluteUrlPageLinks (line 1673) | class TestAbsoluteUrlPageLinks:
    method _make_target_page (line 1676) | def _make_target_page(self, page_id: int, title: str, space_key: str) ...
    method test_absolute_url_same_host_resolves_page (line 1711) | def test_absolute_url_same_host_resolves_page(self) -> None:
    method test_absolute_url_different_host_left_alone (line 1738) | def test_absolute_url_different_host_left_alone(self) -> None:
    method test_legacy_pageid_query_resolves_page (line 1748) | def test_legacy_pageid_query_resolves_page(self) -> None:

FILE: tests/unit/test_emoticon_conversion.py
  function converter (line 14) | def converter() -> Page.Converter:
  class TestEmoticonConversion (line 31) | class TestEmoticonConversion:
    method test_atlassian_check_mark (line 32) | def test_atlassian_check_mark(self, converter: Page.Converter) -> None:
    method test_atlassian_cross_mark (line 42) | def test_atlassian_cross_mark(self, converter: Page.Converter) -> None:
    method test_unicode_emoji_by_hex_id (line 52) | def test_unicode_emoji_by_hex_id(self, converter: Page.Converter) -> N...
    method test_unicode_emoji_fallback_direct (line 62) | def test_unicode_emoji_fallback_direct(self, converter: Page.Converter...
    method test_custom_emoji_uuid_falls_back_to_shortname (line 71) | def test_custom_emoji_uuid_falls_back_to_shortname(self, converter: Pa...
    method test_non_emoticon_img_unchanged (line 81) | def test_non_emoticon_img_unchanged(self, converter: Page.Converter) -...
    method test_emoticon_inline_in_text (line 87) | def test_emoticon_inline_in_text(self, converter: Page.Converter) -> N...

FILE: tests/unit/test_include_macro_conversion.py
  function _make_page (line 11) | def _make_page(editor2: str) -> MagicMock:
  function test_include_macro_transclusion_mode (line 43) | def test_include_macro_transclusion_mode(mock_settings: MagicMock) -> None:
  function test_excerpt_include_macro_transclusion_mode (line 62) | def test_excerpt_include_macro_transclusion_mode(mock_settings: MagicMoc...
  function test_include_macro_inline_mode (line 81) | def test_include_macro_inline_mode(mock_settings: MagicMock) -> None:
  function test_excerpt_include_inline_strips_source_page_title_panel (line 100) | def test_excerpt_include_inline_strips_source_page_title_panel(
  function test_excerpt_include_inline_keeps_body_when_no_panel (line 126) | def test_excerpt_include_inline_keeps_body_when_no_panel(
  function test_include_macro_transclusion_falls_back_when_target_unresolvable (line 146) | def test_include_macro_transclusion_falls_back_when_target_unresolvable(

FILE: tests/unit/test_main.py
  class TestVersionCommand (line 10) | class TestVersionCommand:
    method test_version_output (line 13) | def test_version_output(self, capsys: pytest.CaptureFixture[str]) -> N...
  class TestAppConfiguration (line 23) | class TestAppConfiguration:
    method test_app_is_typer_instance (line 26) | def test_app_is_typer_instance(self) -> None:
    method test_app_has_commands (line 30) | def test_app_has_commands(self) -> None:
    method test_app_has_config_group (line 42) | def test_app_has_config_group(self) -> None:

FILE: tests/unit/test_nbsp_fix.py
  class TestNbspPreservation (line 13) | class TestNbspPreservation:
    method converter (line 17) | def converter(self) -> Page.Converter:
    method test_em_with_leading_nbsp (line 36) | def test_em_with_leading_nbsp(self, converter: Page.Converter) -> None:
    method test_em_with_trailing_nbsp (line 46) | def test_em_with_trailing_nbsp(self, converter: Page.Converter) -> None:
    method test_em_with_both_nbsp (line 56) | def test_em_with_both_nbsp(self, converter: Page.Converter) -> None:
    method test_strong_with_leading_nbsp (line 65) | def test_strong_with_leading_nbsp(self, converter: Page.Converter) -> ...
    method test_strong_with_trailing_nbsp (line 72) | def test_strong_with_trailing_nbsp(self, converter: Page.Converter) ->...
    method test_code_with_leading_nbsp (line 79) | def test_code_with_leading_nbsp(self, converter: Page.Converter) -> None:
    method test_code_with_trailing_nbsp (line 86) | def test_code_with_trailing_nbsp(self, converter: Page.Converter) -> N...
    method test_i_tag_with_nbsp (line 93) | def test_i_tag_with_nbsp(self, converter: Page.Converter) -> None:
    method test_b_tag_with_nbsp (line 100) | def test_b_tag_with_nbsp(self, converter: Page.Converter) -> None:
    method test_real_world_confluence_example (line 107) | def test_real_world_confluence_example(self, converter: Page.Converter...
    method test_multiple_nbsp_in_sequence (line 117) | def test_multiple_nbsp_in_sequence(self, converter: Page.Converter) ->...
    method test_mixed_whitespace (line 124) | def test_mixed_whitespace(self, converter: Page.Converter) -> None:
    method test_normalize_helper_function (line 130) | def test_normalize_helper_function(self, converter: Page.Converter) ->...
    method test_unicode_em_space (line 148) | def test_unicode_em_space(self, converter: Page.Converter) -> None:
    method test_unicode_thin_space (line 158) | def test_unicode_thin_space(self, converter: Page.Converter) -> None:
    method test_preserves_newlines_and_tabs (line 167) | def test_preserves_newlines_and_tabs(self, converter: Page.Converter) ...
    method test_no_modification_when_no_unicode_whitespace (line 177) | def test_no_modification_when_no_unicode_whitespace(self, converter: P...

FILE: tests/unit/test_plantuml_code_block_detection.py
  class TestPlantUMLCodeBlockDetection (line 11) | class TestPlantUMLCodeBlockDetection:
    method _make_page (line 14) | def _make_page(self) -> MagicMock:
    method test_pre_with_startuml_uses_plantuml_fence (line 27) | def test_pre_with_startuml_uses_plantuml_fence(self, mock_settings: Ma...
    method test_pre_without_startuml_keeps_original_language (line 45) | def test_pre_without_startuml_keeps_original_language(self, mock_setti...
    method test_pre_empty_text_returns_empty (line 63) | def test_pre_empty_text_returns_empty(self, mock_settings: MagicMock) ...
    method test_pre_no_language_with_startuml (line 77) | def test_pre_no_language_with_startuml(self, mock_settings: MagicMock)...

FILE: tests/unit/test_plantuml_conversion.py
  class TestPlantUMLConversion (line 12) | class TestPlantUMLConversion:
    method mock_page (line 16) | def mock_page(self) -> MagicMock:
    method mock_server_page (line 39) | def mock_server_page(self) -> MagicMock:
    method test_convert_plantuml_cloud_editor2 (line 61) | def test_convert_plantuml_cloud_editor2(
    method test_convert_plantuml_server_storage (line 81) | def test_convert_plantuml_server_storage(
    method test_convert_plantuml_server_multiple_diagrams (line 101) | def test_convert_plantuml_server_multiple_diagrams(
    method test_convert_plantuml_no_source_available (line 144) | def test_convert_plantuml_no_source_available(
    method test_convert_plantuml_complex_diagram (line 171) | def test_convert_plantuml_complex_diagram(self, mock_settings: MagicMo...
    method test_convert_plantuml_editor2_fallback_to_storage (line 210) | def test_convert_plantuml_editor2_fallback_to_storage(
    method test_convert_plantuml_invalid_json_falls_through (line 251) | def test_convert_plantuml_invalid_json_falls_through(
    method test_convert_span_dispatches_plantuml (line 289) | def test_convert_span_dispatches_plantuml(

FILE: tests/unit/test_template_placeholders.py
  class TestTemplatePlaceholderEscaping (line 13) | class TestTemplatePlaceholderEscaping:
    method converter (line 17) | def converter(self) -> Page.Converter:
    method test_multi_word_placeholder_escaped (line 33) | def test_multi_word_placeholder_escaped(self, converter: Page.Converte...
    method test_allcaps_placeholder_escaped (line 37) | def test_allcaps_placeholder_escaped(self, converter: Page.Converter) ...
    method test_complex_placeholder_escaped (line 43) | def test_complex_placeholder_escaped(self, converter: Page.Converter) ...
    method test_placeholder_with_slash_in_name_escaped (line 49) | def test_placeholder_with_slash_in_name_escaped(self, converter: Page....
    method test_fake_closing_tag_placeholder_escaped (line 55) | def test_fake_closing_tag_placeholder_escaped(self, converter: Page.Co...
    method test_br_tag_preserved (line 59) | def test_br_tag_preserved(self, converter: Page.Converter) -> None:
    method test_br_with_space_preserved (line 63) | def test_br_with_space_preserved(self, converter: Page.Converter) -> N...
    method test_br_uppercase_preserved (line 67) | def test_br_uppercase_preserved(self, converter: Page.Converter) -> None:
    method test_closing_html_tag_preserved (line 71) | def test_closing_html_tag_preserved(self, converter: Page.Converter) -...
    method test_inline_code_not_modified (line 75) | def test_inline_code_not_modified(self, converter: Page.Converter) -> ...
    method test_fenced_code_block_not_modified (line 79) | def test_fenced_code_block_not_modified(self, converter: Page.Converte...
    method test_tilde_fenced_code_block_not_modified (line 86) | def test_tilde_fenced_code_block_not_modified(self, converter: Page.Co...
    method test_text_outside_code_block_still_escaped (line 91) | def test_text_outside_code_block_still_escaped(self, converter: Page.C...
    method test_https_autolink_preserved (line 99) | def test_https_autolink_preserved(self, converter: Page.Converter) -> ...
    method test_http_autolink_preserved (line 105) | def test_http_autolink_preserved(self, converter: Page.Converter) -> N...
    method test_mailto_autolink_preserved (line 109) | def test_mailto_autolink_preserved(self, converter: Page.Converter) ->...
    method test_email_autolink_preserved (line 113) | def test_email_autolink_preserved(self, converter: Page.Converter) -> ...
    method test_autolink_with_space_still_escaped (line 117) | def test_autolink_with_space_still_escaped(self, converter: Page.Conve...

FILE: tests/unit/utils/test_app_data_store_env.py
  class TestEnvVarOverrides (line 17) | class TestEnvVarOverrides:
    method test_log_level_env_override (line 20) | def test_log_level_env_override(self) -> None:
    method test_output_path_env_override (line 26) | def test_output_path_env_override(self) -> None:
    method test_max_workers_env_override (line 32) | def test_max_workers_env_override(self) -> None:
    method test_verify_ssl_env_override_false (line 38) | def test_verify_ssl_env_override_false(self) -> None:
    method test_skip_unchanged_env_override (line 44) | def test_skip_unchanged_env_override(self) -> None:
    method test_save_log_to_file_default_false (line 50) | def test_save_log_to_file_default_false(self) -> None:
    method test_save_log_to_file_env_override (line 54) | def test_save_log_to_file_env_override(self) -> None:
    method test_attachments_export_env_override (line 60) | def test_attachments_export_env_override(self) -> None:
    method test_comments_export_env_override (line 66) | def test_comments_export_env_override(self) -> None:
    method test_confluence_url_in_frontmatter_env_override (line 72) | def test_confluence_url_in_frontmatter_env_override(self) -> None:
    method test_page_metadata_in_frontmatter_env_override (line 78) | def test_page_metadata_in_frontmatter_env_override(self) -> None:
    method test_env_var_does_not_persist (line 84) | def test_env_var_does_not_persist(self) -> None:
    method test_file_config_used_without_env_override (line 102) | def test_file_config_used_without_env_override(self) -> None:
    method test_env_override_takes_precedence_over_file (line 119) | def test_env_override_takes_precedence_over_file(self) -> None:
    method test_multiple_env_overrides (line 134) | def test_multiple_env_overrides(self) -> None:
    method test_page_href_env_override (line 151) | def test_page_href_env_override(self) -> None:
    method test_attachment_href_env_override (line 157) | def test_attachment_href_env_override(self) -> None:
    method test_cleanup_stale_env_override (line 163) | def test_cleanup_stale_env_override(self) -> None:
    method test_backoff_and_retry_env_override (line 169) | def test_backoff_and_retry_env_override(self) -> None:
    method test_max_backoff_seconds_env_override (line 175) | def test_max_backoff_seconds_env_override(self) -> None:
    method test_enable_jira_enrichment_env_override (line 181) | def test_enable_jira_enrichment_env_override(self) -> None:
    method test_lockfile_name_env_override (line 187) | def test_lockfile_name_env_override(self) -> None:
    method test_existence_check_batch_size_env_override (line 193) | def test_existence_check_batch_size_env_override(self) -> None:
    method test_app_settings_is_base_settings_subclass (line 199) | def test_app_settings_is_base_settings_subclass(self) -> None:
    method test_invalid_log_level_env_var_raises (line 205) | def test_invalid_log_level_env_var_raises(self) -> None:
  class TestLoadAppData (line 215) | class TestLoadAppData:
    method test_empty_config_file_returns_defaults (line 218) | def test_empty_config_file_returns_defaults(self) -> None:
    method test_invalid_json_config_file_returns_defaults (line 228) | def test_invalid_json_config_file_returns_defaults(self) -> None:
  class TestAttachmentPathMigration (line 239) | class TestAttachmentPathMigration:
    method test_title_without_extension_gets_migrated (line 242) | def test_title_without_extension_gets_migrated(self) -> None:
    method test_title_with_other_path_segments_migrated (line 247) | def test_title_with_other_path_segments_migrated(self) -> None:
    method test_title_already_has_extension_not_changed (line 252) | def test_title_already_has_extension_not_changed(self) -> None:
    method test_no_attachment_title_not_changed (line 258) | def test_no_attachment_title_not_changed(self) -> None:
    method test_migration_via_env_var (line 264) | def test_migration_via_env_var(self) -> None:
  class TestAttachmentsExportMigration (line 276) | class TestAttachmentsExportMigration:
    method test_legacy_false_maps_to_referenced (line 279) | def test_legacy_false_maps_to_referenced(self) -> None:
    method test_legacy_true_maps_to_all (line 284) | def test_legacy_true_maps_to_all(self) -> None:
    method test_new_field_takes_precedence_over_old (line 289) | def test_new_field_takes_precedence_over_old(self) -> None:
  class TestCommentsExportMigration (line 297) | class TestCommentsExportMigration:
    method test_legacy_true_maps_to_inline (line 300) | def test_legacy_true_maps_to_inline(self) -> None:
    method test_legacy_false_maps_to_none (line 305) | def test_legacy_false_maps_to_none(self) -> None:
    method test_new_field_takes_precedence_over_old (line 310) | def test_new_field_takes_precedence_over_old(self) -> None:
    method test_legacy_key_does_not_appear_on_model (line 317) | def test_legacy_key_does_not_appear_on_model(self) -> None:

FILE: tests/unit/utils/test_drawio_converter.py
  class TestLoadDrawioFile (line 12) | class TestLoadDrawioFile:
    method test_load_existing_file (line 15) | def test_load_existing_file(self, tmp_path: Path) -> None:
    method test_load_nonexistent_file (line 24) | def test_load_nonexistent_file(self, tmp_path: Path) -> None:
  class TestExtractMermaidData (line 31) | class TestExtractMermaidData:
    method test_extract_valid_mermaid_data (line 34) | def test_extract_valid_mermaid_data(self) -> None:
    method test_extract_no_mermaid_data (line 51) | def test_extract_no_mermaid_data(self) -> None:
    method test_extract_invalid_xml (line 66) | def test_extract_invalid_xml(self) -> None:
  class TestParseMermaidJson (line 73) | class TestParseMermaidJson:
    method test_parse_json_with_data_field (line 76) | def test_parse_json_with_data_field(self) -> None:
    method test_parse_plain_diagram (line 82) | def test_parse_plain_diagram(self) -> None:
    method test_parse_malformed_json (line 88) | def test_parse_malformed_json(self) -> None:
  class TestFormatMermaidMarkdown (line 95) | class TestFormatMermaidMarkdown:
    method test_format_diagram (line 98) | def test_format_diagram(self) -> None:
  class TestLoadAndParseDrawio (line 105) | class TestLoadAndParseDrawio:
    method test_full_pipeline (line 108) | def test_full_pipeline(self, tmp_path: Path) -> None:
    method test_nonexistent_file (line 132) | def test_nonexistent_file(self, tmp_path: Path) -> None:
    method test_file_without_mermaid_data (line 137) | def test_file_without_mermaid_data(self, tmp_path: Path) -> None:

FILE: tests/unit/utils/test_export.py
  class TestParseEncodeSetting (line 18) | class TestParseEncodeSetting:
    method test_empty_string (line 21) | def test_empty_string(self) -> None:
    method test_simple_mapping (line 26) | def test_simple_mapping(self) -> None:
    method test_mixed_mapping (line 32) | def test_mixed_mapping(self) -> None:
    method test_equals_mapping (line 38) | def test_equals_mapping(self) -> None:
    method test_special_characters (line 44) | def test_special_characters(self) -> None:
    method test_invalid_json (line 50) | def test_invalid_json(self) -> None:
    method test_non_dict_json (line 55) | def test_non_dict_json(self) -> None:
    method test_malformed_json (line 60) | def test_malformed_json(self) -> None:
  class TestSaveFile (line 66) | class TestSaveFile:
    method test_save_string_content (line 69) | def test_save_string_content(self) -> None:
    method test_save_bytes_content (line 80) | def test_save_bytes_content(self) -> None:
    method test_create_parent_directories (line 91) | def test_create_parent_directories(self) -> None:
    method test_overwrite_existing_file (line 102) | def test_overwrite_existing_file(self) -> None:
    method test_invalid_content_type (line 114) | def test_invalid_content_type(self) -> None:
  class TestSanitizeFilename (line 123) | class TestSanitizeFilename:
    method test_no_encoding_specified (line 127) | def test_no_encoding_specified(self, mock_export_options: MagicMock) -...
    method test_with_encoding_mapping (line 137) | def test_with_encoding_mapping(self, mock_export_options: MagicMock) -...
    method test_with_encoding_mapping_lowercase (line 147) | def test_with_encoding_mapping_lowercase(self, mock_export_options: Ma...
    method test_trim_trailing_spaces_and_dots (line 157) | def test_trim_trailing_spaces_and_dots(self, mock_export_options: Magi...
    method test_reserved_windows_names (line 167) | def test_reserved_windows_names(self, mock_export_options: MagicMock) ...
    method test_filename_length_limit (line 183) | def test_filename_length_limit(self, mock_export_options: MagicMock) -...
    method test_complex_filename_sanitization (line 194) | def test_complex_filename_sanitization(self, mock_export_options: Magi...
    method test_control_characters_removed (line 206) | def test_control_characters_removed(self, mock_export_options: MagicMo...
    method test_multiple_control_characters (line 215) | def test_multiple_control_characters(self, mock_export_options: MagicM...
  class TestSanitizeKey (line 224) | class TestSanitizeKey:
    method test_basic_string (line 227) | def test_basic_string(self) -> None:
    method test_special_characters (line 232) | def test_special_characters(self) -> None:
    method test_multiple_underscores_collapse (line 237) | def test_multiple_underscores_collapse(self) -> None:
    method test_trim_leading_trailing_underscores (line 242) | def test_trim_leading_trailing_underscores(self) -> None:
    method test_starts_with_number (line 247) | def test_starts_with_number(self) -> None:
    method test_starts_with_special_character (line 252) | def test_starts_with_special_character(self) -> None:
    method test_custom_connector (line 259) | def test_custom_connector(self) -> None:
    method test_already_valid_key (line 264) | def test_already_valid_key(self) -> None:
    method test_empty_string (line 269) | def test_empty_string(self) -> None:
    method test_only_special_characters (line 274) | def test_only_special_characters(self) -> None:
  class TestGithubHeadingSlug (line 280) | class TestGithubHeadingSlug:
    method test_leading_hyphen_preserved (line 283) | def test_leading_hyphen_preserved(self) -> None:
    method test_plain_heading (line 287) | def test_plain_heading(self) -> None:
    method test_uppercase (line 290) | def test_uppercase(self) -> None:
    method test_special_chars_removed (line 293) | def test_special_chars_removed(self) -> None:
    method test_multiple_spaces_collapsed (line 296) | def test_multiple_spaces_collapsed(self) -> None:
    method test_trailing_hyphen (line 299) | def test_trailing_hyphen(self) -> None:
    method test_empty_string (line 302) | def test_empty_string(self) -> None:
  class TestEscapeCharacterClass (line 306) | class TestEscapeCharacterClass:
    method test_escape_backslash (line 309) | def test_escape_backslash(self) -> None:
    method test_escape_dash (line 314) | def test_escape_dash(self) -> None:
    method test_escape_right_bracket (line 319) | def test_escape_right_bracket(self) -> None:
    method test_escape_caret (line 324) | def test_escape_caret(self) -> None:
    method test_escape_multiple_characters (line 329) | def test_escape_multiple_characters(self) -> None:
    method test_no_special_characters (line 334) | def test_no_special_characters(self) -> None:
    method test_mixed_characters (line 339) | def test_mixed_characters(self) -> None:
    method test_empty_string (line 344) | def test_empty_string(self) -> None:

FILE: tests/unit/utils/test_lockfile.py
  function _make_mock_page (line 23) | def _make_mock_page(
  function _lock_with_pages (line 42) | def _lock_with_pages(
  function _lock_data (line 58) | def _lock_data(
  function _reset_lockfile_manager (line 79) | def _reset_lockfile_manager() -> None:
  class TestLockfileManagerInit (line 88) | class TestLockfileManagerInit:
    method test_init_creates_empty_lock_when_no_lockfile (line 92) | def test_init_creates_empty_lock_when_no_lockfile(
    method test_init_loads_existing_lockfile (line 108) | def test_init_loads_existing_lockfile(
    method test_init_snapshots_all_entries (line 130) | def test_init_snapshots_all_entries(
    method test_init_discards_v1_lockfile (line 151) | def test_init_discards_v1_lockfile(
  class TestLockfileManagerRecordPage (line 175) | class TestLockfileManagerRecordPage:
    method test_record_page_creates_lockfile (line 178) | def test_record_page_creates_lockfile(self) -> None:
    method test_record_page_does_nothing_when_not_initialized (line 194) | def test_record_page_does_nothing_when_not_initialized(self) -> None:
    method test_record_page_updates_existing_entry (line 201) | def test_record_page_updates_existing_entry(self) -> None:
    method test_record_page_adds_to_seen_page_ids (line 217) | def test_record_page_adds_to_seen_page_ids(self) -> None:
    method test_record_page_across_multiple_orgs_and_spaces (line 229) | def test_record_page_across_multiple_orgs_and_spaces(self) -> None:
  class TestLockfileManagerShouldExport (line 250) | class TestLockfileManagerShouldExport:
    method test_page_not_in_lockfile_should_export (line 253) | def test_page_not_in_lockfile_should_export(self) -> None:
    method test_page_in_lockfile_same_version_same_path_should_not_export (line 262) | def test_page_in_lockfile_same_version_same_path_should_not_export(sel...
    method test_page_in_lockfile_different_version_should_export (line 271) | def test_page_in_lockfile_different_version_should_export(self) -> None:
    method test_page_in_lockfile_different_export_path_should_export (line 280) | def test_page_in_lockfile_different_export_path_should_export(self) ->...
    method test_lock_is_none_should_export (line 289) | def test_lock_is_none_should_export(self) -> None:
    method test_missing_output_file_should_export (line 296) | def test_missing_output_file_should_export(self) -> None:
    method test_existing_output_file_unchanged_should_not_export (line 309) | def test_existing_output_file_unchanged_should_not_export(self) -> None:
  class TestLockfileManagerMarkSeen (line 326) | class TestLockfileManagerMarkSeen:
    method test_mark_seen_adds_page_ids (line 329) | def test_mark_seen_adds_page_ids(self) -> None:
    method test_mark_seen_accumulates (line 334) | def test_mark_seen_accumulates(self) -> None:
  class TestLockfileManagerCleanup (line 341) | class TestLockfileManagerCleanup:
    method test_cleanup_noop_when_not_initialized (line 344) | def test_cleanup_noop_when_not_initialized(self) -> None:
    method test_cleanup_deletes_file_for_removed_page (line 348) | def test_cleanup_deletes_file_for_removed_page(self) -> None:
    method test_cleanup_removes_entry_from_lockfile (line 369) | def test_cleanup_removes_entry_from_lockfile(self) -> None:
    method test_cleanup_deletes_old_file_for_moved_page (line 390) | def test_cleanup_deletes_old_file_for_moved_page(self) -> None:
    method test_cleanup_keeps_page_existing_on_confluence (line 413) | def test_cleanup_keeps_page_existing_on_confluence(self) -> None:
    method test_cleanup_keeps_unchanged_seen_pages (line 435) | def test_cleanup_keeps_unchanged_seen_pages(self) -> None:
    method test_cleanup_handles_already_deleted_file (line 451) | def test_cleanup_handles_already_deleted_file(self) -> None:
    method test_cleanup_api_failure_keeps_pages (line 466) | def test_cleanup_api_failure_keeps_pages(self) -> None:
  class TestFetchDeletedPageIds (line 490) | class TestFetchDeletedPageIds:
    method test_empty_input_returns_empty (line 493) | def test_empty_input_returns_empty(self) -> None:
    method test_returns_deleted_ids (line 502) | def test_returns_deleted_ids(
    method test_api_error_returns_no_deleted_ids (line 521) | def test_api_error_returns_no_deleted_ids(
    method test_batches_large_sets (line 538) | def test_batches_large_sets(
  class TestConfluenceLockSave (line 556) | class TestConfluenceLockSave:
    method test_save_is_atomic_on_success (line 559) | def test_save_is_atomic_on_success(self) -> None:
    method test_save_windows_permission_error_fallback (line 576) | def test_save_windows_permission_error_fallback(self) -> None:
    method test_save_cleans_up_tmp_on_error (line 597) | def test_save_cleans_up_tmp_on_error(self) -> None:
    method test_save_preserves_original_on_error (line 617) | def test_save_preserves_original_on_error(self) -> None:
    method test_save_with_delete_ids (line 645) | def test_save_with_delete_ids(self) -> None:
  class TestConfluenceLockSaveSortsKeys (line 662) | class TestConfluenceLockSaveSortsKeys:
    method test_save_sorts_page_keys (line 665) | def test_save_sorts_page_keys(self) -> None:
    method test_save_preserves_model_field_order (line 683) | def test_save_preserves_model_field_order(self) -> None:
    method test_save_sorts_spaces_and_orgs (line 698) | def test_save_sorts_spaces_and_orgs(self) -> None:
  class TestAttachmentEntryTracking (line 725) | class TestAttachmentEntryTracking:
    method test_page_entry_stores_attachments (line 728) | def test_page_entry_stores_attachments(self) -> None:
    method test_page_entry_attachments_default_empty (line 741) | def test_page_entry_attachments_default_empty(self) -> None:
    method test_lock_file_roundtrip_with_attachments (line 746) | def test_lock_file_roundtrip_with_attachments(self) -> None:
    method test_lock_file_missing_attachments_field_loads_as_empty (line 769) | def test_lock_file_missing_attachments_field_loads_as_empty(self) -> N...
    method test_record_page_stores_attachment_entries (line 784) | def test_record_page_stores_attachment_entries(self) -> None:
    method test_get_page_attachment_entries_returns_entries (line 803) | def test_get_page_attachment_entries_returns_entries(self) -> None:
    method test_get_page_attachment_entries_returns_empty_for_unknown_page (line 820) | def test_get_page_attachment_entries_returns_empty_for_unknown_page(se...
    method test_get_page_attachment_entries_returns_empty_when_not_initialized (line 825) | def test_get_page_attachment_entries_returns_empty_when_not_initialize...

FILE: tests/unit/utils/test_measure_time.py
  class TestMeasureTime (line 14) | class TestMeasureTime:
    method test_measure_time_decorator_logs (line 17) | def test_measure_time_decorator_logs(self, caplog: pytest.LogCaptureFi...
    method test_measure_time_with_exception (line 35) | def test_measure_time_with_exception(self, caplog: pytest.LogCaptureFi...
    method test_measure_time_with_return_value (line 52) | def test_measure_time_with_return_value(self) -> None:
    method test_measure_time_with_args_kwargs (line 62) | def test_measure_time_with_args_kwargs(self) -> None:
  class TestMeasureContextManager (line 73) | class TestMeasureContextManager:
    method test_measure_success (line 76) | def test_measure_success(self) -> None:
    method test_measure_with_exception (line 81) | def test_measure_with_exception(self) -> None:
    method test_measure_debug_logs_start (line 91) | def test_measure_debug_logs_start(self, caplog: pytest.LogCaptureFixtu...
    method test_measure_timing_calculation (line 103) | def test_measure_timing_calculation(self, mock_datetime: pytest.Monkey...
    method test_measure_no_exception_propagation (line 113) | def test_measure_no_exception_propagation(self) -> None:

FILE: tests/unit/utils/test_page_registry.py
  function _clean_registry (line 11) | def _clean_registry() -> None:
  function test_unique_title_not_ambiguous (line 17) | def test_unique_title_not_ambiguous() -> None:
  function test_two_pages_same_title_ambiguous (line 22) | def test_two_pages_same_title_ambiguous() -> None:
  function test_unknown_title_not_ambiguous (line 28) | def test_unknown_title_not_ambiguous() -> None:
  function test_re_register_same_id_does_not_inflate_count (line 32) | def test_re_register_same_id_does_not_inflate_count() -> None:
  function test_renaming_page_updates_counts (line 40) | def test_renaming_page_updates_counts() -> None:
  function test_reset_clears_state (line 51) | def test_reset_clears_state() -> None:
  function test_blank_inputs_ignored (line 59) | def test_blank_inputs_ignored() -> None:

FILE: tests/unit/utils/test_rich_console.py
  function test_setup_logging_writes_to_file (line 9) | def test_setup_logging_writes_to_file(tmp_path: Path) -> None:
  function test_setup_logging_without_file_does_not_create_one (line 26) | def test_setup_logging_without_file_does_not_create_one(tmp_path: Path) ...

FILE: tests/unit/utils/test_table_converter.py
  class TestTableConverter (line 8) | class TestTableConverter:
    method test_pipe_character_in_cell (line 11) | def test_pipe_character_in_cell(self) -> None:
    method test_multiple_pipes_in_cell (line 37) | def test_multiple_pipes_in_cell(self) -> None:
    method test_pipe_character_in_header (line 60) | def test_pipe_character_in_header(self) -> None:
    method test_table_without_pipes (line 83) | def test_table_without_pipes(self) -> None:
    method test_convert_p_bool_parent_tags_no_crash (line 110) | def test_convert_p_bool_parent_tags_no_crash(self) -> None:
    method test_convert_ol_bool_parent_tags_no_crash (line 118) | def test_convert_ol_bool_parent_tags_no_crash(self) -> None:
    method test_convert_ul_bool_parent_tags_no_crash (line 126) | def test_convert_ul_bool_parent_tags_no_crash(self) -> None:
    method test_single_item_ul_in_cell_strips_list_symbol (line 134) | def test_single_item_ul_in_cell_strips_list_symbol(self) -> None:
    method test_multi_item_ul_in_cell_keeps_list_symbols (line 152) | def test_multi_item_ul_in_cell_keeps_list_symbols(self) -> None:
    method test_ol_in_cell_with_empty_paragraph_shows_number (line 170) | def test_ol_in_cell_with_empty_paragraph_shows_number(self) -> None:
    method test_ol_in_cell_with_empty_paragraph_respects_start (line 182) | def test_ol_in_cell_with_empty_paragraph_respects_start(self) -> None:
    method test_ol_in_cell_with_content (line 194) | def test_ol_in_cell_with_content(self) -> None:
    method test_ul_in_cell_with_paragraph_items (line 208) | def test_ul_in_cell_with_paragraph_items(self) -> None:
    method test_td_detection_still_works_with_set_parent_tags (line 222) | def test_td_detection_still_works_with_set_parent_tags(self) -> None:

FILE: tests/unit/utils/test_type_converter.py
  class TestStrToBool (line 8) | class TestStrToBool:
    method test_true_values (line 11) | def test_true_values(self) -> None:
    method test_false_values (line 17) | def test_false_values(self) -> None:
    method test_whitespace_handling (line 34) | def test_whitespace_handling(self) -> None:
    method test_invalid_values (line 41) | def test_invalid_values(self) -> None:
    method test_empty_string (line 48) | def test_empty_string(self) -> None:
    method test_none_handling (line 53) | def test_none_handling(self) -> None:
Condensed preview — 91 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (671K chars).
[
  {
    "path": ".dockerignore",
    "chars": 166,
    "preview": ".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\nsc"
  },
  {
    "path": ".github/FUNDING.yml",
    "chars": 65,
    "preview": "# These are supported funding model platforms\n\ngithub: Spenhouet\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/1_bug_report.yaml",
    "chars": 2835,
    "preview": "name: Bug report\ndescription: Report an error or unexpected behavior\nlabels: [\"bug\"]\nbody:\n  - type: markdown\n    attrib"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/2_feature_request.yaml",
    "chars": 2060,
    "preview": "name: Feature request\ndescription: Suggest a new feature or enhancement\nlabels: [\"enhancement\"]\nbody:\n  - type: markdown"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/3_question.yaml",
    "chars": 1219,
    "preview": "name: Question\ndescription: Ask a question about confluence-markdown-exporter\nlabels: [\"question\"]\nbody:\n  - type: texta"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "chars": 194,
    "preview": "blank_issues_enabled: false\ncontact_links:\n  - name: Documentation\n    url: https://github.com/Spenhouet/confluence-mark"
  },
  {
    "path": ".github/PULL_REQUEST_TEMPLATE.md",
    "chars": 450,
    "preview": "<!--\nThank you for contributing to confluence-markdown-exporter! To help us out with reviewing, please consider the foll"
  },
  {
    "path": ".github/dependabot.yml",
    "chars": 178,
    "preview": "version: 2\nupdates:\n  - package-ecosystem: \"github-actions\"\n    directory: \"/\"\n    schedule:\n      interval: \"daily\"\n   "
  },
  {
    "path": ".github/workflows/docker-build.yml",
    "chars": 1280,
    "preview": "name: Build Docker image\n\non:\n  pull_request:\n    branches: [main]\n    paths:\n      - Dockerfile\n      - .dockerignore\n "
  },
  {
    "path": ".github/workflows/docker-publish.yml",
    "chars": 2902,
    "preview": "name: Publish Docker image\n\non:\n  workflow_call:\n    inputs:\n      version:\n        description: \"Release version to pub"
  },
  {
    "path": ".github/workflows/docs.yml",
    "chars": 1341,
    "preview": "name: Deploy docs\n\non:\n  push:\n    branches: [main]\n    paths:\n      - \"docs/**\"\n      - \"versioned_docs/**\"\n      - \"ve"
  },
  {
    "path": ".github/workflows/python-build.yml",
    "chars": 1327,
    "preview": "name: Build Python package\n\non:\n  push:\n    branches: [main]\n  pull_request:\n    branches: [main]\n\njobs:\n  test:\n    nam"
  },
  {
    "path": ".github/workflows/python-publish.yml",
    "chars": 1633,
    "preview": "name: Publish Python package\n\non:\n  workflow_call:\n    inputs:\n      version:\n        description: \"Release version to p"
  },
  {
    "path": ".github/workflows/release.yml",
    "chars": 3263,
    "preview": "name: Release\n\non:\n  workflow_dispatch:\n    inputs:\n      version_bump:\n        description: \"Version bump type\"\n       "
  },
  {
    "path": ".gitignore",
    "chars": 2262,
    "preview": "### 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 "
  },
  {
    "path": ".python-version",
    "chars": 8,
    "preview": "3.10.12\n"
  },
  {
    "path": ".vscode/extensions.json",
    "chars": 565,
    "preview": "{\n  // See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations.\n  // Extension ident"
  },
  {
    "path": ".vscode/launch.json",
    "chars": 3382,
    "preview": "{\r\n  // Use IntelliSense to learn about possible attributes.\r\n  // Hover to view descriptions of existing attributes.\r\n "
  },
  {
    "path": ".vscode/settings.json",
    "chars": 1622,
    "preview": "{\r\n  \"files.eol\": \"\\n\",\r\n  \"editor.formatOnSave\": true,\r\n  \"autoDocstring.docstringFormat\": \"google\",\r\n  \"autoDocstring."
  },
  {
    "path": ".vscode/tasks.json",
    "chars": 39,
    "preview": "{\n  \"version\": \"2.0.0\",\n  \"tasks\": []\n}"
  },
  {
    "path": "CONTRIBUTING.md",
    "chars": 6065,
    "preview": "# Contributing\n\nAny contribution is welcome! This document provides guidelines for contributing to the confluence-markdo"
  },
  {
    "path": "Dockerfile",
    "chars": 1965,
    "preview": "# syntax=docker/dockerfile:1.7\n\n# ---- builder ---------------------------------------------------------------\nFROM pyth"
  },
  {
    "path": "LICENSE",
    "chars": 1075,
    "preview": "MIT License\n\nCopyright (c) 2025 Sebastian Penhouet\n\nPermission is hereby granted, free of charge, to any person obtainin"
  },
  {
    "path": "README.md",
    "chars": 5332,
    "preview": "<p align=\"center\">\n  <a href=\"https://github.com/Spenhouet/confluence-markdown-exporter\"><img src=\"https://raw.githubuse"
  },
  {
    "path": "confluence_markdown_exporter/__init__.py",
    "chars": 280,
    "preview": "\"\"\"Confluence Markdown Exporter package.\"\"\"\n\ntry:\n    from importlib.metadata import version\n\n    __version__ = version("
  },
  {
    "path": "confluence_markdown_exporter/api_clients.py",
    "chars": 14122,
    "preview": "import logging\nimport re\nimport urllib.parse\nfrom threading import Lock\nfrom threading import local\nfrom typing import A"
  },
  {
    "path": "confluence_markdown_exporter/config.py",
    "chars": 14103,
    "preview": "\"\"\"Config sub-app for the cme CLI.\"\"\"\n\nimport json\nimport logging\nfrom typing import Annotated\n\nimport jmespath\nimport t"
  },
  {
    "path": "confluence_markdown_exporter/confluence.py",
    "chars": 118615,
    "preview": "\"\"\"Confluence API documentation.\n\nhttps://developer.atlassian.com/cloud/confluence/rest/v1/intro\n\"\"\"\n\nimport functools\ni"
  },
  {
    "path": "confluence_markdown_exporter/main.py",
    "chars": 18426,
    "preview": "import json\nimport logging\nimport platform\nimport sys\nimport urllib.parse\nfrom typing import Annotated\n\nimport typer\nimp"
  },
  {
    "path": "confluence_markdown_exporter/utils/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "confluence_markdown_exporter/utils/app_data_store.py",
    "chars": 35526,
    "preview": "\"\"\"Handles storage and retrieval of application data (auth and settings) for the exporter.\"\"\"\n\nimport contextlib\nimport "
  },
  {
    "path": "confluence_markdown_exporter/utils/config_interactive.py",
    "chars": 30221,
    "preview": "from pathlib import Path\nfrom typing import Literal\nfrom typing import get_args\nfrom typing import get_origin\n\nimport jm"
  },
  {
    "path": "confluence_markdown_exporter/utils/drawio_converter.py",
    "chars": 3940,
    "preview": "\"\"\"Utility module for parsing DrawIO files and extracting mermaid diagrams.\"\"\"\n\nimport html\nimport json\nimport logging\nf"
  },
  {
    "path": "confluence_markdown_exporter/utils/export.py",
    "chars": 5268,
    "preview": "import json\nimport logging\nimport re\nfrom pathlib import Path\n\nfrom confluence_markdown_exporter.utils.app_data_store im"
  },
  {
    "path": "confluence_markdown_exporter/utils/lockfile.py",
    "chars": 12739,
    "preview": "\"\"\"Lock file handling for tracking exported Confluence pages.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport"
  },
  {
    "path": "confluence_markdown_exporter/utils/measure_time.py",
    "chars": 2860,
    "preview": "import logging\nimport time\nfrom collections.abc import Callable\nfrom collections.abc import Generator\nfrom contextlib im"
  },
  {
    "path": "confluence_markdown_exporter/utils/page_registry.py",
    "chars": 1877,
    "preview": "\"\"\"Cross-space page title registry for link disambiguation.\n\nConfluence enforces page-title uniqueness per space, not ac"
  },
  {
    "path": "confluence_markdown_exporter/utils/rich_console.py",
    "chars": 12505,
    "preview": "\"\"\"Shared rich console, logging setup, and export statistics tracking.\"\"\"\n\nimport logging\nimport threading\nfrom dataclas"
  },
  {
    "path": "confluence_markdown_exporter/utils/table_converter.py",
    "chars": 5873,
    "preview": "import re\nfrom typing import cast\n\nfrom bs4 import BeautifulSoup\nfrom bs4 import Tag\nfrom markdownify import MarkdownCon"
  },
  {
    "path": "confluence_markdown_exporter/utils/type_converter.py",
    "chars": 358,
    "preview": "def str_to_bool(value: str) -> bool:\n    \"\"\"Convert a string to boolean.\"\"\"\n    true_set = {\"true\", \"1\", \"yes\", \"on\"}\n  "
  },
  {
    "path": "docs/compatibility.md",
    "chars": 506,
    "preview": "---\nid: compatibility\ntitle: Compatibility\nsidebar_position: 5\n---\n\n# Compatibility\n\nThis package is not tested extensiv"
  },
  {
    "path": "docs/configuration/authentication.md",
    "chars": 2675,
    "preview": "---\nid: authentication\ntitle: Authentication\nsidebar_position: 3\n---\n\n# Authentication\n\n:::note\nAuth credentials use URL"
  },
  {
    "path": "docs/configuration/ci.md",
    "chars": 1866,
    "preview": "---\nid: ci\ntitle: Running in CI\nsidebar_label: CI / non-interactive\nsidebar_position: 5\n---\n\n# Running in CI / non-inter"
  },
  {
    "path": "docs/configuration/index.md",
    "chars": 4093,
    "preview": "---\nid: index\ntitle: Configuration\nslug: /configuration/\nsidebar_position: 1\n---\n\n# Configuration\n\nAll configuration and"
  },
  {
    "path": "docs/configuration/options.md",
    "chars": 18345,
    "preview": "---\nid: options\ntitle: Configuration options\nsidebar_label: Options reference\nsidebar_position: 2\n---\n\n# Configuration o"
  },
  {
    "path": "docs/contributing.md",
    "chars": 1980,
    "preview": "---\nid: contributing\ntitle: Contributing\nsidebar_position: 7\n---\n\n# Contributing\n\nIf you would like to contribute to `co"
  },
  {
    "path": "docs/docker.md",
    "chars": 4043,
    "preview": "---\nid: docker\ntitle: Docker\nsidebar_position: 5\n---\n\n# Docker\n\nPrebuilt images are published to Docker Hub at [`spenhou"
  },
  {
    "path": "docs/features.md",
    "chars": 2338,
    "preview": "---\nid: features\ntitle: Features\nsidebar_position: 3\n---\n\n# Features\n\nExports individual pages, pages with descendants, "
  },
  {
    "path": "docs/installation.md",
    "chars": 3991,
    "preview": "---\nid: installation\ntitle: Installation\nsidebar_position: 1\n---\n\nimport Tabs from '@theme/Tabs';\nimport TabItem from '@"
  },
  {
    "path": "docs/intro.md",
    "chars": 2598,
    "preview": "---\nid: intro\ntitle: Introduction\nsidebar_position: 1\n---\n\nimport Tabs from '@theme/Tabs';\nimport TabItem from '@theme/T"
  },
  {
    "path": "docs/troubleshooting.md",
    "chars": 1489,
    "preview": "---\nid: troubleshooting\ntitle: Troubleshooting\nsidebar_position: 6\n---\n\n# Troubleshooting\n\n## Known issues and limitatio"
  },
  {
    "path": "docs/usage.md",
    "chars": 2788,
    "preview": "---\nid: usage\ntitle: Usage\nsidebar_position: 2\n---\n\n# Usage\n\nRun the exporter with the desired Confluence page URL or sp"
  },
  {
    "path": "docusaurus.config.ts",
    "chars": 5235,
    "preview": "import { themes as prismThemes } from \"prism-react-renderer\";\nimport type { Config } from \"@docusaurus/types\";\nimport ty"
  },
  {
    "path": "package.json",
    "chars": 1295,
    "preview": "{\n  \"name\": \"confluence-markdown-exporter-docs\",\n  \"version\": \"0.0.0\",\n  \"private\": true,\n  \"scripts\": {\n    \"docusaurus"
  },
  {
    "path": "pyproject.toml",
    "chars": 5934,
    "preview": "[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n\n[project]\nname = \"confluence-markdown-exporte"
  },
  {
    "path": "scripts/build-versions.mjs",
    "chars": 3555,
    "preview": "#!/usr/bin/env node\n/**\n * Build the Docusaurus site with per-tag versioned docs derived from git history.\n *\n * Strateg"
  },
  {
    "path": "scripts/bump-docs-version.sh",
    "chars": 2191,
    "preview": "#!/usr/bin/env bash\n# Bump every version-pinning reference in README and the documentation tree.\n#\n# Patterns rewritten:"
  },
  {
    "path": "sidebars.ts",
    "chars": 705,
    "preview": "import type { SidebarsConfig } from \"@docusaurus/plugin-content-docs\";\n\nconst sidebars: SidebarsConfig = {\n  docsSidebar"
  },
  {
    "path": "src/components/HomepageFeatures/index.tsx",
    "chars": 2603,
    "preview": "import React, { type ReactNode } from \"react\";\nimport clsx from \"clsx\";\nimport Link from \"@docusaurus/Link\";\nimport styl"
  },
  {
    "path": "src/components/HomepageFeatures/styles.module.css",
    "chars": 332,
    "preview": ".features {\n  padding: 4rem 0;\n  width: 100%;\n}\n\n.featureCol {\n  margin-bottom: 1.5rem;\n  text-decoration: none !importa"
  },
  {
    "path": "src/components/quickstart/index.tsx",
    "chars": 3005,
    "preview": "import React, { type ReactNode } from \"react\";\nimport Tabs from \"@theme/Tabs\";\nimport TabItem from \"@theme/TabItem\";\nimp"
  },
  {
    "path": "src/css/custom.css",
    "chars": 6200,
    "preview": "/**\n * Theme overrides for Docusaurus Infima.\n * Primary palette tuned for a modern docs look.\n */\n\n:root {\n  --ifm-colo"
  },
  {
    "path": "src/pages/index.module.css",
    "chars": 855,
    "preview": ".heroBanner {\n  text-align: center;\n}\n\n.buttons {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  g"
  },
  {
    "path": "src/pages/index.tsx",
    "chars": 4345,
    "preview": "import React, { type ReactNode } from \"react\";\nimport clsx from \"clsx\";\nimport Link from \"@docusaurus/Link\";\nimport useD"
  },
  {
    "path": "tests/__init__.py",
    "chars": 48,
    "preview": "# Test package for confluence-markdown-exporter\n"
  },
  {
    "path": "tests/conftest.py",
    "chars": 9202,
    "preview": "\"\"\"Shared test fixtures and configuration for confluence-markdown-exporter tests.\"\"\"\n\nimport importlib\nimport os\nimport "
  },
  {
    "path": "tests/integration/__init__.py",
    "chars": 58,
    "preview": "\"\"\"Integration tests for confluence-markdown-exporter.\"\"\"\n"
  },
  {
    "path": "tests/integration/test_cli_integration.py",
    "chars": 3323,
    "preview": "\"\"\"Basic tests for confluence-markdown-exporter package.\"\"\"\n\nimport subprocess\nimport sys\n\nimport pytest\n\nimport conflue"
  },
  {
    "path": "tests/unit/__init__.py",
    "chars": 51,
    "preview": "\"\"\"Unit tests for confluence-markdown-exporter.\"\"\"\n"
  },
  {
    "path": "tests/unit/test_alert_conversion.py",
    "chars": 6076,
    "preview": "\"\"\"Test Confluence alert/panel macro conversion.\"\"\"\n\nfrom __future__ import annotations\n\nfrom typing import TYPE_CHECKIN"
  },
  {
    "path": "tests/unit/test_api_clients.py",
    "chars": 14651,
    "preview": "\"\"\"Unit tests for api_clients module.\"\"\"\n\nimport urllib.parse\nfrom unittest.mock import MagicMock\nfrom unittest.mock imp"
  },
  {
    "path": "tests/unit/test_confluence.py",
    "chars": 71750,
    "preview": "\"\"\"Unit tests for confluence module URL resolution.\"\"\"\n\nfrom __future__ import annotations\n\nimport types\nfrom pathlib im"
  },
  {
    "path": "tests/unit/test_emoticon_conversion.py",
    "chars": 3378,
    "preview": "\"\"\"Test that Confluence emoticon img tags are converted to unicode emoji.\"\"\"\n\nfrom __future__ import annotations\n\nfrom t"
  },
  {
    "path": "tests/unit/test_include_macro_conversion.py",
    "chars": 5613,
    "preview": "\"\"\"Unit tests for `include` / `excerpt-include` macro conversion.\"\"\"\n\nfrom unittest.mock import MagicMock\nfrom unittest."
  },
  {
    "path": "tests/unit/test_main.py",
    "chars": 1574,
    "preview": "\"\"\"Unit tests for main module.\"\"\"\n\nimport pytest\nimport typer\n\nfrom confluence_markdown_exporter.main import app\nfrom co"
  },
  {
    "path": "tests/unit/test_nbsp_fix.py",
    "chars": 8472,
    "preview": "\"\"\"Test that Unicode whitespace (especially &nbsp;) is preserved in inline formatting.\"\"\"\n\nfrom __future__ import annota"
  },
  {
    "path": "tests/unit/test_plantuml_code_block_detection.py",
    "chars": 3179,
    "preview": "\"\"\"Unit tests for PlantUML auto-detection in code blocks.\"\"\"\n\nfrom unittest.mock import MagicMock\nfrom unittest.mock imp"
  },
  {
    "path": "tests/unit/test_plantuml_conversion.py",
    "chars": 11398,
    "preview": "\"\"\"Unit tests for PlantUML diagram conversion.\"\"\"\n\nfrom unittest.mock import MagicMock\nfrom unittest.mock import patch\n\n"
  },
  {
    "path": "tests/unit/test_template_placeholders.py",
    "chars": 5512,
    "preview": "\"\"\"Test that <template> placeholders are escaped for Obsidian compatibility.\"\"\"\n\nfrom __future__ import annotations\n\nfro"
  },
  {
    "path": "tests/unit/utils/__init__.py",
    "chars": 35,
    "preview": "\"\"\"Unit tests for utils module.\"\"\"\n"
  },
  {
    "path": "tests/unit/utils/test_app_data_store_env.py",
    "chars": 15192,
    "preview": "\"\"\"Tests for ENV var override support in AppSettings.\"\"\"\n\nimport os\nimport tempfile\nfrom pathlib import Path\nfrom unitte"
  },
  {
    "path": "tests/unit/utils/test_drawio_converter.py",
    "chars": 5141,
    "preview": "\"\"\"Tests for DrawIO converter functionality.\"\"\"\n\nfrom pathlib import Path\n\nfrom confluence_markdown_exporter.utils.drawi"
  },
  {
    "path": "tests/unit/utils/test_export.py",
    "chars": 13911,
    "preview": "\"\"\"Unit tests for export module.\"\"\"\n\nimport tempfile\nfrom pathlib import Path\nfrom unittest.mock import MagicMock\nfrom u"
  },
  {
    "path": "tests/unit/utils/test_lockfile.py",
    "chars": 35550,
    "preview": "\"\"\"Unit tests for lockfile module.\"\"\"\n\nimport json\nimport tempfile\nfrom pathlib import Path\nfrom unittest.mock import Ma"
  },
  {
    "path": "tests/unit/utils/test_measure_time.py",
    "chars": 4416,
    "preview": "\"\"\"Unit tests for the measure_time module.\"\"\"\n\nimport logging\nimport time\nfrom datetime import datetime\nfrom unittest.mo"
  },
  {
    "path": "tests/unit/utils/test_page_registry.py",
    "chars": 2078,
    "preview": "\"\"\"Tests for PageTitleRegistry collision detection.\"\"\"\n\nfrom __future__ import annotations\n\nimport pytest\n\nfrom confluen"
  },
  {
    "path": "tests/unit/utils/test_rich_console.py",
    "chars": 1004,
    "preview": "\"\"\"Tests for the logging helpers in rich_console.\"\"\"\n\nimport logging\nfrom pathlib import Path\n\nfrom confluence_markdown_"
  },
  {
    "path": "tests/unit/utils/test_table_converter.py",
    "chars": 7834,
    "preview": "\"\"\"Tests for the table_converter module.\"\"\"\n\nfrom bs4 import BeautifulSoup\n\nfrom confluence_markdown_exporter.utils.tabl"
  },
  {
    "path": "tests/unit/utils/test_type_converter.py",
    "chars": 2027,
    "preview": "\"\"\"Unit tests for type_converter module.\"\"\"\n\nimport pytest\n\nfrom confluence_markdown_exporter.utils.type_converter impor"
  },
  {
    "path": "tsconfig.json",
    "chars": 142,
    "preview": "{\n  \"extends\": \"@docusaurus/tsconfig\",\n  \"compilerOptions\": {\n    \"baseUrl\": \".\"\n  },\n  \"exclude\": [\"build\", \".docusauru"
  }
]

// ... and 1 more files (download for full content)

About this extraction

This page contains the full source code of the Spenhouet/confluence-markdown-exporter GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 91 files (617.7 KB), approximately 147.7k tokens, and a symbol index with 845 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!